From 8c4a59eadbf60df4e51b9cd7ea05771a21444b9f Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sat, 5 Sep 2026 23:50:16 -0700 Subject: [PATCH 01/24] Add tests for catalog-tools offering write tools Covers the happy path, a 403 from the hub, and an unknown canonical name/provider name for create_offering, set_offering_priority, and disable_offering ahead of the implementation. --- packages/catalog-tools/src/tool.test.ts | 226 ++++++++++++++++++++++++ 1 file changed, 226 insertions(+) diff --git a/packages/catalog-tools/src/tool.test.ts b/packages/catalog-tools/src/tool.test.ts index d8b13488a..f170576fa 100644 --- a/packages/catalog-tools/src/tool.test.ts +++ b/packages/catalog-tools/src/tool.test.ts @@ -2,11 +2,16 @@ import { describe, expect, test } from "bun:test"; import type { ToolCall } from "@intx/types/runtime"; import { + catalogOfferingTools, catalogTools, + CREATE_OFFERING_TOOL, + DISABLE_OFFERING_TOOL, ESTIMATE_RUN_COST_TOOL, LIST_MODEL_CONCEPTS_TOOL, PICK_MODELS_TOOL, + SET_OFFERING_PRIORITY_TOOL, type WorkflowCatalogEnv, + type WorkflowCatalogOfferingEnv, } from "./tool"; const CHAIN_BODY = { @@ -234,3 +239,224 @@ describe("list_model_concepts", () => { expect(content).toContain("nothing here can do it yet"); }); }); + +function offeringEnv(): WorkflowCatalogOfferingEnv { + return { + hubCatalogUrl: "https://hub.example.com", + sidecarToken: "sc-token", + address: "run_1@workflow", + tenantId: "tenant_1", + } as unknown as WorkflowCatalogOfferingEnv; +} + +const OWN_MODELS_PAGE = { + data: [ + { + id: "model_1", + tenantId: "tenant_1", + canonicalName: "thrifty", + displayName: null, + description: null, + disabled: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + nextCursor: null, +}; + +const OWN_PROVIDERS_PAGE = { + data: [ + { + id: "provider_1", + tenantId: "tenant_1", + name: "globex", + plugin: "openai-compatible", + baseURL: "https://globex.example.com", + credentialId: "cred_1", + walletId: null, + disabled: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + nextCursor: null, +}; + +const OFFERING_ROW = { + id: "offering_1", + tenantId: "tenant_1", + modelId: "model_1", + providerId: "provider_1", + priority: 0, + deploymentTags: [], + capabilities: ["plain-text"], + quirks: null, + disabled: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +/** Routes a stub fetch by which tenant-admin path it hits, rather than + * one fixed body — `create_offering` makes several calls in sequence + * (list models, list providers, then create). */ +function routedFetch( + handlers: Record Response>, +): typeof fetch { + return (async (input: string) => { + const url = new URL(input); + for (const [suffix, handler] of Object.entries(handlers)) { + if (url.pathname.endsWith(suffix)) return handler(); + } + throw new Error(`unstubbed request: ${url.pathname}`); + }) as unknown as typeof fetch; +} + +function ok(body: unknown, status = 200): () => Response { + return () => new Response(JSON.stringify(body), { status }); +} + +function forbidden(): Response { + return new Response( + JSON.stringify({ + error: { + code: "forbidden", + userMessage: "You do not have permission to perform this action", + refId: "ref_test", + }, + }), + { status: 403 }, + ); +} + +describe("the offering bundle's shape", () => { + test("gates create_offering and disable_offering behind approval, not set_offering_priority", () => { + expect(catalogOfferingTools.definitions).toEqual([ + { name: CREATE_OFFERING_TOOL, approval: "ask" }, + { name: SET_OFFERING_PRIORITY_TOOL }, + { name: DISABLE_OFFERING_TOOL, approval: "ask" }, + ]); + }); + + test("requires the tenant id alongside the sanctioned workflow-run env keys", () => { + expect(catalogOfferingTools.requires).toEqual([ + "hubCatalogUrl", + "sidecarToken", + "address", + "tenantId", + ]); + }); +}); + +describe("create_offering", () => { + test("resolves both names to ids, then creates the offering", async () => { + globalThis.fetch = routedFetch({ + "/catalog/models": ok(OWN_MODELS_PAGE), + "/catalog/providers": ok(OWN_PROVIDERS_PAGE), + "/catalog/offerings": ok(OFFERING_ROW, 201), + }); + const result = await catalogOfferingTools(offeringEnv()).run( + callFor(CREATE_OFFERING_TOOL, { + modelCanonicalName: "thrifty", + providerName: "globex", + priority: 0, + capabilities: ["plain-text"], + }), + new AbortController().signal, + ); + expect(result.isError).toBe(false); + expect(String(result.content)).toContain("offering_1"); + }); + + test("an unknown canonical name is refused, never a guessed id", async () => { + globalThis.fetch = routedFetch({ + "/catalog/models": ok({ data: [], nextCursor: null }), + "/catalog/providers": ok(OWN_PROVIDERS_PAGE), + }); + const result = await catalogOfferingTools(offeringEnv()).run( + callFor(CREATE_OFFERING_TOOL, { + modelCanonicalName: "does-not-exist", + providerName: "globex", + }), + new AbortController().signal, + ); + expect(result.isError).toBe(true); + expect(String(result.content)).toContain( + 'No model named "does-not-exist"', + ); + }); + + test("a 403 from the hub surfaces as a refusal, not a thrown crash", async () => { + globalThis.fetch = routedFetch({ + "/catalog/models": () => forbidden(), + "/catalog/providers": ok(OWN_PROVIDERS_PAGE), + }); + const result = await catalogOfferingTools(offeringEnv()).run( + callFor(CREATE_OFFERING_TOOL, { + modelCanonicalName: "thrifty", + providerName: "globex", + }), + new AbortController().signal, + ); + expect(result.isError).toBe(true); + expect(String(result.content)).toContain( + "You do not have permission to perform this action", + ); + }); +}); + +describe("set_offering_priority", () => { + test("reorders an offering this workbench already owns", async () => { + globalThis.fetch = routedFetch({ + "/catalog/offerings/offering_1": ok({ ...OFFERING_ROW, priority: 5 }), + }); + const result = await catalogOfferingTools(offeringEnv()).run( + callFor(SET_OFFERING_PRIORITY_TOOL, { + offeringId: "offering_1", + priority: 5, + }), + new AbortController().signal, + ); + expect(result.isError).toBe(false); + expect(String(result.content)).toContain("priority 5"); + }); + + test("a 403 from the hub surfaces as a refusal", async () => { + globalThis.fetch = routedFetch({ + "/catalog/offerings/offering_1": () => forbidden(), + }); + const result = await catalogOfferingTools(offeringEnv()).run( + callFor(SET_OFFERING_PRIORITY_TOOL, { + offeringId: "offering_1", + priority: 5, + }), + new AbortController().signal, + ); + expect(result.isError).toBe(true); + }); +}); + +describe("disable_offering", () => { + test("restricts an offering this workbench already owns", async () => { + globalThis.fetch = routedFetch({ + "/catalog/offerings/offering_1": ok({ ...OFFERING_ROW, disabled: true }), + }); + const result = await catalogOfferingTools(offeringEnv()).run( + callFor(DISABLE_OFFERING_TOOL, { offeringId: "offering_1" }), + new AbortController().signal, + ); + expect(result.isError).toBe(false); + expect(String(result.content)).toContain("now disabled"); + }); + + test("a 403 from the hub surfaces as a refusal", async () => { + globalThis.fetch = routedFetch({ + "/catalog/offerings/offering_1": () => forbidden(), + }); + const result = await catalogOfferingTools(offeringEnv()).run( + callFor(DISABLE_OFFERING_TOOL, { offeringId: "offering_1" }), + new AbortController().signal, + ); + expect(result.isError).toBe(true); + }); +}); From ec0e2575e580d4feccaeb4eca93524cafaa66975 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sat, 5 Sep 2026 23:50:22 -0700 Subject: [PATCH 02/24] catalog-tools: add create_offering, set_offering_priority, disable_offering Lets an agent manage the tenant's own inference catalog offerings through the existing tenant-admin catalog routes, resolving a model's canonical name and a provider's name against this tenant's own catalog before writing, and refusing rather than guessing when either name isn't found. create_offering and disable_offering require approval; set_offering_priority only reorders an offering already live. Seeds the model/model-provider/model-offering read, create, and manage grants these routes require. --- bun.lock | 11 +- packages/catalog-tools/package.json | 3 +- packages/catalog-tools/src/client.ts | 197 +++++++++++++++++- packages/catalog-tools/src/index.ts | 8 + packages/catalog-tools/src/tool.ts | 294 +++++++++++++++++++++++++++ packages/seeding/src/seed.ts | 11 + 6 files changed, 513 insertions(+), 11 deletions(-) diff --git a/bun.lock b/bun.lock index 3e7830334..60e38eca2 100644 --- a/bun.lock +++ b/bun.lock @@ -408,8 +408,9 @@ }, "packages/catalog-tools": { "name": "@corbits/catalog-tools", - "version": "0.0.2", + "version": "0.0.3", "dependencies": { + "@corbits/error-sink": "workspace:*", "@intx/agent": "workspace:*", "@intx/types": "workspace:*", "arktype": "catalog:", @@ -3563,10 +3564,6 @@ "@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/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=="], @@ -3589,10 +3586,6 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], - "@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=="], - "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=="], diff --git a/packages/catalog-tools/package.json b/packages/catalog-tools/package.json index 842ccfef6..59df51047 100644 --- a/packages/catalog-tools/package.json +++ b/packages/catalog-tools/package.json @@ -2,7 +2,7 @@ "name": "@corbits/catalog-tools", "private": true, "description": "The list_model_concepts, pick_models and estimate_run_cost tools as an @intx/agent bundle: an agent asks for a model by what the work needs and gets back an ordered, priced chain of what this bench can actually reach — never a model named by hand.", - "version": "0.0.2", + "version": "0.0.3", "license": "LGPL-2.1-or-later", "type": "module", "exports": { @@ -13,6 +13,7 @@ "test": "bun test" }, "dependencies": { + "@corbits/error-sink": "workspace:*", "@intx/agent": "workspace:*", "@intx/types": "workspace:*", "arktype": "catalog:" diff --git a/packages/catalog-tools/src/client.ts b/packages/catalog-tools/src/client.ts index 487eb8737..267026b36 100644 --- a/packages/catalog-tools/src/client.ts +++ b/packages/catalog-tools/src/client.ts @@ -6,7 +6,15 @@ // Authenticated the same way: sidecar bearer token plus run address, never a // human browser session. import { type } from "arktype"; -import { Capability } from "@intx/types"; +import { + Capability, + CreateModelOffering, + ModelOfferingResponse, + ModelProviderResponse, + ModelResponse, + UpdateModelOffering, + paginatedSchema, +} from "@intx/types"; export interface CatalogToolClientConfig { /** The hub's plain HTTP origin — the same value connections-tools' @@ -175,3 +183,190 @@ export async function fetchEstimate( request, ); } + +// --- Tenant catalog administration ----------------------------------- +// +// `list_model_concepts`/`pick_models`/`estimate_run_cost` above read the +// run-authenticated `/api/workflow-inference-catalog` surface. The tools +// below instead write through the tenant-admin surface Interchange's own +// `apps/hub-web` settings UI uses directly: +// `vendor/intx/hub-api/src/routes/{models,model-providers,model-offerings}.ts`, +// mounted at `/api/tenants/:id/catalog/{models,providers,offerings}`. They +// carry the same sidecar bearer token and run address as every other call +// in this file — the hub is expected to resolve that credential to this +// tenant's own principal for these routes exactly as it already does for +// `/api/workflow-inference-catalog`. + +export interface CatalogAdminClientConfig { + /** Same hub origin as {@link CatalogToolClientConfig.hubCatalogUrl}. */ + readonly hubCatalogUrl: string; + readonly sidecarToken: string; + readonly address: string; + /** The tenant this run belongs to — the tenant-admin routes are scoped + * by this id in their URL path. */ + readonly tenantId: string; + /** Override for tests; defaults to the global `fetch`. */ + readonly fetchImpl?: typeof fetch; +} + +/** Thrown when no model this tenant owns directly matches the requested + * canonical name. */ +export class UnknownCanonicalNameError extends Error {} + +/** Thrown when no model-provider this tenant owns directly matches the + * requested name. */ +export class UnknownProviderNameError extends Error {} + +const ModelsPage = paginatedSchema(ModelResponse); +const ModelProvidersPage = paginatedSchema(ModelProviderResponse); + +function adminAuthHeaders( + config: CatalogAdminClientConfig, +): Record { + return { + authorization: `Bearer ${config.sidecarToken}`, + "x-workflow-run-address": config.address, + "content-type": "application/json", + }; +} + +async function adminCall( + config: CatalogAdminClientConfig, + path: string, + what: string, + schema: (body: unknown) => T | type.errors, + init?: RequestInit, +): Promise { + const doFetch = config.fetchImpl ?? fetch; + const response = await doFetch( + `${config.hubCatalogUrl}/api/tenants/${config.tenantId}${path}`, + { ...init, headers: { ...adminAuthHeaders(config), ...init?.headers } }, + ); + if (!response.ok) { + const detail: unknown = await response.json().catch(() => undefined); + const message = + errorMessageFrom(detail) ?? `${response.status} ${response.statusText}`; + throw new Error(`${what} failed: ${message}`); + } + const parsed = schema(await response.json()); + if (parsed instanceof type.errors) { + throw new Error( + `${what} came back in an unexpected shape: ${parsed.summary}`, + ); + } + return parsed; +} + +/** Finds the id of a model this tenant owns directly by its canonical + * name, paging through the tenant's own model list. Throws + * {@link UnknownCanonicalNameError} rather than inventing an id when + * nothing matches. */ +export async function findModelIdByCanonicalName( + config: CatalogAdminClientConfig, + canonicalName: string, +): Promise { + let cursor: string | undefined; + do { + const qs = cursor + ? `?limit=100&cursor=${encodeURIComponent(cursor)}` + : "?limit=100"; + const page = await adminCall( + config, + `/catalog/models${qs}`, + "Listing this workbench's own models", + (raw) => ModelsPage(raw), + ); + const found = page.data.find((row) => row.canonicalName === canonicalName); + if (found !== undefined) return found.id; + cursor = page.nextCursor ?? undefined; + } while (cursor !== undefined); + throw new UnknownCanonicalNameError( + `No model named "${canonicalName}" in this workbench's own catalog.`, + ); +} + +/** Finds the id of a model-provider this tenant owns directly by its + * name, paging through the tenant's own provider list. Throws + * {@link UnknownProviderNameError} rather than inventing an id when + * nothing matches. */ +export async function findModelProviderIdByName( + config: CatalogAdminClientConfig, + providerName: string, +): Promise { + let cursor: string | undefined; + do { + const qs = cursor + ? `?limit=100&cursor=${encodeURIComponent(cursor)}` + : "?limit=100"; + const page = await adminCall( + config, + `/catalog/providers${qs}`, + "Listing this workbench's own model providers", + (raw) => ModelProvidersPage(raw), + ); + const found = page.data.find((row) => row.name === providerName); + if (found !== undefined) return found.id; + cursor = page.nextCursor ?? undefined; + } while (cursor !== undefined); + throw new UnknownProviderNameError( + `No model provider named "${providerName}" in this workbench's own catalog.`, + ); +} + +export type CreateOfferingRequest = { + readonly modelId: string; + readonly providerId: string; + readonly priority?: number; + readonly capabilities?: readonly Capability[]; +}; + +/** Creates a tenant-owned offering pairing a tenant-owned model with a + * tenant-owned provider. */ +export async function createOffering( + config: CatalogAdminClientConfig, + request: CreateOfferingRequest, +): Promise { + return await adminCall( + config, + "/catalog/offerings", + "Creating the offering", + (raw) => ModelOfferingResponse(raw), + { method: "POST", body: JSON.stringify(CreateModelOffering.assert(request)) }, + ); +} + +/** Reorders an offering this tenant already owns directly. */ +export async function setOfferingPriority( + config: CatalogAdminClientConfig, + offeringId: string, + priority: number, +): Promise { + return await adminCall( + config, + `/catalog/offerings/${offeringId}`, + "Setting the offering's priority", + (raw) => ModelOfferingResponse(raw), + { + method: "PATCH", + body: JSON.stringify(UpdateModelOffering.assert({ priority })), + }, + ); +} + +/** Restricts an offering this tenant already owns directly, taking it out + * of resolution without deleting its pricing history. */ +export async function disableOffering( + config: CatalogAdminClientConfig, + offeringId: string, +): Promise { + return await adminCall( + config, + `/catalog/offerings/${offeringId}`, + "Disabling the offering", + (raw) => ModelOfferingResponse(raw), + { + method: "PATCH", + body: JSON.stringify(UpdateModelOffering.assert({ disabled: true })), + }, + ); +} diff --git a/packages/catalog-tools/src/index.ts b/packages/catalog-tools/src/index.ts index 2359443de..9b4d21e07 100644 --- a/packages/catalog-tools/src/index.ts +++ b/packages/catalog-tools/src/index.ts @@ -2,6 +2,9 @@ export { fetchChain, fetchEstimate, listConcepts, + UnknownCanonicalNameError, + UnknownProviderNameError, + type CatalogAdminClientConfig, type CatalogToolClientConfig, type ChainEntry, type ConceptSummary, @@ -9,10 +12,15 @@ export { type ModelChainResult, } from "./client"; export { + catalogOfferingTools, catalogTools, describeChain, + CREATE_OFFERING_TOOL, + DISABLE_OFFERING_TOOL, ESTIMATE_RUN_COST_TOOL, LIST_MODEL_CONCEPTS_TOOL, PICK_MODELS_TOOL, + SET_OFFERING_PRIORITY_TOOL, type WorkflowCatalogEnv, + type WorkflowCatalogOfferingEnv, } from "./tool"; diff --git a/packages/catalog-tools/src/tool.ts b/packages/catalog-tools/src/tool.ts index 814522765..d29b3863f 100644 --- a/packages/catalog-tools/src/tool.ts +++ b/packages/catalog-tools/src/tool.ts @@ -20,10 +20,20 @@ import type { ToolCall, ToolResult } from "@intx/types/runtime"; import { WIRE_CAPABILITIES, type Capability } from "@intx/types"; import { type } from "arktype"; +import { reportError } from "@corbits/error-sink"; + import { + createOffering, + disableOffering, fetchChain, fetchEstimate, + findModelIdByCanonicalName, + findModelProviderIdByName, listConcepts, + setOfferingPriority, + UnknownCanonicalNameError, + UnknownProviderNameError, + type CatalogAdminClientConfig, type CatalogToolClientConfig, type ChainEntry, type ModelChainResult, @@ -32,6 +42,9 @@ import { export const LIST_MODEL_CONCEPTS_TOOL = "list_model_concepts"; export const PICK_MODELS_TOOL = "pick_models"; export const ESTIMATE_RUN_COST_TOOL = "estimate_run_cost"; +export const CREATE_OFFERING_TOOL = "create_offering"; +export const SET_OFFERING_PRIORITY_TOOL = "set_offering_priority"; +export const DISABLE_OFFERING_TOOL = "disable_offering"; /** Env this bundle needs beyond `BaseEnv`: the run's hub-reach credential, * mirroring `@corbits/connections-tools`' `WorkflowConnectionEnv`. */ @@ -97,6 +110,47 @@ function clientConfig(env: WorkflowCatalogEnv): CatalogToolClientConfig { }; } +/** Env `create_offering`/`set_offering_priority`/`disable_offering` need + * beyond `BaseEnv`: the same hub-reach credential as + * {@link WorkflowCatalogEnv}, plus the tenant those tenant-admin catalog + * routes are scoped to. */ +export interface WorkflowCatalogOfferingEnv extends BaseEnv { + readonly hubCatalogUrl: string; + readonly sidecarToken: string; + readonly address: string; + readonly tenantId: string; +} + +function adminClientConfig( + env: WorkflowCatalogOfferingEnv, +): CatalogAdminClientConfig { + return { + hubCatalogUrl: env.hubCatalogUrl, + sidecarToken: env.sidecarToken, + address: env.address, + tenantId: env.tenantId, + }; +} + +const CreateOfferingInput = type({ + modelCanonicalName: "string > 0", + providerName: "string > 0", + "priority?": "number", + "capabilities?": type.enumerated(...WIRE_CAPABILITIES).array(), +}); +type CreateOfferingInput = typeof CreateOfferingInput.infer; + +const SetOfferingPriorityInput = type({ + offeringId: "string > 0", + priority: "number", +}); +type SetOfferingPriorityInput = typeof SetOfferingPriorityInput.infer; + +const DisableOfferingInput = type({ + offeringId: "string > 0", +}); +type DisableOfferingInput = typeof DisableOfferingInput.infer; + /** The need, as exactly one of the two forms the hub accepts. The input * schema has already rejected both-at-once and neither. */ function needOf(input: { concept?: string; capabilities?: Capability[] }): { @@ -217,6 +271,90 @@ async function runEstimateRunCost( } } +async function runCreateOffering( + env: WorkflowCatalogOfferingEnv, + call: ToolCall, + parsed: CreateOfferingInput, +): Promise { + try { + const config = adminClientConfig(env); + const [modelId, providerId] = await Promise.all([ + findModelIdByCanonicalName(config, parsed.modelCanonicalName), + findModelProviderIdByName(config, parsed.providerName), + ]); + const offering = await createOffering(config, { + modelId, + providerId, + ...(parsed.priority !== undefined ? { priority: parsed.priority } : {}), + ...(parsed.capabilities !== undefined + ? { capabilities: parsed.capabilities } + : {}), + }); + return { + callId: call.id, + isError: false, + content: + `Created offering ${offering.id}: ${parsed.modelCanonicalName} via ` + + `${parsed.providerName}, priority ${String(offering.priority)}.`, + }; + } catch (err) { + if ( + err instanceof UnknownCanonicalNameError || + err instanceof UnknownProviderNameError + ) { + return errorResult(call.id, err); + } + reportError(err, { operation: "create_offering", agentId: env.address }); + return errorResult(call.id, err); + } +} + +async function runSetOfferingPriority( + env: WorkflowCatalogOfferingEnv, + call: ToolCall, + parsed: SetOfferingPriorityInput, +): Promise { + try { + const offering = await setOfferingPriority( + adminClientConfig(env), + parsed.offeringId, + parsed.priority, + ); + return { + callId: call.id, + isError: false, + content: `Offering ${offering.id} is now priority ${String(offering.priority)}.`, + }; + } catch (err) { + reportError(err, { + operation: "set_offering_priority", + agentId: env.address, + }); + return errorResult(call.id, err); + } +} + +async function runDisableOffering( + env: WorkflowCatalogOfferingEnv, + call: ToolCall, + parsed: DisableOfferingInput, +): Promise { + try { + const offering = await disableOffering( + adminClientConfig(env), + parsed.offeringId, + ); + return { + callId: call.id, + isError: false, + content: `Offering ${offering.id} is now disabled.`, + }; + } catch (err) { + reportError(err, { operation: "disable_offering", agentId: env.address }); + return errorResult(call.id, err); + } +} + /** * The `@corbits/catalog-tools` bundle factory: three read-only tools, no * approval gate, three env keys. @@ -330,3 +468,159 @@ export const catalogTools = defineTool({ }, }), }); + +/** + * The `@corbits/catalog-tools` offering-management bundle: three write + * tools over the tenant-admin catalog routes, `create_offering` and + * `disable_offering` gated behind approval — both change what this + * bench can resolve to at runtime. `set_offering_priority` only reorders + * an offering already live, so it stays ungated, matching how + * `pick_models`' own `order` param is ungated. + */ +export const catalogOfferingTools = defineTool({ + id: "@corbits/catalog-tools/offerings", + requires: ["hubCatalogUrl", "sidecarToken", "address", "tenantId"], + definitions: [ + { name: CREATE_OFFERING_TOOL, approval: "ask" }, + { name: SET_OFFERING_PRIORITY_TOOL }, + { name: DISABLE_OFFERING_TOOL, approval: "ask" }, + ], + factory: (env) => ({ + definitions: [ + { + name: CREATE_OFFERING_TOOL, + description: + "add a tenant-owned offering pairing a model (by canonical " + + "name) with a model provider (by name), both already known to " + + "this workbench's own catalog. A human must approve before " + + "the offering becomes live.", + inputSchema: { + type: "object", + properties: { + modelCanonicalName: { + type: "string", + description: + "The exact canonical name of a model already in this " + + "workbench's own catalog — never invented.", + }, + providerName: { + type: "string", + description: + "The exact name of a model provider already in this " + + "workbench's own catalog — never invented.", + }, + priority: { + type: "number", + description: + "Ordering hint for source resolution; lower values are " + + "preferred first. Defaults to 0.", + }, + capabilities: { + type: "array", + items: { type: "string", enum: [...WIRE_CAPABILITIES] }, + description: + "The capabilities this offering advertises for the model.", + }, + }, + required: ["modelCanonicalName", "providerName"], + }, + }, + { + name: SET_OFFERING_PRIORITY_TOOL, + description: + "reorder an offering this workbench already owns directly, " + + "changing where it falls in source-resolution fallback order. " + + "Lower values are preferred first.", + inputSchema: { + type: "object", + properties: { + offeringId: { + type: "string", + description: "The offering's id, from create_offering or " + + "the workbench's own catalog listing.", + }, + priority: { + type: "number", + description: + "The offering's new priority; lower values are preferred " + + "first.", + }, + }, + required: ["offeringId", "priority"], + }, + }, + { + name: DISABLE_OFFERING_TOOL, + description: + "restrict an offering this workbench already owns directly, " + + "taking it out of source resolution without deleting its " + + "pricing history. A human must approve before it stops " + + "resolving. Running instances resolved through it fail over " + + "to the next eligible source.", + inputSchema: { + type: "object", + properties: { + offeringId: { + type: "string", + description: "The offering's id to disable.", + }, + }, + required: ["offeringId"], + }, + }, + ], + run: (call: ToolCall, _signal: AbortSignal) => { + switch (call.name) { + case CREATE_OFFERING_TOOL: { + const parsed = CreateOfferingInput(call.arguments); + if (parsed instanceof type.errors) { + return Promise.resolve( + errorResult( + call.id, + new Error( + `create_offering received invalid input: ${parsed.summary}`, + ), + ), + ); + } + return runCreateOffering(env, call, parsed); + } + case SET_OFFERING_PRIORITY_TOOL: { + const parsed = SetOfferingPriorityInput(call.arguments); + if (parsed instanceof type.errors) { + return Promise.resolve( + errorResult( + call.id, + new Error( + `set_offering_priority received invalid input: ${parsed.summary}`, + ), + ), + ); + } + return runSetOfferingPriority(env, call, parsed); + } + case DISABLE_OFFERING_TOOL: { + const parsed = DisableOfferingInput(call.arguments); + if (parsed instanceof type.errors) { + return Promise.resolve( + errorResult( + call.id, + new Error( + `disable_offering received invalid input: ${parsed.summary}`, + ), + ), + ); + } + return runDisableOffering(env, call, parsed); + } + default: + return Promise.resolve( + errorResult( + call.id, + new Error(`@corbits/catalog-tools: unknown tool "${call.name}"`), + ), + ); + } + }, + }), +}); diff --git a/packages/seeding/src/seed.ts b/packages/seeding/src/seed.ts index 48c9f781b..1921e0eaa 100644 --- a/packages/seeding/src/seed.ts +++ b/packages/seeding/src/seed.ts @@ -663,6 +663,17 @@ export const SEED_GRANTS: readonly { resource: string; action: string }[] = [ // vocabulary. { resource: "asset:*", action: "create" }, { resource: "asset:*", action: "write" }, + // CL-7468: `@corbits/catalog-tools`' `create_offering`/ + // `set_offering_priority`/`disable_offering` resolve a canonical model + // name and a provider name to ids (read) before writing the offering + // itself (create/manage) through the tenant-admin catalog routes + // (`vendor/intx/hub-api/src/routes/{models,model-providers, + // model-offerings}.ts`). + { resource: "model:*", action: "read" }, + { resource: "model-provider:*", action: "read" }, + { resource: "model-offering:*", action: "read" }, + { resource: "model-offering:*", action: "create" }, + { resource: "model-offering:*", action: "manage" }, ]; // The grants table has no unique constraint and the create route is a From b83f37f5e0b41b9be42deb711263c947da966f6e Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sat, 5 Sep 2026 23:50:25 -0700 Subject: [PATCH 03/24] Update docs: catalog-tools offering write tools --- packages/catalog-tools/README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/catalog-tools/README.md b/packages/catalog-tools/README.md index 312e27675..0f7382ddd 100644 --- a/packages/catalog-tools/README.md +++ b/packages/catalog-tools/README.md @@ -20,3 +20,26 @@ Everything comes from `@corbits/inference-catalog`'s run-authenticated surface at `/api/workflow-inference-catalog`, reached with the sidecar bearer token and run address like every other workflow-run tool bundle. All three tools read only, so none is gated behind approval. + +## Managing offerings + +Three more tools let an agent write to the tenant's own inference catalog, +through the tenant-admin catalog routes +(`vendor/intx/hub-api/src/routes/{models,model-providers,model-offerings}.ts`) +rather than the read-only concept surface above. + +- **`create_offering`** — pairs a model (by canonical name) with a model + provider (by name), both already present in this workbench's own catalog, + at a given priority with a set of advertised capabilities. Gated behind + `approval: "ask"`: it changes what this bench can resolve to at runtime. +- **`set_offering_priority`** — reorders an offering this workbench already + owns directly, changing where it falls in source-resolution fallback + order. Ungated: it only reorders an offering already live. +- **`disable_offering`** — restricts an offering this workbench already owns + directly, taking it out of source resolution without deleting its pricing + history. Gated behind `approval: "ask"`: running instances resolved + through it fail over to the next eligible source. + +`create_offering` never invents a model or provider id — it resolves the +given canonical name and provider name against this tenant's own catalog +listing first, and refuses rather than guessing when neither is found. From 1d54cf449c11cd303a550085c26e7b10befd2b70 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sat, 5 Sep 2026 23:51:03 -0700 Subject: [PATCH 04/24] Add tests for @corbits/access-tools Covers the tool bundle's client (auth headers, response parsing, 403/404 mapped to typed errors) and the tool handlers themselves (declarations, approval gating, invalid input short-circuiting fetch, a forbidden grant surfacing as a clear tool error, and a successful grant round-tripping through list_grants). --- packages/access-tools/src/client.test.ts | 154 +++++++++++++++++++++++ packages/access-tools/src/tool.test.ts | 149 ++++++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 packages/access-tools/src/client.test.ts create mode 100644 packages/access-tools/src/tool.test.ts diff --git a/packages/access-tools/src/client.test.ts b/packages/access-tools/src/client.test.ts new file mode 100644 index 000000000..2a8fc34f4 --- /dev/null +++ b/packages/access-tools/src/client.test.ts @@ -0,0 +1,154 @@ +import { expect, test } from "bun:test"; + +import { + AccessForbiddenError, + AccessNotFoundError, + grantAccess, + listGrants, + listPrincipals, + revokeAccess, + type AccessToolClientConfig, +} from "./client"; + +function testConfig(fetchImpl: typeof fetch): AccessToolClientConfig { + return { + hubAccessUrl: "https://hub.example.com/api/workflow-access", + sidecarToken: "sc-token", + address: "run_1@workflow", + fetchImpl, + }; +} + +test("listPrincipals sends sidecar auth and parses the response", async () => { + let seenUrl: string | undefined; + let seenHeaders: Record | undefined; + const fetchImpl = (async (url: string | URL, init?: RequestInit) => { + seenUrl = String(url); + seenHeaders = init?.headers as Record; + return new Response( + JSON.stringify({ + principals: [ + { id: "prin_1", kind: "workflow", refId: "run_1", status: "active" }, + ], + }), + ); + }) as unknown as typeof fetch; + + const result = await listPrincipals(testConfig(fetchImpl)); + + expect(seenUrl).toBe("https://hub.example.com/api/workflow-access/principals"); + expect(seenHeaders?.["authorization"]).toBe("Bearer sc-token"); + expect(seenHeaders?.["x-workflow-run-address"]).toBe("run_1@workflow"); + expect(result).toEqual([ + { id: "prin_1", kind: "workflow", refId: "run_1", status: "active" }, + ]); +}); + +test("listGrants filters by principalId and resource", async () => { + let seenUrl: string | undefined; + const fetchImpl = (async (url: string | URL) => { + seenUrl = String(url); + return new Response(JSON.stringify({ grants: [] })); + }) as unknown as typeof fetch; + + await listGrants(testConfig(fetchImpl), { + principalId: "prin_1", + resource: "workflow-run:*", + }); + + const parsed = new URL(seenUrl ?? ""); + expect(parsed.searchParams.get("principalId")).toBe("prin_1"); + expect(parsed.searchParams.get("resource")).toBe("workflow-run:*"); +}); + +test("grantAccess round-trips the created grants", async () => { + let seenBody: unknown; + const fetchImpl = (async (_url: string | URL, init?: RequestInit) => { + seenBody = JSON.parse(String(init?.body)); + return new Response( + JSON.stringify({ + grants: [ + { + id: "grant_1", + principalId: "prin_2", + resource: "workflow-run:*", + action: "read", + effect: "allow", + }, + ], + }), + { status: 201 }, + ); + }) as unknown as typeof fetch; + + const result = await grantAccess(testConfig(fetchImpl), { + principalId: "prin_2", + resource: "workflow-run:*", + actions: ["read"], + }); + + expect(seenBody).toEqual({ + principalId: "prin_2", + resource: "workflow-run:*", + actions: ["read"], + }); + expect(result).toEqual([ + { + id: "grant_1", + principalId: "prin_2", + resource: "workflow-run:*", + action: "read", + effect: "allow", + }, + ]); +}); + +test("a 403 from grantAccess surfaces as AccessForbiddenError with the hub's userMessage", async () => { + const fetchImpl = (async () => + new Response( + JSON.stringify({ + error: { + code: "forbidden", + userMessage: "You do not have permission to perform this action", + }, + }), + { status: 403 }, + )) as unknown as typeof fetch; + + await expect( + grantAccess(testConfig(fetchImpl), { + principalId: "prin_2", + resource: "workflow-run:*", + actions: ["read"], + }), + ).rejects.toThrow(AccessForbiddenError); +}); + +test("a 404 from revokeAccess surfaces as AccessNotFoundError", async () => { + const fetchImpl = (async () => + new Response( + JSON.stringify({ + error: { code: "not_found", userMessage: "No grant here" }, + }), + { status: 404 }, + )) as unknown as typeof fetch; + + await expect( + revokeAccess(testConfig(fetchImpl), "grant_missing"), + ).rejects.toThrow(AccessNotFoundError); +}); + +test("revokeAccess sends a DELETE to the grant's own path", async () => { + let seenUrl: string | undefined; + let seenMethod: string | undefined; + const fetchImpl = (async (url: string | URL, init?: RequestInit) => { + seenUrl = String(url); + seenMethod = init?.method; + return new Response(null, { status: 204 }); + }) as unknown as typeof fetch; + + await revokeAccess(testConfig(fetchImpl), "grant_1"); + + expect(seenUrl).toBe("https://hub.example.com/api/workflow-access/grants/grant_1"); + expect(seenMethod).toBe("DELETE"); +}); diff --git a/packages/access-tools/src/tool.test.ts b/packages/access-tools/src/tool.test.ts new file mode 100644 index 000000000..122082fde --- /dev/null +++ b/packages/access-tools/src/tool.test.ts @@ -0,0 +1,149 @@ +import { expect, test } from "bun:test"; +import type { ToolCall } from "@intx/types/runtime"; + +import { + accessTools, + GRANT_ACCESS_TOOL, + LIST_GRANTS_TOOL, + LIST_PRINCIPALS_TOOL, + REVOKE_ACCESS_TOOL, + type WorkflowAccessEnv, +} from "./tool"; + +function testEnv(): WorkflowAccessEnv { + return { + hubAccessUrl: "https://hub.example.com/api/workflow-access", + sidecarToken: "sc-token", + address: "run_1@workflow", + } as unknown as WorkflowAccessEnv; +} + +function callFor(name: string, args: Record): ToolCall { + return { id: "call_1", name, arguments: args }; +} + +test("declares exactly the four access tools", () => { + const bundle = accessTools(testEnv()); + expect(bundle.definitions.map((d) => d.name)).toEqual([ + LIST_PRINCIPALS_TOOL, + LIST_GRANTS_TOOL, + GRANT_ACCESS_TOOL, + REVOKE_ACCESS_TOOL, + ]); +}); + +test("requires the sanctioned env keys", () => { + expect(accessTools.requires).toEqual([ + "hubAccessUrl", + "sidecarToken", + "address", + ]); +}); + +test("grant_access and revoke_access declare approval: ask; the reads do not", () => { + expect(accessTools.definitions).toEqual([ + { name: LIST_PRINCIPALS_TOOL }, + { name: LIST_GRANTS_TOOL }, + { name: GRANT_ACCESS_TOOL, approval: "ask" }, + { name: REVOKE_ACCESS_TOOL, approval: "ask" }, + ]); +}); + +test("list_grants rejects invalid input before ever calling fetch", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + throw new Error("fetch should not be called"); + }) as unknown as typeof fetch; + try { + const bundle = accessTools(testEnv()); + const result = await bundle.run( + callFor(LIST_GRANTS_TOOL, { principalId: "" }), + new AbortController().signal, + ); + expect(result.isError).toBe(true); + expect(result.content).toMatch(/invalid input/i); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("a 403 from grant_access surfaces as a clear tool error", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + error: { + code: "forbidden", + userMessage: "You do not have permission to perform this action", + }, + }), + { status: 403 }, + )) as unknown as typeof fetch; + try { + const bundle = accessTools(testEnv()); + const result = await bundle.run( + callFor(GRANT_ACCESS_TOOL, { + principalId: "prin_2", + resource: "workflow-run:*", + actions: ["read"], + }), + new AbortController().signal, + ); + expect(result.isError).toBe(true); + expect(result.content).toBe( + "You do not have permission to perform this action", + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("a successful grant_access round-trips into list_grants", async () => { + const originalFetch = globalThis.fetch; + const grantRow = { + id: "grant_1", + principalId: "prin_2", + resource: "workflow-run:*", + action: "read", + effect: "allow", + }; + let grantCreated = false; + globalThis.fetch = (async (url: string | URL, init?: RequestInit) => { + const path = new URL(String(url)).pathname; + if (init?.method === "POST" && path.endsWith("/grants")) { + grantCreated = true; + return new Response(JSON.stringify({ grants: [grantRow] }), { + status: 201, + }); + } + if (path.endsWith("/grants")) { + return new Response( + JSON.stringify({ grants: grantCreated ? [grantRow] : [] }), + ); + } + throw new Error(`unexpected request: ${String(url)}`); + }) as unknown as typeof fetch; + + try { + const bundle = accessTools(testEnv()); + const grantResult = await bundle.run( + callFor(GRANT_ACCESS_TOOL, { + principalId: "prin_2", + resource: "workflow-run:*", + actions: ["read"], + }), + new AbortController().signal, + ); + expect(grantResult.isError).toBe(false); + + const listResult = await bundle.run( + callFor(LIST_GRANTS_TOOL, { principalId: "prin_2" }), + new AbortController().signal, + ); + expect(listResult.isError).toBe(false); + expect(listResult.content).toContain("grant_1"); + expect(listResult.content).toContain("workflow-run:*"); + } finally { + globalThis.fetch = originalFetch; + } +}); From 463f81c0d1a3dd076e2fbe7e434a97dbc1841283 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sat, 5 Sep 2026 23:51:15 -0700 Subject: [PATCH 05/24] Add @corbits/access-tools: principals and scoped grants for agents Lets an agent see who exists in its tenant and, once a human approves, create or revoke scoped grants for the specialist agents it stands up. list_principals and list_grants are plain reads; grant_access and revoke_access declare approval: "ask" so a human signs off on each grant before it takes effect. The package's own routes.ts adds a workflow-run-authenticated mirror of the tenant's principal/grant surface at /api/workflow-access, gated by Interchange's real grant store and condition registry -- the same authorization every session-authenticated route runs, not a bundle-local approximation. Mounted in apps/hub alongside the other workflow-run surfaces, threaded a hub URL by the sidecar's step-env builder, and published into the corbits-tools registry. Extends SEED_GRANTS with the principal/grant reads and grant create/manage a seeded principal needs to actually use this surface. --- apps/hub/package.json | 1 + apps/hub/src/index.ts | 19 ++ .../workflow-substrate-factory/step-env.ts | 5 + bun.lock | 37 ++- packages/access-tools/LICENSE | 176 ++++++++++ packages/access-tools/package.json | 32 ++ packages/access-tools/src/client.ts | 213 ++++++++++++ packages/access-tools/src/index.ts | 21 ++ packages/access-tools/src/routes.ts | 263 +++++++++++++++ packages/access-tools/src/tool.ts | 307 ++++++++++++++++++ packages/access-tools/tsconfig.json | 8 + packages/seeding/src/seed.ts | 9 + .../tool-registry-publish/src/registry.ts | 1 + 13 files changed, 1088 insertions(+), 4 deletions(-) create mode 100644 packages/access-tools/LICENSE create mode 100644 packages/access-tools/package.json create mode 100644 packages/access-tools/src/client.ts create mode 100644 packages/access-tools/src/index.ts create mode 100644 packages/access-tools/src/routes.ts create mode 100644 packages/access-tools/src/tool.ts create mode 100644 packages/access-tools/tsconfig.json diff --git a/apps/hub/package.json b/apps/hub/package.json index 560970163..5bc91d25b 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -13,6 +13,7 @@ }, "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 705b62889..508761be2 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -165,6 +165,7 @@ import { createDrizzleSidecarPlacementStore, createSidecarPlacementRoutes, } from "@corbits/sidecar-placement"; +import { createWorkflowAccessRoutes } from "@corbits/access-tools/routes"; import { generateId } from "@intx/hub-common"; import { ensureDefaultTenant } from "./default-tenant"; @@ -2865,6 +2866,24 @@ 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 + // `@corbits/seeding`'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 diff --git a/apps/sidecar/src/workflow-substrate-factory/step-env.ts b/apps/sidecar/src/workflow-substrate-factory/step-env.ts index a2a7881af..2e86622fa 100644 --- a/apps/sidecar/src/workflow-substrate-factory/step-env.ts +++ b/apps/sidecar/src/workflow-substrate-factory/step-env.ts @@ -368,6 +368,7 @@ export function createSidecarStepBuildEnv( hubAgentDirectoryUrl: string; hubChatUrl: string; hubWorkflowAuthoringUrl: string; + hubAccessUrl: string; sidecarToken: string; definitionId: string; } = { @@ -426,6 +427,10 @@ export function createSidecarStepBuildEnv( // (`requires: ["hubWorkflowAuthoringUrl", "sidecarToken", "address"]`) // for `@corbits/workflows`'s `./authoring`'s run-authenticated routes. hubWorkflowAuthoringUrl: deps.hubArtifactsUrl, + // And once more under the key `@corbits/access-tools` declares + // (`requires: ["hubAccessUrl", "sidecarToken", "address"]`) for its + // own workflow-run-authenticated principal/grant surface. + hubAccessUrl: deps.hubArtifactsUrl, sidecarToken: deps.sidecarToken, definitionId: deps.definitionId, }; diff --git a/bun.lock b/bun.lock index 3e7830334..c1ff1b1c8 100644 --- a/bun.lock +++ b/bun.lock @@ -34,6 +34,7 @@ "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:*", @@ -205,6 +206,26 @@ "typescript": "catalog:", }, }, + "packages/access-tools": { + "name": "@corbits/access-tools", + "version": "0.0.1", + "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": { + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, "packages/agent-directory": { "name": "@corbits/agent-directory", "version": "0.0.1", @@ -2111,6 +2132,8 @@ "@connectrpc/connect-web": ["@connectrpc/connect-web@2.1.2", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0", "@connectrpc/connect": "2.1.2" } }, "sha512-1tfaK85MU+gJjwwmL31d2rzdf0XCYX99chZf63uG89SGBUd4XuZ4ZzhGo2u79TPXOE6nLIZQ2okrpyey42PYdg=="], + "@corbits/access-tools": ["@corbits/access-tools@workspace:packages/access-tools"], + "@corbits/agent-directory": ["@corbits/agent-directory@workspace:packages/agent-directory"], "@corbits/agent-directory-tools": ["@corbits/agent-directory-tools@workspace:packages/agent-directory-tools"], @@ -3563,9 +3586,15 @@ "@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/artifacts-hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#81049ed", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-81049ed", "sha512-oTE0iFDyQdz0ifG1epo39pwaCaYaw19YcKXwfaZqAEQ56a1g9YIozXwH9CG4NaUTwcJKUeYGuNls6oJsMPisCw=="], + + "@corbits/chat-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/context-menu/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/plugins-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - "@corbits/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=="], + "@corbits/settings-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], "@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=="], @@ -3589,9 +3618,9 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], - "@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/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#81049ed", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-81049ed", "sha512-oTE0iFDyQdz0ifG1epo39pwaCaYaw19YcKXwfaZqAEQ56a1g9YIozXwH9CG4NaUTwcJKUeYGuNls6oJsMPisCw=="], - "@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/web/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], "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=="], diff --git a/packages/access-tools/LICENSE b/packages/access-tools/LICENSE new file mode 100644 index 000000000..c6487f4fd --- /dev/null +++ b/packages/access-tools/LICENSE @@ -0,0 +1,176 @@ +GNU LESSER GENERAL PUBLIC LICENSE + +Version 2.1, February 1999 + +Copyright (C) 1991, 1999 Free Software Foundation, Inc. +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + +[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] + +Preamble + +The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. + +This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. + +When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. + +To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. + +For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. + +We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. + +To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. + +Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. + +Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. + +When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. + +We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. + +For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. + +In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. + +Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. + +The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. + +GNU LESSER GENERAL PUBLIC LICENSE +TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + +0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". + +A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. + +The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) + +"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. + +Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. + +1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. + +You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. + +2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. + +(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. + +3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. + +Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. + +This option is useful when you wish to copy part of the code of the Library into a program that is not a library. + +4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. + +If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. + +5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. + +However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. + +When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. + +If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) + +Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. + +6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. + +You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: + + a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) + + b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. + + c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. + + d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. + + e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. + +For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. + +It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. + +7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. + + b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. + +8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. + +9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. + +10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. + +11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. + +This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. + +12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. + +13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. + +14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. + +NO WARRANTY + +15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Libraries + +If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). + +To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + one line to give the library's name and an idea of what it does. + Copyright (C) year name of author + + This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: + +Yoyodyne, Inc., hereby disclaims all copyright interest in +the library `Frob' (a library for tweaking knobs) written +by James Random Hacker. + +signature of Ty Coon, 1 April 1990 +Ty Coon, President of Vice +That's all there is to it! diff --git a/packages/access-tools/package.json b/packages/access-tools/package.json new file mode 100644 index 000000000..6fc47d1e9 --- /dev/null +++ b/packages/access-tools/package.json @@ -0,0 +1,32 @@ +{ + "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.1", + "license": "LGPL-2.1-or-later", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./routes": "./src/routes.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "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": { + "@types/bun": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/access-tools/src/client.ts b/packages/access-tools/src/client.ts new file mode 100644 index 000000000..ac5e04363 --- /dev/null +++ b/packages/access-tools/src/client.ts @@ -0,0 +1,213 @@ +// A minimal client for `@corbits/access-tools`' own workflow-run- +// authenticated surface (`./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`). +import { type } from "arktype"; + +export interface AccessToolClientConfig { + readonly hubAccessUrl: string; + readonly sidecarToken: string; + readonly address: string; + /** Override for tests; defaults to the global `fetch`. */ + readonly fetchImpl?: typeof fetch; +} + +export interface ListedPrincipal { + readonly id: string; + readonly kind: "user" | "agent" | "workflow"; + readonly refId: string; + readonly status: "active" | "suspended" | "invited" | "deactivated"; +} + +export interface ListedGrant { + readonly id: string; + readonly principalId: string | null; + readonly resource: string; + readonly action: string; + readonly effect: "allow" | "deny" | "ask"; +} + +export interface GrantAccessRequest { + readonly principalId: string; + readonly resource: string; + readonly actions: readonly string[]; +} + +function authHeaders(config: AccessToolClientConfig): Record { + return { + authorization: `Bearer ${config.sidecarToken}`, + "x-workflow-run-address": config.address, + }; +} + +/** Pulls `error.userMessage` out of the canonical hub envelope + * (`{error: {code, userMessage, refId}}`), if `body` matches that shape. */ +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" || + !("userMessage" in error) + ) { + return undefined; + } + const userMessage = (error as { userMessage: unknown }).userMessage; + return typeof userMessage === "string" ? userMessage : undefined; +} + +async function readErrorMessage( + response: Response, + fallback: string, +): Promise { + const body: unknown = await response.json().catch(() => undefined); + return errorMessageFrom(body) ?? fallback; +} + +/** Thrown when the access route rejects the request as forbidden — + * the caller's own principal has no `principal:*`/`grant:*` grant of + * its own — 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 {} + +/** 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(`${config.hubAccessUrl}${path}`, { + ...init, + headers: { ...authHeaders(config), ...(init.headers ?? {}) }, + }); + if (response.status === 403) { + throw new AccessForbiddenError( + await readErrorMessage(response, `${failureLabel}: forbidden`), + ); + } + if (response.status === 404) { + throw new AccessNotFoundError( + await readErrorMessage(response, `${failureLabel}: not found`), + ); + } + if (!response.ok) { + throw new Error( + `${failureLabel}: ${response.status} ${response.statusText}`, + ); + } + return response; +} + +const ListedPrincipalsResponse = type({ + principals: type({ + id: "string", + kind: "'user'|'agent'|'workflow'", + refId: "string", + status: "'active'|'suspended'|'invited'|'deactivated'", + }).array(), +}); + +export async function listPrincipals( + config: AccessToolClientConfig, +): Promise { + const response = await doRequest( + config, + "/principals", + { headers: authHeaders(config) }, + "Listing principals failed", + ); + const body: unknown = await response.json(); + const parsed = ListedPrincipalsResponse(body); + if (parsed instanceof type.errors) { + throw new Error( + `List-principals response did not match the expected shape: ${parsed.summary}`, + ); + } + return parsed.principals; +} + +const ListedGrantsResponse = type({ + grants: type({ + id: "string", + principalId: "string | null", + resource: "string", + action: "string", + effect: "'allow'|'deny'|'ask'", + }).array(), +}); + +export interface ListGrantsFilter { + readonly principalId?: string; + readonly resource?: string; +} + +export async function listGrants( + 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); + const query = params.toString(); + const response = await doRequest( + config, + `/grants${query.length > 0 ? `?${query}` : ""}`, + { headers: authHeaders(config) }, + "Listing grants failed", + ); + 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}`, + ); + } + return parsed.grants; +} + +export async function grantAccess( + config: AccessToolClientConfig, + input: GrantAccessRequest, +): Promise { + const response = await doRequest( + config, + "/grants", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }, + "Granting access failed", + ); + const body: unknown = await response.json(); + const parsed = ListedGrantsResponse(body); + if (parsed instanceof type.errors) { + throw new Error( + `Grant-access response did not match the expected shape: ${parsed.summary}`, + ); + } + return parsed.grants; +} + +export async function revokeAccess( + config: AccessToolClientConfig, + grantId: string, +): Promise { + 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 new file mode 100644 index 000000000..e818babd6 --- /dev/null +++ b/packages/access-tools/src/index.ts @@ -0,0 +1,21 @@ +export { + grantAccess, + listGrants, + listPrincipals, + revokeAccess, + AccessForbiddenError, + AccessNotFoundError, + type AccessToolClientConfig, + type GrantAccessRequest, + type ListedGrant, + type ListedPrincipal, + type ListGrantsFilter, +} from "./client"; +export { + accessTools, + GRANT_ACCESS_TOOL, + LIST_GRANTS_TOOL, + LIST_PRINCIPALS_TOOL, + REVOKE_ACCESS_TOOL, + type WorkflowAccessEnv, +} from "./tool"; diff --git a/packages/access-tools/src/routes.ts b/packages/access-tools/src/routes.ts new file mode 100644 index 000000000..6a814b4b9 --- /dev/null +++ b/packages/access-tools/src/routes.ts @@ -0,0 +1,263 @@ +// Workflow-run-authenticated principal + grant surface: the execution +// 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 `@intx/hub-api`'s own +// `/api/tenants/:tenantId/principals` and `/grants` routes use — 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. +// +// This is a thin wrapper, not a reimplementation: authorization for +// every route below runs through `@intx/hub-api`'s own +// `createRequireGrant` against the SAME grant store and condition +// registry the tenant-session routes gate on, 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, eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { type } from "arktype"; + +import type { DB } from "@intx/db"; +import { grant, principal, tenant } from "@intx/db/schema"; +import { generateId } from "@intx/hub-common"; +import { + createRequireGrant, + idResource, + type RequireGrant, + type TenantEnv, +} from "@intx/hub-api"; +import type { ConditionRegistry, GrantStore } from "@intx/types/authz"; +import { makeErrorEnvelope } from "@corbits/error-sink"; + +export type WorkflowAccessRunScope = { + readonly tenantId: string; + readonly principalId: string; +}; + +export type WorkflowRunAuthenticator = { + resolve( + token: string, + runAddress: string, + ): Promise; +}; + +export interface ListedPrincipal { + readonly id: string; + readonly kind: "user" | "agent" | "workflow"; + readonly refId: string; + readonly status: "active" | "suspended" | "invited" | "deactivated"; +} + +export interface ListedGrant { + readonly id: string; + readonly principalId: string | null; + readonly resource: string; + readonly action: string; + readonly effect: "allow" | "deny" | "ask"; +} + +function formatPrincipal(row: typeof principal.$inferSelect): ListedPrincipal { + return { + id: row.id, + kind: row.kind, + refId: row.refId, + status: row.status, + }; +} + +function formatGrant(row: typeof grant.$inferSelect): ListedGrant { + return { + id: row.id, + principalId: row.principalId ?? null, + resource: row.resource, + action: row.action, + effect: row.effect, + }; +} + +const GrantAccessInput = type({ + principalId: "string > 0", + resource: "string > 0", + actions: type("string > 0").array().atLeastLength(1), +}); + +export type CreateWorkflowAccessRoutesDeps = { + db: DB["db"]; + authenticator: WorkflowRunAuthenticator; + grantStore: GrantStore; + conditionRegistry: ConditionRegistry; +}; + +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( + makeErrorEnvelope({ + code: "unauthorized", + userMessage: + "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( + makeErrorEnvelope({ + code: "unauthorized", + userMessage: + "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 rows = await deps.db.query.principal.findMany({ + where: eq(principal.tenantId, tenantCtx.id), + }); + return c.json({ principals: rows.map(formatPrincipal) }); + }); + + app.get("/grants", requireGrant("grant:*", "read"), async (c) => { + const tenantCtx = c.get("tenant"); + const principalId = c.req.query("principalId"); + const resource = c.req.query("resource"); + const conditions = [eq(grant.tenantId, tenantCtx.id)]; + if (principalId) conditions.push(eq(grant.principalId, principalId)); + if (resource) conditions.push(eq(grant.resource, resource)); + const rows = await deps.db.query.grant.findMany({ + where: and(...conditions), + }); + return c.json({ grants: rows.map(formatGrant) }); + }); + + app.post("/grants", requireGrant("grant:*", "create"), async (c) => { + const tenantCtx = c.get("tenant"); + const body = GrantAccessInput(await c.req.json().catch(() => undefined)); + if (body instanceof type.errors) { + return c.json( + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid grant request: ${body.summary}`, + }), + 400, + ); + } + + 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( + makeErrorEnvelope({ + code: "not_found", + userMessage: `No principal "${body.principalId}" in this tenant`, + }), + 404, + ); + } + + const now = new Date(); + const rows = await deps.db + .insert(grant) + .values( + body.actions.map((action) => ({ + id: generateId("grant"), + tenantId: tenantCtx.id, + roleId: null, + principalId: body.principalId, + resource: body.resource, + action, + effect: "allow" as const, + conditions: null, + // The invoking human's authority delegated this grant: the + // tool declares `approval: "ask"`, so a human already + // approved this exact principal/resource/actions triple + // before this route ever ran. + origin: "invoker" as const, + expiresAt: null, + createdAt: now, + updatedAt: now, + })), + ) + .returning(); + + return c.json({ grants: rows.map(formatGrant) }, 201); + }); + + app.delete( + "/grants/:grantId", + requireGrant(idResource("grant", "grantId"), "manage"), + async (c) => { + const tenantCtx = c.get("tenant"); + const grantId = c.req.param("grantId"); + 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( + makeErrorEnvelope({ + code: "not_found", + userMessage: `No grant "${grantId}" in this tenant`, + }), + 404, + ); + } + return c.body(null, 204); + }, + ); + + return app; +} diff --git a/packages/access-tools/src/tool.ts b/packages/access-tools/src/tool.ts new file mode 100644 index 000000000..ca84cb556 --- /dev/null +++ b/packages/access-tools/src/tool.ts @@ -0,0 +1,307 @@ +// The `@corbits/access-tools` bundle: `list_principals` and +// `list_grants` (plain reads, no approval) plus `grant_access` and +// `revoke_access` (`approval: "ask"` — `@intx/agent`'s native +// per-invocation gate suspends the call as a pending approval BEFORE +// this bundle's `run` ever executes, exactly like +// `@corbits/capability-tools`'s `request_capability`). Lets Myra create +// scoped grants for the specialist agents she stands up, gated behind a +// human approving each grant. +// +// `hubAccessUrl`/`sidecarToken`/`address` are threaded onto `env` by the +// 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. +import { defineTool } from "@intx/agent"; +import type { BaseEnv } from "@intx/agent"; +import type { ToolCall, ToolResult } from "@intx/types/runtime"; +import { type } from "arktype"; +import { reportError } from "@corbits/error-sink"; + +import { + grantAccess, + listGrants, + listPrincipals, + revokeAccess, + type AccessToolClientConfig, +} from "./client"; + +export const LIST_PRINCIPALS_TOOL = "list_principals"; +export const LIST_GRANTS_TOOL = "list_grants"; +export const GRANT_ACCESS_TOOL = "grant_access"; +export const REVOKE_ACCESS_TOOL = "revoke_access"; + +export interface WorkflowAccessEnv extends BaseEnv { + readonly hubAccessUrl: string; + readonly sidecarToken: string; + readonly address: string; +} + +const ListGrantsInput = type({ + "principalId?": "string > 0", + "resource?": "string > 0", +}); + +const GrantAccessInput = type({ + principalId: "string > 0", + resource: "string > 0", + actions: type("string > 0").array().atLeastLength(1), + "why?": "string > 0", +}); + +const RevokeAccessInput = type({ + grantId: "string > 0", +}); + +function errorResult(callId: string, err: unknown): ToolResult { + return { + callId, + isError: true, + content: err instanceof Error ? err.message : String(err), + }; +} + +function clientConfig(env: WorkflowAccessEnv): AccessToolClientConfig { + return { + hubAccessUrl: env.hubAccessUrl, + sidecarToken: env.sidecarToken, + address: env.address, + }; +} + +async function runListPrincipals( + env: WorkflowAccessEnv, + call: ToolCall, +): Promise { + try { + const principals = await listPrincipals(clientConfig(env)); + const content = + principals.length === 0 + ? "No principals in this tenant." + : principals + .map((p) => `${p.kind} ${p.refId} — ${p.status} (id: ${p.id})`) + .join("\n"); + return { callId: call.id, isError: false, content }; + } catch (err) { + reportError(err, { + operation: "list_principals", + agentId: env.address, + }); + return errorResult(call.id, err); + } +} + +async function runListGrants( + env: WorkflowAccessEnv, + call: ToolCall, +): Promise { + const parsed = ListGrantsInput(call.arguments); + if (parsed instanceof type.errors) { + return errorResult( + call.id, + new Error(`list_grants received invalid input: ${parsed.summary}`), + ); + } + try { + const grants = await listGrants(clientConfig(env), { + ...(parsed.principalId !== undefined + ? { principalId: parsed.principalId } + : {}), + ...(parsed.resource !== undefined ? { resource: parsed.resource } : {}), + }); + const content = + grants.length === 0 + ? "No matching grants." + : grants + .map( + (g) => + `${g.effect} ${g.resource}:${g.action} -> principal ${g.principalId ?? "(role)"} (id: ${g.id})`, + ) + .join("\n"); + return { callId: call.id, isError: false, content }; + } catch (err) { + reportError(err, { operation: "list_grants", agentId: env.address }); + return errorResult(call.id, err); + } +} + +async function runGrantAccess( + env: WorkflowAccessEnv, + call: ToolCall, +): Promise { + const parsed = GrantAccessInput(call.arguments); + if (parsed instanceof type.errors) { + return errorResult( + call.id, + new Error(`grant_access received invalid input: ${parsed.summary}`), + ); + } + try { + const grants = await grantAccess(clientConfig(env), { + principalId: parsed.principalId, + resource: parsed.resource, + actions: parsed.actions, + }); + return { + callId: call.id, + isError: false, + content: `Granted ${parsed.actions.join(", ")} on ${parsed.resource} to ${parsed.principalId} (${grants.length} grant${grants.length === 1 ? "" : "s"} created).`, + }; + } catch (err) { + reportError(err, { operation: "grant_access", agentId: env.address }); + return errorResult(call.id, err); + } +} + +async function runRevokeAccess( + env: WorkflowAccessEnv, + call: ToolCall, +): Promise { + const parsed = RevokeAccessInput(call.arguments); + if (parsed instanceof type.errors) { + return errorResult( + call.id, + new Error(`revoke_access received invalid input: ${parsed.summary}`), + ); + } + try { + await revokeAccess(clientConfig(env), parsed.grantId); + return { + callId: call.id, + isError: false, + content: `Revoked grant ${parsed.grantId}.`, + }; + } catch (err) { + reportError(err, { operation: "revoke_access", agentId: env.address }); + return errorResult(call.id, err); + } +} + +/** + * The `@corbits/access-tools` bundle factory: `list_principals` and + * `list_grants` (read, no approval), `grant_access` and `revoke_access` + * (`approval: "ask"`) — Myra's own way to create principals-scoped + * grants for the agents she stands up. + */ +export const accessTools = defineTool({ + id: "@corbits/access-tools/access", + requires: ["hubAccessUrl", "sidecarToken", "address"], + definitions: [ + { name: LIST_PRINCIPALS_TOOL }, + { name: LIST_GRANTS_TOOL }, + { name: GRANT_ACCESS_TOOL, approval: "ask" }, + { name: REVOKE_ACCESS_TOOL, approval: "ask" }, + ], + factory: (env) => ({ + definitions: [ + { + name: LIST_PRINCIPALS_TOOL, + description: + "List the principals (users, agents, and workflow runs) in " + + "this tenant, with their id, kind, and status — use this to " + + "find the principal id a grant should target.", + inputSchema: { type: "object", properties: {} }, + }, + { + name: LIST_GRANTS_TOOL, + description: + "List grants in this tenant, optionally filtered by " + + "principalId or resource — use this to see what a principal " + + "already has before granting more, or to find the grant id " + + "revoke_access needs.", + inputSchema: { + type: "object", + properties: { + principalId: { + type: "string", + description: "Only grants targeting this principal id.", + }, + resource: { + type: "string", + description: + 'Only grants for this exact resource string (e.g. "workflow-run:*").', + }, + }, + }, + }, + { + name: GRANT_ACCESS_TOOL, + description: + "grant a principal one or more scoped actions on a resource " + + "-- e.g. letting a specialist agent read its own workflow " + + "runs. A human must approve before anything is granted. Use " + + "this only for a genuine, specific need; never speculatively, " + + "and never grant broader access than the specific need " + + "requires.", + inputSchema: { + type: "object", + properties: { + principalId: { + type: "string", + description: + "The principal id to grant access to (from list_principals).", + }, + resource: { + type: "string", + description: + 'The resource string to grant, e.g. "workflow-run:*" or ' + + '"asset:ast_123".', + }, + actions: { + type: "array", + items: { type: "string" }, + description: + 'Actions to grant on that resource, e.g. ["read"] or ' + + '["read", "write"].', + }, + why: { + type: "string", + description: + "One sentence on why this grant is needed right now, " + + "shown to the person approving the request.", + }, + }, + required: ["principalId", "resource", "actions"], + }, + }, + { + name: REVOKE_ACCESS_TOOL, + description: + "Revoke a previously created grant by its id (from " + + "list_grants). A human must approve before anything is " + + "revoked.", + inputSchema: { + type: "object", + properties: { + grantId: { + type: "string", + description: "The grant id to revoke.", + }, + }, + required: ["grantId"], + }, + }, + ], + run: (call: ToolCall, _signal: AbortSignal) => { + switch (call.name) { + case LIST_PRINCIPALS_TOOL: + return runListPrincipals(env, call); + case LIST_GRANTS_TOOL: + return runListGrants(env, call); + case GRANT_ACCESS_TOOL: + return runGrantAccess(env, call); + case REVOKE_ACCESS_TOOL: + return runRevokeAccess(env, call); + default: + return Promise.resolve( + errorResult( + call.id, + new Error(`@corbits/access-tools: unknown tool "${call.name}"`), + ), + ); + } + }, + }), +}); diff --git a/packages/access-tools/tsconfig.json b/packages/access-tools/tsconfig.json new file mode 100644 index 000000000..d7611c122 --- /dev/null +++ b/packages/access-tools/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "test"], + "compilerOptions": { + "types": ["bun"], + "noEmit": true + } +} diff --git a/packages/seeding/src/seed.ts b/packages/seeding/src/seed.ts index 48c9f781b..a2e0faa79 100644 --- a/packages/seeding/src/seed.ts +++ b/packages/seeding/src/seed.ts @@ -663,6 +663,15 @@ export const SEED_GRANTS: readonly { resource: string; action: string }[] = [ // vocabulary. { resource: "asset:*", action: "create" }, { resource: "asset:*", action: "write" }, + // `@corbits/access-tools`' workflow-run-authenticated routes + // (`list_principals`, `list_grants`, `grant_access`, `revoke_access`): + // read on both, plus create/manage on grants so a seeded principal + // can actually mint and revoke the scoped grants it stands up for its + // own specialist agents. + { resource: "principal:*", action: "read" }, + { resource: "grant:*", action: "read" }, + { resource: "grant:*", action: "create" }, + { resource: "grant:*", action: "manage" }, ]; // The grants table has no unique constraint and the create route is a diff --git a/packages/tool-registry-publish/src/registry.ts b/packages/tool-registry-publish/src/registry.ts index 5ca17f3e1..e8b1cbc92 100644 --- a/packages/tool-registry-publish/src/registry.ts +++ b/packages/tool-registry-publish/src/registry.ts @@ -62,6 +62,7 @@ export const CORBITS_TOOL_PACKAGE_DIRS: readonly string[] = [ new URL("../../connections-tools", import.meta.url).pathname, new URL("../../catalog-tools", import.meta.url).pathname, new URL("../../agent-directory-tools", import.meta.url).pathname, + new URL("../../access-tools", import.meta.url).pathname, new URL("../../interaction-tools", import.meta.url).pathname, new URL("../../skills-tools", import.meta.url).pathname, new URL("../../mcp-tools", import.meta.url).pathname, From 1ba0890b19dbdc774f00f5ff22efedf0677b364f Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sat, 5 Sep 2026 23:51:19 -0700 Subject: [PATCH 06/24] Update docs: @corbits/access-tools README One paragraph per tool, plus a pointer to the workflow-run-authenticated routes they call. --- packages/access-tools/README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 packages/access-tools/README.md diff --git a/packages/access-tools/README.md b/packages/access-tools/README.md new file mode 100644 index 000000000..4e90f1938 --- /dev/null +++ b/packages/access-tools/README.md @@ -0,0 +1,27 @@ +# @corbits/access-tools + +Myra's principal and grant tools — a thin wrapper over the tenant's real +principal and grant rows, gated by Interchange's own grant-store +authorization (`@intx/authz`), never a reimplementation of it. Lets an +agent see who exists in its tenant and, once a human approves, grant or +revoke scoped access for the specialist agents it stands up. + +`list_principals` lists every principal (user, agent, or workflow run) +in the calling agent's tenant, with its id, kind, and status, so a grant +can target the right principal id. + +`list_grants` lists grants in the tenant, optionally filtered by +`principalId` or `resource`, so an agent can see what a principal +already has before granting more, or find the grant id `revoke_access` +needs. + +`grant_access` grants a principal one or more actions on a resource +string (e.g. `read` on `workflow-run:*`). Declared `approval: "ask"` — +a human must approve the specific principal/resource/actions triple +before anything is written. + +`revoke_access` revokes a previously created grant by its id. Also +declared `approval: "ask"`. + +See `./src/routes.ts` for the workflow-run-authenticated hub-side +routes these tools call, mounted at `/api/workflow-access`. From da6905bc3ab6456ea8835eaf671d237e53d7af42 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sat, 5 Sep 2026 23:54:07 -0700 Subject: [PATCH 07/24] access-tools: regenerate tsconfig project references Run scripts/generate-tsconfig-references.ts so the composite build graph picks up the new package's dependencies -- required for tsc --build and the tsconfig-references structural check. --- packages/access-tools/tsconfig.json | 34 +++++++++++++++++++++---- packages/access-tools/tsconfig.src.json | 30 ++++++++++++++++++++++ tsconfig.build.json | 3 +++ 3 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 packages/access-tools/tsconfig.src.json diff --git a/packages/access-tools/tsconfig.json b/packages/access-tools/tsconfig.json index d7611c122..edcebf5a1 100644 --- a/packages/access-tools/tsconfig.json +++ b/packages/access-tools/tsconfig.json @@ -1,8 +1,32 @@ { - "extends": "../../tsconfig.base.json", - "include": ["src", "test"], + "extends": "./tsconfig.src.json", "compilerOptions": { - "types": ["bun"], - "noEmit": true - } + "composite": false, + "noEmit": true, + "disableSourceOfProjectReferenceRedirect": true, + "declaration": false, + "declarationMap": false, + "emitDeclarationOnly": false, + "tsBuildInfoFile": "tsconfig.tsbuildinfo", + "rootDir": "../.." + }, + "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" + }, + { + "path": "../error-sink/tsconfig.src.json" + } + ] } diff --git a/packages/access-tools/tsconfig.src.json b/packages/access-tools/tsconfig.src.json new file mode 100644 index 000000000..5debd4afb --- /dev/null +++ b/packages/access-tools/tsconfig.src.json @@ -0,0 +1,30 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "package.json", "src/**/*.json"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], + "compilerOptions": { + "types": ["bun"], + "noEmit": true, + "composite": true, + "emitDeclarationOnly": true, + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" + }, + "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" + }, + { + "path": "../error-sink/tsconfig.src.json" + } + ] +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 74fd79f21..0355644ab 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -7,6 +7,9 @@ { "path": "./packages/access-policy/tsconfig.src.json" }, + { + "path": "./packages/access-tools/tsconfig.src.json" + }, { "path": "./packages/agent-directory-tools/tsconfig.src.json" }, From 826b27a18a7cbb291f163910e8c66b892237777c Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sat, 5 Sep 2026 23:55:40 -0700 Subject: [PATCH 08/24] access-tools: fix prettier formatting in client.test.ts --- packages/access-tools/src/client.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/access-tools/src/client.test.ts b/packages/access-tools/src/client.test.ts index 2a8fc34f4..1e44f8134 100644 --- a/packages/access-tools/src/client.test.ts +++ b/packages/access-tools/src/client.test.ts @@ -36,7 +36,9 @@ test("listPrincipals sends sidecar auth and parses the response", async () => { const result = await listPrincipals(testConfig(fetchImpl)); - expect(seenUrl).toBe("https://hub.example.com/api/workflow-access/principals"); + expect(seenUrl).toBe( + "https://hub.example.com/api/workflow-access/principals", + ); expect(seenHeaders?.["authorization"]).toBe("Bearer sc-token"); expect(seenHeaders?.["x-workflow-run-address"]).toBe("run_1@workflow"); expect(result).toEqual([ @@ -149,6 +151,8 @@ test("revokeAccess sends a DELETE to the grant's own path", async () => { await revokeAccess(testConfig(fetchImpl), "grant_1"); - expect(seenUrl).toBe("https://hub.example.com/api/workflow-access/grants/grant_1"); + expect(seenUrl).toBe( + "https://hub.example.com/api/workflow-access/grants/grant_1", + ); expect(seenMethod).toBe("DELETE"); }); From 52774a891e4bff72dab79392bb4044ce9b3cd36d Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 08:43:55 -0700 Subject: [PATCH 09/24] Add tests for the workflow-access delegation ceiling Covers the caller-authenticated /principals and /grants routes: 401 without a recognized sidecar bearer token, 403 when the caller lacks grant:*/create, 403 when a requested (resource, action) pair exceeds the caller's own authority, 201 within the ceiling, revoke 403 outside the ceiling, and 404 for an unknown grant. --- packages/access-tools/src/routes.test.ts | 299 +++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 packages/access-tools/src/routes.test.ts diff --git a/packages/access-tools/src/routes.test.ts b/packages/access-tools/src/routes.test.ts new file mode 100644 index 000000000..ff8a8de42 --- /dev/null +++ b/packages/access-tools/src/routes.test.ts @@ -0,0 +1,299 @@ +// 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. +import { afterAll, beforeAll, describe, 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 { + createWorkflowAccessRoutes, + type WorkflowRunAuthenticator, +} from "./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 = databaseUrl === undefined ? describe.skip : describe; + +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("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:*", + actions: ["read"], + }, + }), + ); + expect(res.status).toBe(403); + }); + + test("403 when the requested pair exceeds the caller's own ceiling", async () => { + const grantStore = createInMemoryGrantStore([ + { + id: generateId("grant"), + resource: "grant:*", + action: "create", + effect: "allow", + origin: "system", + conditions: null, + expiresAt: null, + roleId: null, + principalId: callerPrincipalId, + }, + { + id: generateId("grant"), + resource: "room:*", + action: "read", + effect: "allow", + origin: "system", + conditions: null, + expiresAt: null, + roleId: null, + principalId: callerPrincipalId, + }, + ]); + const app = mountedApp(grantStore); + const res = await app.request( + request("/grants", { + method: "POST", + body: { + principalId: targetPrincipalId, + resource: "room:*", + actions: ["read", "write"], + }, + }), + ); + expect(res.status).toBe(403); + const body = (await res.json()) as { error: { userMessage: string } }; + expect(body.error.userMessage).toContain("write"); + }); + + test("201 within the caller's own ceiling", async () => { + const grantStore = createInMemoryGrantStore([ + { + id: generateId("grant"), + resource: "grant:*", + action: "create", + effect: "allow", + origin: "system", + conditions: null, + expiresAt: null, + roleId: null, + principalId: callerPrincipalId, + }, + { + id: generateId("grant"), + resource: "room:*", + action: "read", + effect: "allow", + origin: "system", + conditions: null, + expiresAt: null, + roleId: null, + principalId: callerPrincipalId, + }, + ]); + const app = mountedApp(grantStore); + const res = await app.request( + request("/grants", { + method: "POST", + body: { + principalId: targetPrincipalId, + resource: "room:*", + actions: ["read"], + }, + }), + ); + expect(res.status).toBe(201); + const body = (await res.json()) as { + grants: { id: string; resource: string; action: string }[]; + }; + expect(body.grants).toHaveLength(1); + expect(body.grants[0]?.resource).toBe("room:*"); + + await db.db + .delete(schema.grant) + .where(eq(schema.grant.id, body.grants[0]!.id)); + }); + + test("revoke: 403 outside the caller's own ceiling", async () => { + const [ungrantableRow] = 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 grantStore = createInMemoryGrantStore([ + { + id: generateId("grant"), + resource: "grant:*", + action: "manage", + effect: "allow", + origin: "system", + conditions: null, + expiresAt: null, + roleId: null, + principalId: callerPrincipalId, + }, + ]); + 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([ + { + id: generateId("grant"), + resource: "grant:*", + action: "manage", + effect: "allow", + origin: "system", + conditions: null, + expiresAt: null, + roleId: null, + principalId: callerPrincipalId, + }, + ]); + const app = mountedApp(grantStore); + const res = await app.request( + request(`/grants/${generateId("grant")}`, { method: "DELETE" }), + ); + expect(res.status).toBe(404); + }); +}); From 94511cffb24ae53123b1960d7f1ada237a946bd3 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 08:43:59 -0700 Subject: [PATCH 10/24] access-tools: enforce a delegation ceiling on grant/revoke A caller holding grant:*/create could previously mint any (resource, action) pair, including ones its own principal was never granted -- self-escalation. POST /grants and DELETE /grants/:grantId now check each requested pair against the caller's own authority through the same @intx/authz authorize() call createRequireGrant uses, and refuse with a 403 naming the first disallowed pair. --- packages/access-tools/src/routes.ts | 80 +++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/packages/access-tools/src/routes.ts b/packages/access-tools/src/routes.ts index 6a814b4b9..4022cac8b 100644 --- a/packages/access-tools/src/routes.ts +++ b/packages/access-tools/src/routes.ts @@ -36,6 +36,7 @@ import { type RequireGrant, type TenantEnv, } from "@intx/hub-api"; +import { authorize } from "@intx/authz"; import type { ConditionRegistry, GrantStore } from "@intx/types/authz"; import { makeErrorEnvelope } from "@corbits/error-sink"; @@ -98,6 +99,36 @@ export type CreateWorkflowAccessRoutesDeps = { conditionRegistry: ConditionRegistry; }; +// 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 firstPairOutsideCeiling( + deps: Pick< + CreateWorkflowAccessRoutesDeps, + "grantStore" | "conditionRegistry" + >, + callerPrincipalId: string, + tenantId: string, + resource: string, + actions: readonly string[], +): Promise { + for (const action of actions) { + const result = await authorize( + deps.grantStore, + callerPrincipalId, + tenantId, + resource, + action, + deps.conditionRegistry, + ); + if (result.effect !== "allow") { + return action; + } + } + return null; +} + export function createWorkflowAccessRoutes( deps: CreateWorkflowAccessRoutesDeps, ): Hono { @@ -181,6 +212,7 @@ export function createWorkflowAccessRoutes( app.post("/grants", requireGrant("grant:*", "create"), async (c) => { const tenantCtx = c.get("tenant"); + const callerPrincipal = c.get("principal"); const body = GrantAccessInput(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { return c.json( @@ -192,6 +224,23 @@ export function createWorkflowAccessRoutes( ); } + const disallowedAction = await firstPairOutsideCeiling( + deps, + callerPrincipal.id, + tenantCtx.id, + body.resource, + body.actions, + ); + if (disallowedAction !== null) { + return c.json( + makeErrorEnvelope({ + code: "forbidden", + userMessage: `Cannot grant "${body.resource}" "${disallowedAction}": exceeds the caller's own authority`, + }), + 403, + ); + } + const target = await deps.db.query.principal.findFirst({ where: and( eq(principal.id, body.principalId), @@ -241,7 +290,38 @@ export function createWorkflowAccessRoutes( 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( + makeErrorEnvelope({ + code: "not_found", + userMessage: `No grant "${grantId}" in this tenant`, + }), + 404, + ); + } + + const disallowedAction = await firstPairOutsideCeiling( + deps, + callerPrincipal.id, + tenantCtx.id, + target.resource, + [target.action], + ); + if (disallowedAction !== null) { + return c.json( + makeErrorEnvelope({ + code: "forbidden", + userMessage: `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))) From 911aa8b9dcaa144612871bff3a8217a41d2ae4b7 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 08:44:06 -0700 Subject: [PATCH 11/24] seeding: scope access-tools grants to Myra's own run principal The four access-tools grants (principal:*/read, grant:*/read+create+ manage) were landing in SEED_GRANTS, which reconcileSeedGrants plants on every tenant's shared principal -- letting that principal read and mint grants for every principal in the tenant, not just the specialist agents Myra stands up herself. Plant them instead on the assistant workflow's own run principal, derived the same deterministic way @intx/hub-api's workflow-run- trigger mints one on a deployment's first materialized trigger (deriveRunPrincipalId(tenantId, deploymentId)). That id is stable across a relaunch because it's keyed to the deployment's own top-level run id, not to a fresh per-invocation run. Only planted once confirmDeploymentAnswers has actually triggered the assistant deployment, since that trigger is what commits the run-principal row these grants reference. --- bun.lock | 13 +++++++ packages/seeding/package.json | 1 + packages/seeding/src/seed.ts | 68 ++++++++++++++++++++++++++++++++--- 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/bun.lock b/bun.lock index 1ee803915..d9600b08a 100644 --- a/bun.lock +++ b/bun.lock @@ -1279,6 +1279,7 @@ "@corbits/workbench-digest-workflow": "workspace:*", "@corbits/workflows": "workspace:*", "@intx/agent": "workspace:*", + "@intx/hub-common": "0.3.0", "@intx/inference": "workspace:*", "@intx/types": "workspace:*", "@workbench/templates": "workspace:*", @@ -3600,8 +3601,12 @@ "@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=="], @@ -3624,6 +3629,10 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.8", "", {}, "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q=="], + "@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=="], + "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=="], @@ -3678,6 +3687,8 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@corbits/inbox/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -3724,6 +3735,8 @@ "@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=="], "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], diff --git a/packages/seeding/package.json b/packages/seeding/package.json index 9d85948e7..4da40ea34 100644 --- a/packages/seeding/package.json +++ b/packages/seeding/package.json @@ -33,6 +33,7 @@ "@corbits/workbench-digest-workflow": "workspace:*", "@corbits/workflows": "workspace:*", "@intx/agent": "workspace:*", + "@intx/hub-common": "0.3.0", "@intx/inference": "workspace:*", "@intx/types": "workspace:*", "@corbits/connections": "workspace:*", diff --git a/packages/seeding/src/seed.ts b/packages/seeding/src/seed.ts index a2e0faa79..74be9c52f 100644 --- a/packages/seeding/src/seed.ts +++ b/packages/seeding/src/seed.ts @@ -25,6 +25,7 @@ import { Capability, } from "@intx/types"; import { type } from "arktype"; +import { deriveRunPrincipalId } from "@intx/hub-common"; import type { InferencePreference } from "@intx/agent"; import { buildAssistantWorkflow, @@ -663,11 +664,21 @@ export const SEED_GRANTS: readonly { resource: string; action: string }[] = [ // vocabulary. { resource: "asset:*", action: "create" }, { resource: "asset:*", action: "write" }, - // `@corbits/access-tools`' workflow-run-authenticated routes - // (`list_principals`, `list_grants`, `grant_access`, `revoke_access`): - // read on both, plus create/manage on grants so a seeded principal - // can actually mint and revoke the scoped grants it stands up for its - // own specialist agents. +]; + +// `@corbits/access-tools`' workflow-run-authenticated routes +// (`list_principals`, `list_grants`, `grant_access`, `revoke_access`): +// read on both, plus create/manage on grants so Myra can mint and revoke +// the scoped grants she stands up for her own specialist agents. Planted +// ONLY on Myra's own run principal (see `plantAssistantAccessToolsGrants`) +// — every other `SEED_GRANTS` entry above lands on the tenant's shared +// principal, and `principal:*`/`grant:*` there would let that shared +// principal read and mint grants for every principal in the tenant, not +// just its own specialist agents. +const ASSISTANT_ACCESS_TOOLS_GRANTS: readonly { + resource: string; + action: string; +}[] = [ { resource: "principal:*", action: "read" }, { resource: "grant:*", action: "read" }, { resource: "grant:*", action: "create" }, @@ -759,6 +770,45 @@ export async function reconcileSeedGrants( } } +/** + * Plants the `@corbits/access-tools` grants on Myra's OWN run principal, + * not the tenant's shared principal (CL-7467). `deriveRunPrincipalId` is + * the same deterministic `(tenantId, runId)` derivation + * `@intx/hub-api`'s `workflow-run-trigger` uses to mint a deployment's + * run principal on its first materialized trigger — since a deployment's + * top-level run id never changes across a relaunch, this stays stable + * for the life of the deployment, unlike a fresh per-invocation run id. + * Only called once `confirmDeploymentAnswers` has actually triggered the + * assistant deployment (`confirmDeployments: true`), because that + * trigger is what commits the run-principal row this grant references; + * the connect flow's `confirmDeployments: false` path defers this until + * the tenant's next `workbench seed` run finds a confirmed deployment. + */ +async function plantAssistantAccessToolsGrants( + api: ApiCall, + cookies: string[], + args: { tenantId: string; deploymentId: string }, + log: (line: string) => void, +): Promise { + const runPrincipalId = await deriveRunPrincipalId( + args.tenantId, + args.deploymentId, + ); + for (const grant of ASSISTANT_ACCESS_TOOLS_GRANTS) { + await plantGrant( + api, + cookies, + { + tenantId: args.tenantId, + principalId: runPrincipalId, + resource: grant.resource, + action: grant.action, + }, + log, + ); + } +} + async function plantDefaultSkills( api: ApiCall, cookies: string[], @@ -1336,6 +1386,14 @@ export async function seedTenant(args: SeedTenantArgs): Promise { }, log, ); + if (workflow.assetName === SETUP_AGENT_ASSET_NAME) { + await plantAssistantAccessToolsGrants( + api, + cookies, + { tenantId: tenant.tenantId, deploymentId }, + log, + ); + } } confirmed += 1; } From 86e40412ae26700215f7342ae22b5056ad3f0b34 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 08:44:16 -0700 Subject: [PATCH 12/24] Add the workflow-run-authenticated catalog-admin routes and their tests A workflow child authenticates with its sidecar bearer token and run address, never a browser session, so it cannot reach the tenant-admin catalog routes @intx/hub-api mounts for the settings UI. This adds a mirror at /api/workflow-catalog-admin, gated by the same grant store and pushing the same source-update refresh on every offering write. --- apps/hub/package.json | 1 + bun.lock | 23 +- packages/catalog-tools/package.json | 13 +- packages/catalog-tools/src/routes.test.ts | 163 ++++++++ packages/catalog-tools/src/routes.ts | 444 ++++++++++++++++++++++ packages/catalog-tools/tsconfig.json | 12 + packages/catalog-tools/tsconfig.src.json | 12 + 7 files changed, 664 insertions(+), 4 deletions(-) create mode 100644 packages/catalog-tools/src/routes.test.ts create mode 100644 packages/catalog-tools/src/routes.ts diff --git a/apps/hub/package.json b/apps/hub/package.json index 560970163..2ceeba1af 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -19,6 +19,7 @@ "@corbits/artifacts": "github:corbitsdev/corbits-artifacts#81049ed24a64e927498c7238bda6ffa66b63d2ab", "@corbits/artifacts-hub": "workspace:*", "@corbits/bench": "workspace:*", + "@corbits/catalog-tools": "workspace:*", "@corbits/chat": "workspace:*", "@corbits/commands": "workspace:*", "@corbits/error-sink": "workspace:*", diff --git a/bun.lock b/bun.lock index 60e38eca2..37eb81581 100644 --- a/bun.lock +++ b/bun.lock @@ -40,6 +40,7 @@ "@corbits/artifacts": "github:corbitsdev/corbits-artifacts#81049ed24a64e927498c7238bda6ffa66b63d2ab", "@corbits/artifacts-hub": "workspace:*", "@corbits/bench": "workspace:*", + "@corbits/catalog-tools": "workspace:*", "@corbits/chat": "workspace:*", "@corbits/commands": "workspace:*", "@corbits/connections": "workspace:*", @@ -408,12 +409,18 @@ }, "packages/catalog-tools": { "name": "@corbits/catalog-tools", - "version": "0.0.3", + "version": "0.0.4", "dependencies": { "@corbits/error-sink": "workspace:*", "@intx/agent": "workspace:*", + "@intx/db": "workspace:*", + "@intx/hub-api": "workspace:*", + "@intx/hub-common": "0.3.0", + "@intx/hub-sessions": "workspace:*", "@intx/types": "workspace:*", "arktype": "catalog:", + "drizzle-orm": "catalog:", + "hono": "catalog:", }, "devDependencies": { "@types/bun": "catalog:", @@ -3564,6 +3571,16 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@corbits/artifacts-hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#81049ed", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-81049ed", "sha512-oTE0iFDyQdz0ifG1epo39pwaCaYaw19YcKXwfaZqAEQ56a1g9YIozXwH9CG4NaUTwcJKUeYGuNls6oJsMPisCw=="], + + "@corbits/chat-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/context-menu/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/plugins-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/settings-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "@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=="], @@ -3586,6 +3603,10 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], + "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#81049ed", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-81049ed", "sha512-oTE0iFDyQdz0ifG1epo39pwaCaYaw19YcKXwfaZqAEQ56a1g9YIozXwH9CG4NaUTwcJKUeYGuNls6oJsMPisCw=="], + + "@workbench/web/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "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=="], diff --git a/packages/catalog-tools/package.json b/packages/catalog-tools/package.json index 59df51047..ca01d6968 100644 --- a/packages/catalog-tools/package.json +++ b/packages/catalog-tools/package.json @@ -2,11 +2,12 @@ "name": "@corbits/catalog-tools", "private": true, "description": "The list_model_concepts, pick_models and estimate_run_cost tools as an @intx/agent bundle: an agent asks for a model by what the work needs and gets back an ordered, priced chain of what this bench can actually reach — never a model named by hand.", - "version": "0.0.3", + "version": "0.0.4", "license": "LGPL-2.1-or-later", "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./routes": "./src/routes.ts" }, "scripts": { "typecheck": "tsc --noEmit", @@ -15,8 +16,14 @@ "dependencies": { "@corbits/error-sink": "workspace:*", "@intx/agent": "workspace:*", + "@intx/db": "workspace:*", + "@intx/hub-api": "workspace:*", + "@intx/hub-common": "0.3.0", + "@intx/hub-sessions": "workspace:*", "@intx/types": "workspace:*", - "arktype": "catalog:" + "arktype": "catalog:", + "drizzle-orm": "catalog:", + "hono": "catalog:" }, "devDependencies": { "@types/bun": "catalog:", diff --git a/packages/catalog-tools/src/routes.test.ts b/packages/catalog-tools/src/routes.test.ts new file mode 100644 index 000000000..fbc95b77e --- /dev/null +++ b/packages/catalog-tools/src/routes.test.ts @@ -0,0 +1,163 @@ +import { expect, test } from "bun:test"; + +import type { ConditionRegistry, GrantStore } from "@intx/types/authz"; + +import { + createWorkflowCatalogAdminRoutes, + type WorkflowRunAuthenticator, +} from "./routes"; + +const ACTIVE_TENANT = { + id: "tenant_1", + name: "Acme", + slug: "acme", + domain: "acme.example.com", + parentId: null, + config: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const ACTIVE_PRINCIPAL = { + id: "prin_1", + tenantId: "tenant_1", + kind: "workflow" as const, + refId: "run_1", + status: "active" as const, + createdAt: new Date(), + updatedAt: new Date(), +}; + +function fakeDb(overrides?: { + tenant?: typeof ACTIVE_TENANT | undefined; + principal?: (Omit & { status: string }) | undefined; +}) { + return { + query: { + tenant: { + findFirst: async () => + overrides && "tenant" in overrides + ? overrides.tenant + : ACTIVE_TENANT, + }, + principal: { + findFirst: async () => + overrides && "principal" in overrides + ? overrides.principal + : ACTIVE_PRINCIPAL, + }, + model: { findMany: async () => [] }, + }, + } as unknown as import("@intx/db").DB["db"]; +} + +function allowAllGrantStore(): GrantStore { + return { + collectGrants: async () => [ + { + id: "grant_1", + resource: "model:*", + action: "read", + effect: "allow", + origin: "system", + conditions: null, + expiresAt: null, + roleId: null, + principalId: null, + }, + ], + collectGrantsInChain: async () => [], + }; +} + +function denyAllGrantStore(): GrantStore { + return { + collectGrants: async () => [], + collectGrantsInChain: async () => [], + }; +} + +function resolvingAuthenticator(): WorkflowRunAuthenticator { + return { + resolve: async (token, address) => + token === "sc-token" && address === "run_1@workflow" + ? { tenantId: "tenant_1", principalId: "prin_1" } + : null, + }; +} + +function requestModels(app: ReturnType) { + return app.request("/models", { + headers: { + authorization: "Bearer sc-token", + "x-workflow-run-address": "run_1@workflow", + }, + }); +} + +test("rejects a request with no sidecar bearer token", async () => { + const app = createWorkflowCatalogAdminRoutes({ + db: fakeDb(), + authenticator: resolvingAuthenticator(), + grantStore: allowAllGrantStore(), + conditionRegistry: {} as ConditionRegistry, + sidecarRouter: {} as never, + credentialCipher: {} as never, + }); + const response = await app.request("/models"); + expect(response.status).toBe(401); +}); + +test("rejects a request whose run address the authenticator does not resolve", async () => { + const app = createWorkflowCatalogAdminRoutes({ + db: fakeDb(), + authenticator: { resolve: async () => null }, + grantStore: allowAllGrantStore(), + conditionRegistry: {} as ConditionRegistry, + sidecarRouter: {} as never, + credentialCipher: {} as never, + }); + const response = await requestModels(app); + expect(response.status).toBe(401); +}); + +test("rejects a resolved run whose principal row is not active", async () => { + const app = createWorkflowCatalogAdminRoutes({ + db: fakeDb({ principal: { ...ACTIVE_PRINCIPAL, status: "suspended" } }), + authenticator: resolvingAuthenticator(), + grantStore: allowAllGrantStore(), + conditionRegistry: {} as ConditionRegistry, + sidecarRouter: {} as never, + credentialCipher: {} as never, + }); + const response = await requestModels(app); + expect(response.status).toBe(401); +}); + +test("an authenticated principal with no model:read grant gets a real 403, not a bundle-local approximation", async () => { + const app = createWorkflowCatalogAdminRoutes({ + db: fakeDb(), + authenticator: resolvingAuthenticator(), + grantStore: denyAllGrantStore(), + conditionRegistry: {} as ConditionRegistry, + sidecarRouter: {} as never, + credentialCipher: {} as never, + }); + const response = await requestModels(app); + expect(response.status).toBe(403); +}); + +test("an authenticated, granted principal lists this tenant's own models", async () => { + const app = createWorkflowCatalogAdminRoutes({ + db: fakeDb(), + authenticator: resolvingAuthenticator(), + grantStore: allowAllGrantStore(), + conditionRegistry: {} as ConditionRegistry, + sidecarRouter: {} as never, + credentialCipher: {} as never, + }); + const response = await requestModels(app); + expect(response.status).toBe(200); + const body = (await response.json()) as { data: unknown[]; nextCursor: null }; + expect(body).toEqual({ data: [], nextCursor: null }); +}); diff --git a/packages/catalog-tools/src/routes.ts b/packages/catalog-tools/src/routes.ts new file mode 100644 index 000000000..62e5852c9 --- /dev/null +++ b/packages/catalog-tools/src/routes.ts @@ -0,0 +1,444 @@ +// Workflow-run-authenticated catalog-admin surface: the execution half of +// `@corbits/catalog-tools`' `create_offering`, `set_offering_priority`, and +// `disable_offering` tools. A workflow child has no browser session, only +// its sidecar bearer token and its own run address, so it cannot use +// `@intx/hub-api`'s tenant-session `/api/tenants/:id/catalog/{models, +// providers,offerings}` routes directly — the same reasoning +// `@corbits/access-tools`'s own `/api/workflow-access` mirror gives for its +// principal/grant surface. +// +// Mounted OUTSIDE the tenant prefix for that reason, at +// `/api/workflow-catalog-admin`. Every write is scoped to the authenticated +// run's own tenant alone — identity never rides in a request body. +// +// This is a thin wrapper, not a reimplementation: every route below reads +// or writes the SAME `model`/`modelProvider`/`modelOffering` rows +// `@intx/hub-api`'s own tenant-session catalog routes do +// (`vendor/intx/hub-api/src/routes/model-offerings.ts` and friends), and +// authorization runs through the SAME `createRequireGrant` against the +// same grant store and condition registry, resolved for the caller's real +// tenant/principal rows exactly like `@corbits/access-tools`' own +// `/api/workflow-access` mount resolves them. Those route factories aren't +// exported from `@intx/hub-api`'s public surface (only mounted internally +// by its own `createApp`), so this reimplements only their thin handler +// bodies against the shared schema and shared response shapes from +// `@intx/types` — never the grant-store authorization itself. Every +// offering write also pushes the same `pushSourceUpdatesSubtree` sidecar +// refresh the tenant-session routes push, so a running instance resolves +// the change the same way either surface made it. +import { and, desc, eq, lt, or } from "drizzle-orm"; +import { Hono } from "hono"; +import { type } from "arktype"; + +import type { DB } from "@intx/db"; +import { model, modelProvider, modelOffering } from "@intx/db/schema"; +import { parseModelOfferingRow } from "@intx/db"; +import { generateId } from "@intx/hub-common"; +import { + createRequireGrant, + idResource, + type RequireGrant, + type TenantEnv, +} from "@intx/hub-api"; +import type { ConditionRegistry, GrantStore } from "@intx/types/authz"; +import { + CreateModelOffering, + UpdateModelOffering, + base64urlDecode, + base64urlEncode, + type CredentialCipher, +} from "@intx/types"; +import { makeErrorEnvelope } from "@corbits/error-sink"; +import { pushSourceUpdatesSubtree, type SidecarRouter } from "@intx/hub-sessions"; + +export type WorkflowCatalogAdminRunScope = { + readonly tenantId: string; + readonly principalId: string; +}; + +export type WorkflowRunAuthenticator = { + resolve( + token: string, + runAddress: string, + ): Promise; +}; + +export type CreateWorkflowCatalogAdminRoutesDeps = { + db: DB["db"]; + authenticator: WorkflowRunAuthenticator; + grantStore: GrantStore; + conditionRegistry: ConditionRegistry; + sidecarRouter: SidecarRouter; + credentialCipher: CredentialCipher; +}; + +function formatModel(row: typeof model.$inferSelect) { + return { + id: row.id, + tenantId: row.tenantId, + canonicalName: row.canonicalName, + displayName: row.displayName, + description: row.description, + disabled: row.disabled, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function formatModelProvider(row: typeof modelProvider.$inferSelect) { + return { + id: row.id, + tenantId: row.tenantId, + name: row.name, + plugin: row.plugin, + baseURL: row.baseURL, + credentialId: row.credentialId, + walletId: row.walletId, + disabled: row.disabled, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function formatModelOffering(row: typeof modelOffering.$inferSelect) { + const parsed = parseModelOfferingRow(row); + return { + id: parsed.id, + tenantId: parsed.tenantId, + modelId: parsed.modelId, + providerId: parsed.providerId, + priority: parsed.priority, + deploymentTags: parsed.deploymentTags, + capabilities: parsed.capabilities, + quirks: parsed.quirks, + disabled: parsed.disabled, + createdAt: parsed.createdAt.toISOString(), + updatedAt: parsed.updatedAt.toISOString(), + }; +} + +// --- Minimal keyset pagination, mirroring `@intx/hub-api`'s own +// `parsePageParams`/`cursorCondition`/`pageOrder`/`paginatedResponse` +// (`vendor/intx/hub-api/src/pagination.ts`) — not exported from its +// public surface, so reimplemented here at the same, small, generic +// shape rather than duplicating any domain logic. ----------------------- + +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", +}); +type CursorData = typeof CursorData.infer; + +function encodeCursor(createdAt: Date, id: string): string { + const data: CursorData = { t: createdAt.toISOString(), id }; + return base64urlEncode(new TextEncoder().encode(JSON.stringify(data))); +} + +function decodeCursor(cursor: string): CursorData | null { + try { + const json = new TextDecoder().decode(base64urlDecode(cursor)); + const parsed: unknown = JSON.parse(json); + const data = CursorData(parsed); + return data instanceof type.errors ? null : data; + } catch { + // report-error-ignore: a malformed opaque cursor is a client passing + // back garbage, not an operational incident — treated the same as + // the arktype mismatch just above, both fall through to `null` and + // the caller starts the listing over from the first page. + return null; + } +} + +function parsePageParams(query: { + cursor: string | undefined; + limit: string | undefined; +}): { limit: number; cursor: CursorData | null } { + const raw = query.limit ? Number(query.limit) : DEFAULT_LIMIT; + const limit = Number.isNaN(raw) + ? DEFAULT_LIMIT + : Math.min(Math.max(1, raw), MAX_LIMIT); + const cursor = query.cursor ? decodeCursor(query.cursor) : null; + return { limit, cursor }; +} + +function paginatedResponse( + 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 ? encodeCursor(lastRow.createdAt, lastRow.id) : null, + }; +} + +export function createWorkflowCatalogAdminRoutes( + deps: CreateWorkflowCatalogAdminRoutesDeps, +): 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( + makeErrorEnvelope({ + code: "unauthorized", + userMessage: + "Missing or unrecognized sidecar bearer token / run address", + }), + 401, + ); + } + + const [tenantRow, principalRow] = await Promise.all([ + deps.db.query.tenant.findFirst({ + where: (row, { eq: eqCol }) => eqCol(row.id, scope.tenantId), + }), + deps.db.query.principal.findFirst({ + where: (row, { and: andCols, eq: eqCol }) => + andCols( + eqCol(row.id, scope.principalId), + eqCol(row.tenantId, scope.tenantId), + ), + }), + ]); + if ( + tenantRow === undefined || + principalRow === undefined || + principalRow.status !== "active" + ) { + return c.json( + makeErrorEnvelope({ + code: "unauthorized", + userMessage: + "Missing or unrecognized sidecar bearer token / run address", + }), + 401, + ); + } + c.set("tenant", tenantRow); + c.set("principal", principalRow); + await next(); + }); + + app.get("/models", requireGrant("model:*", "read"), async (c) => { + const tenantCtx = c.get("tenant"); + const { limit, cursor } = parsePageParams({ + cursor: c.req.query("cursor"), + limit: c.req.query("limit"), + }); + const conditions = [eq(model.tenantId, tenantCtx.id)]; + if (cursor) { + conditions.push( + or( + lt(model.createdAt, new Date(cursor.t)), + and(eq(model.createdAt, new Date(cursor.t)), lt(model.id, cursor.id)), + ) ?? eq(model.id, model.id), + ); + } + const rows = await deps.db.query.model.findMany({ + where: and(...conditions), + orderBy: [desc(model.createdAt), desc(model.id)], + limit, + }); + return c.json(paginatedResponse(rows.map(formatModel), rows, limit)); + }); + + app.get( + "/providers", + requireGrant("model-provider:*", "read"), + async (c) => { + const tenantCtx = c.get("tenant"); + const { limit, cursor } = parsePageParams({ + cursor: c.req.query("cursor"), + limit: c.req.query("limit"), + }); + const conditions = [eq(modelProvider.tenantId, tenantCtx.id)]; + if (cursor) { + conditions.push( + or( + lt(modelProvider.createdAt, new Date(cursor.t)), + and( + eq(modelProvider.createdAt, new Date(cursor.t)), + lt(modelProvider.id, cursor.id), + ), + ) ?? eq(modelProvider.id, modelProvider.id), + ); + } + const rows = await deps.db.query.modelProvider.findMany({ + where: and(...conditions), + orderBy: [desc(modelProvider.createdAt), desc(modelProvider.id)], + limit, + }); + return c.json( + paginatedResponse(rows.map(formatModelProvider), rows, limit), + ); + }, + ); + + app.post( + "/offerings", + requireGrant("model-offering:*", "create"), + async (c) => { + const tenantCtx = c.get("tenant"); + const body = CreateModelOffering(await c.req.json().catch(() => undefined)); + if (body instanceof type.errors) { + return c.json( + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid offering request: ${body.summary}`, + }), + 400, + ); + } + + const modelRow = await deps.db.query.model.findFirst({ + where: and(eq(model.id, body.modelId), eq(model.tenantId, tenantCtx.id)), + }); + if (modelRow === undefined) { + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "Model not found in this tenant", + }), + 404, + ); + } + + const providerRow = await deps.db.query.modelProvider.findFirst({ + where: and( + eq(modelProvider.id, body.providerId), + eq(modelProvider.tenantId, tenantCtx.id), + ), + }); + if (providerRow === undefined) { + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: "Provider not found in this tenant", + }), + 404, + ); + } + + const existing = await deps.db.query.modelOffering.findFirst({ + where: and( + eq(modelOffering.tenantId, tenantCtx.id), + eq(modelOffering.modelId, body.modelId), + eq(modelOffering.providerId, body.providerId), + ), + }); + if (existing !== undefined) { + return c.json( + makeErrorEnvelope({ + code: "conflict", + userMessage: + "An offering for this model and provider already exists", + }), + 409, + ); + } + + const now = new Date(); + const [row] = await deps.db + .insert(modelOffering) + .values({ + id: generateId("modelOffering"), + tenantId: tenantCtx.id, + modelId: body.modelId, + providerId: body.providerId, + priority: body.priority ?? 0, + deploymentTags: body.deploymentTags ?? [], + capabilities: body.capabilities ?? [], + quirks: body.quirks ?? null, + createdAt: now, + updatedAt: now, + }) + .returning(); + if (row === undefined) { + throw new Error("Insert into model_offering returned no row"); + } + + void pushSourceUpdatesSubtree( + deps.db, + deps.sidecarRouter, + tenantCtx.id, + deps.credentialCipher, + ); + return c.json(formatModelOffering(row), 201); + }, + ); + + app.patch( + "/offerings/:offeringId", + requireGrant(idResource("model-offering", "offeringId"), "manage"), + async (c) => { + const tenantCtx = c.get("tenant"); + const offeringId = c.req.param("offeringId"); + const body = UpdateModelOffering(await c.req.json().catch(() => undefined)); + if (body instanceof type.errors) { + return c.json( + makeErrorEnvelope({ + code: "bad_request", + userMessage: `invalid offering update: ${body.summary}`, + }), + 400, + ); + } + + const updates: Record = { updatedAt: new Date() }; + if (body.priority !== undefined) updates["priority"] = body.priority; + if (body.deploymentTags !== undefined) + updates["deploymentTags"] = body.deploymentTags; + if (body.capabilities !== undefined) + updates["capabilities"] = body.capabilities; + if (body.quirks !== undefined) updates["quirks"] = body.quirks; + if (body.disabled !== undefined) updates["disabled"] = body.disabled; + + const [updated] = await deps.db + .update(modelOffering) + .set(updates) + .where( + and( + eq(modelOffering.id, offeringId), + eq(modelOffering.tenantId, tenantCtx.id), + ), + ) + .returning(); + + if (updated === undefined) { + return c.json( + makeErrorEnvelope({ + code: "not_found", + userMessage: `No offering "${offeringId}" in this tenant`, + }), + 404, + ); + } + + void pushSourceUpdatesSubtree( + deps.db, + deps.sidecarRouter, + tenantCtx.id, + deps.credentialCipher, + ); + return c.json(formatModelOffering(updated)); + }, + ); + + return app; +} diff --git a/packages/catalog-tools/tsconfig.json b/packages/catalog-tools/tsconfig.json index 081530363..b9efa0c3b 100644 --- a/packages/catalog-tools/tsconfig.json +++ b/packages/catalog-tools/tsconfig.json @@ -16,8 +16,20 @@ { "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/hub-sessions/tsconfig.src.json" + }, { "path": "../../vendor/intx/types/tsconfig.src.json" + }, + { + "path": "../error-sink/tsconfig.src.json" } ] } diff --git a/packages/catalog-tools/tsconfig.src.json b/packages/catalog-tools/tsconfig.src.json index 3959f6247..2311cd544 100644 --- a/packages/catalog-tools/tsconfig.src.json +++ b/packages/catalog-tools/tsconfig.src.json @@ -13,8 +13,20 @@ { "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/hub-sessions/tsconfig.src.json" + }, { "path": "../../vendor/intx/types/tsconfig.src.json" + }, + { + "path": "../error-sink/tsconfig.src.json" } ] } From f3d1251c0d4c90bcb07ed1c682cbae3c424642f5 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 08:44:22 -0700 Subject: [PATCH 13/24] Catalog-tools: authenticate offering writes through the new run mount create_offering, set_offering_priority, and disable_offering called the tenant-session catalog routes directly, so a workflow run's sidecar bearer and run address 401ed against them. Point the admin client at the new /api/workflow-catalog-admin mount instead, mounted in apps/hub beside its own read-only inference-catalog counterpart, and drop the tenantId the client no longer needs to carry now that the hub resolves it from the same credential. --- apps/hub/src/index.ts | 22 +++++++++++++++ packages/catalog-tools/src/client.ts | 36 ++++++++++++------------- packages/catalog-tools/src/index.ts | 6 +++++ packages/catalog-tools/src/tool.test.ts | 26 +++++++++--------- packages/catalog-tools/src/tool.ts | 9 +++---- 5 files changed, 62 insertions(+), 37 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 705b62889..e460dab95 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -165,6 +165,7 @@ import { createDrizzleSidecarPlacementStore, createSidecarPlacementRoutes, } from "@corbits/sidecar-placement"; +import { createWorkflowCatalogAdminRoutes } from "@corbits/catalog-tools/routes"; import { generateId } from "@intx/hub-common"; import { ensureDefaultTenant } from "./default-tenant"; @@ -2865,6 +2866,27 @@ export async function createHub(config: HubConfig) { getPolicy: (tenantId) => benchModelPolicy.store.getPolicy(tenantId), }), ); + // Myra's own catalog-administration surface + // (`@corbits/catalog-tools`' `create_offering`/`set_offering_priority`/ + // `disable_offering`): the workflow-run-authenticated counterpart to + // `@intx/hub-api`'s tenant-session `/api/tenants/:tenantId/catalog/ + // {models,providers,offerings}` mount, which only accepts a browser + // session and so 401s a workflow child. Reuses the SAME grant store and + // condition registry every other extension's own requireGrant check + // runs against, and the SAME sidecarRouter/credentialCipher the + // tenant-session mount pushes source updates through on every offering + // write, so a write through either surface propagates identically. + app.route( + "/api/workflow-catalog-admin", + createWorkflowCatalogAdminRoutes({ + db, + authenticator: createWorkflowRunAuthenticator({ db }), + grantStore: chatGrantStore, + conditionRegistry: chatConditionRegistry, + sidecarRouter, + credentialCipher, + }), + ); // 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 diff --git a/packages/catalog-tools/src/client.ts b/packages/catalog-tools/src/client.ts index 267026b36..c6130e02f 100644 --- a/packages/catalog-tools/src/client.ts +++ b/packages/catalog-tools/src/client.ts @@ -184,27 +184,27 @@ export async function fetchEstimate( ); } -// --- Tenant catalog administration ----------------------------------- +// --- Catalog administration ------------------------------------------- // // `list_model_concepts`/`pick_models`/`estimate_run_cost` above read the // run-authenticated `/api/workflow-inference-catalog` surface. The tools -// below instead write through the tenant-admin surface Interchange's own -// `apps/hub-web` settings UI uses directly: -// `vendor/intx/hub-api/src/routes/{models,model-providers,model-offerings}.ts`, -// mounted at `/api/tenants/:id/catalog/{models,providers,offerings}`. They -// carry the same sidecar bearer token and run address as every other call -// in this file — the hub is expected to resolve that credential to this -// tenant's own principal for these routes exactly as it already does for -// `/api/workflow-inference-catalog`. +// below write through `@corbits/catalog-tools`' own workflow-run- +// authenticated mirror of the tenant-admin catalog surface Interchange's +// own `apps/hub-web` settings UI uses via a browser session +// (`vendor/intx/hub-api/src/routes/{models,model-providers, +// model-offerings}.ts`, mounted at `/api/tenants/:id/catalog/*`): see +// `./routes.ts`'s `createWorkflowCatalogAdminRoutes`, mounted in +// `apps/hub` at `/api/workflow-catalog-admin`. Same sidecar bearer token +// and run address as every other call in this file; the hub resolves +// that credential to the run's own tenant/principal, so the tenant never +// rides in the request the way it does in the tenant-admin routes' URL +// path. export interface CatalogAdminClientConfig { /** Same hub origin as {@link CatalogToolClientConfig.hubCatalogUrl}. */ readonly hubCatalogUrl: string; readonly sidecarToken: string; readonly address: string; - /** The tenant this run belongs to — the tenant-admin routes are scoped - * by this id in their URL path. */ - readonly tenantId: string; /** Override for tests; defaults to the global `fetch`. */ readonly fetchImpl?: typeof fetch; } @@ -239,7 +239,7 @@ async function adminCall( ): Promise { const doFetch = config.fetchImpl ?? fetch; const response = await doFetch( - `${config.hubCatalogUrl}/api/tenants/${config.tenantId}${path}`, + `${config.hubCatalogUrl}/api/workflow-catalog-admin${path}`, { ...init, headers: { ...adminAuthHeaders(config), ...init?.headers } }, ); if (!response.ok) { @@ -272,7 +272,7 @@ export async function findModelIdByCanonicalName( : "?limit=100"; const page = await adminCall( config, - `/catalog/models${qs}`, + `/models${qs}`, "Listing this workbench's own models", (raw) => ModelsPage(raw), ); @@ -300,7 +300,7 @@ export async function findModelProviderIdByName( : "?limit=100"; const page = await adminCall( config, - `/catalog/providers${qs}`, + `/providers${qs}`, "Listing this workbench's own model providers", (raw) => ModelProvidersPage(raw), ); @@ -328,7 +328,7 @@ export async function createOffering( ): Promise { return await adminCall( config, - "/catalog/offerings", + "/offerings", "Creating the offering", (raw) => ModelOfferingResponse(raw), { method: "POST", body: JSON.stringify(CreateModelOffering.assert(request)) }, @@ -343,7 +343,7 @@ export async function setOfferingPriority( ): Promise { return await adminCall( config, - `/catalog/offerings/${offeringId}`, + `/offerings/${offeringId}`, "Setting the offering's priority", (raw) => ModelOfferingResponse(raw), { @@ -361,7 +361,7 @@ export async function disableOffering( ): Promise { return await adminCall( config, - `/catalog/offerings/${offeringId}`, + `/offerings/${offeringId}`, "Disabling the offering", (raw) => ModelOfferingResponse(raw), { diff --git a/packages/catalog-tools/src/index.ts b/packages/catalog-tools/src/index.ts index 9b4d21e07..29bbe3e7b 100644 --- a/packages/catalog-tools/src/index.ts +++ b/packages/catalog-tools/src/index.ts @@ -24,3 +24,9 @@ export { type WorkflowCatalogEnv, type WorkflowCatalogOfferingEnv, } from "./tool"; +// The workflow-run-authenticated hub-side routes these tools call +// (`create_offering`/`set_offering_priority`/`disable_offering`) live at +// `./routes`, a separate export subpath, not re-exported here: they pull +// in `hono`/`drizzle-orm`/`@intx/db`, server-only dependencies this +// package's main entry — consumed by a running agent's tool-loading path +// — must never carry. diff --git a/packages/catalog-tools/src/tool.test.ts b/packages/catalog-tools/src/tool.test.ts index f170576fa..ed3eb2894 100644 --- a/packages/catalog-tools/src/tool.test.ts +++ b/packages/catalog-tools/src/tool.test.ts @@ -245,7 +245,6 @@ function offeringEnv(): WorkflowCatalogOfferingEnv { hubCatalogUrl: "https://hub.example.com", sidecarToken: "sc-token", address: "run_1@workflow", - tenantId: "tenant_1", } as unknown as WorkflowCatalogOfferingEnv; } @@ -338,12 +337,11 @@ describe("the offering bundle's shape", () => { ]); }); - test("requires the tenant id alongside the sanctioned workflow-run env keys", () => { + test("requires only the sanctioned workflow-run env keys", () => { expect(catalogOfferingTools.requires).toEqual([ "hubCatalogUrl", "sidecarToken", "address", - "tenantId", ]); }); }); @@ -351,9 +349,9 @@ describe("the offering bundle's shape", () => { describe("create_offering", () => { test("resolves both names to ids, then creates the offering", async () => { globalThis.fetch = routedFetch({ - "/catalog/models": ok(OWN_MODELS_PAGE), - "/catalog/providers": ok(OWN_PROVIDERS_PAGE), - "/catalog/offerings": ok(OFFERING_ROW, 201), + "/models": ok(OWN_MODELS_PAGE), + "/providers": ok(OWN_PROVIDERS_PAGE), + "/offerings": ok(OFFERING_ROW, 201), }); const result = await catalogOfferingTools(offeringEnv()).run( callFor(CREATE_OFFERING_TOOL, { @@ -370,8 +368,8 @@ describe("create_offering", () => { test("an unknown canonical name is refused, never a guessed id", async () => { globalThis.fetch = routedFetch({ - "/catalog/models": ok({ data: [], nextCursor: null }), - "/catalog/providers": ok(OWN_PROVIDERS_PAGE), + "/models": ok({ data: [], nextCursor: null }), + "/providers": ok(OWN_PROVIDERS_PAGE), }); const result = await catalogOfferingTools(offeringEnv()).run( callFor(CREATE_OFFERING_TOOL, { @@ -388,8 +386,8 @@ describe("create_offering", () => { test("a 403 from the hub surfaces as a refusal, not a thrown crash", async () => { globalThis.fetch = routedFetch({ - "/catalog/models": () => forbidden(), - "/catalog/providers": ok(OWN_PROVIDERS_PAGE), + "/models": () => forbidden(), + "/providers": ok(OWN_PROVIDERS_PAGE), }); const result = await catalogOfferingTools(offeringEnv()).run( callFor(CREATE_OFFERING_TOOL, { @@ -408,7 +406,7 @@ describe("create_offering", () => { describe("set_offering_priority", () => { test("reorders an offering this workbench already owns", async () => { globalThis.fetch = routedFetch({ - "/catalog/offerings/offering_1": ok({ ...OFFERING_ROW, priority: 5 }), + "/offerings/offering_1": ok({ ...OFFERING_ROW, priority: 5 }), }); const result = await catalogOfferingTools(offeringEnv()).run( callFor(SET_OFFERING_PRIORITY_TOOL, { @@ -423,7 +421,7 @@ describe("set_offering_priority", () => { test("a 403 from the hub surfaces as a refusal", async () => { globalThis.fetch = routedFetch({ - "/catalog/offerings/offering_1": () => forbidden(), + "/offerings/offering_1": () => forbidden(), }); const result = await catalogOfferingTools(offeringEnv()).run( callFor(SET_OFFERING_PRIORITY_TOOL, { @@ -439,7 +437,7 @@ describe("set_offering_priority", () => { describe("disable_offering", () => { test("restricts an offering this workbench already owns", async () => { globalThis.fetch = routedFetch({ - "/catalog/offerings/offering_1": ok({ ...OFFERING_ROW, disabled: true }), + "/offerings/offering_1": ok({ ...OFFERING_ROW, disabled: true }), }); const result = await catalogOfferingTools(offeringEnv()).run( callFor(DISABLE_OFFERING_TOOL, { offeringId: "offering_1" }), @@ -451,7 +449,7 @@ describe("disable_offering", () => { test("a 403 from the hub surfaces as a refusal", async () => { globalThis.fetch = routedFetch({ - "/catalog/offerings/offering_1": () => forbidden(), + "/offerings/offering_1": () => forbidden(), }); const result = await catalogOfferingTools(offeringEnv()).run( callFor(DISABLE_OFFERING_TOOL, { offeringId: "offering_1" }), diff --git a/packages/catalog-tools/src/tool.ts b/packages/catalog-tools/src/tool.ts index d29b3863f..4b5a38f96 100644 --- a/packages/catalog-tools/src/tool.ts +++ b/packages/catalog-tools/src/tool.ts @@ -112,13 +112,13 @@ function clientConfig(env: WorkflowCatalogEnv): CatalogToolClientConfig { /** Env `create_offering`/`set_offering_priority`/`disable_offering` need * beyond `BaseEnv`: the same hub-reach credential as - * {@link WorkflowCatalogEnv}, plus the tenant those tenant-admin catalog - * routes are scoped to. */ + * {@link WorkflowCatalogEnv}. The hub resolves the run's own tenant from + * that same credential, so no tenant id rides in the env or the + * request. */ export interface WorkflowCatalogOfferingEnv extends BaseEnv { readonly hubCatalogUrl: string; readonly sidecarToken: string; readonly address: string; - readonly tenantId: string; } function adminClientConfig( @@ -128,7 +128,6 @@ function adminClientConfig( hubCatalogUrl: env.hubCatalogUrl, sidecarToken: env.sidecarToken, address: env.address, - tenantId: env.tenantId, }; } @@ -479,7 +478,7 @@ export const catalogTools = defineTool({ */ export const catalogOfferingTools = defineTool({ id: "@corbits/catalog-tools/offerings", - requires: ["hubCatalogUrl", "sidecarToken", "address", "tenantId"], + requires: ["hubCatalogUrl", "sidecarToken", "address"], definitions: [ { name: CREATE_OFFERING_TOOL, approval: "ask" }, { name: SET_OFFERING_PRIORITY_TOOL }, From 722a8b70c219733d125a8fc53e9e00535dc12447 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 08:44:27 -0700 Subject: [PATCH 14/24] Bump the @corbits/catalog-tools tool-package pin to 0.0.4 The pin was still at 0.0.2 after the package's version moved to 0.0.3, and this change moves it again to 0.0.4 for the new workflow-run-authenticated routes. --- workflows/assistant/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workflows/assistant/src/index.ts b/workflows/assistant/src/index.ts index 088f5eb34..bca576018 100644 --- a/workflows/assistant/src/index.ts +++ b/workflows/assistant/src/index.ts @@ -47,7 +47,7 @@ export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ { name: "@corbits/capability-tools", version: "0.0.5" }, { name: "@corbits/agent-directory-tools", version: "0.0.6" }, { name: "@corbits/connections-tools", version: "0.0.8" }, - { name: "@corbits/catalog-tools", version: "0.0.2" }, + { name: "@corbits/catalog-tools", version: "0.0.4" }, { name: "@corbits/skills-tools", version: "0.0.6" }, { name: "@corbits/mcp-tools", version: "0.0.11" }, { name: "@corbits/interaction-tools", version: "0.0.7" }, From 4d5b8c6e3af30b57f90200fad5d2988d978c49ad Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 08:44:40 -0700 Subject: [PATCH 15/24] Update docs: catalog-tools offering routes now run authenticated The offering-management tools' README still described them as writing through the tenant-admin catalog routes directly; point it at the package's own workflow-run-authenticated mirror instead. --- packages/catalog-tools/README.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/catalog-tools/README.md b/packages/catalog-tools/README.md index 0f7382ddd..da252d310 100644 --- a/packages/catalog-tools/README.md +++ b/packages/catalog-tools/README.md @@ -24,9 +24,15 @@ tools read only, so none is gated behind approval. ## Managing offerings Three more tools let an agent write to the tenant's own inference catalog, -through the tenant-admin catalog routes -(`vendor/intx/hub-api/src/routes/{models,model-providers,model-offerings}.ts`) -rather than the read-only concept surface above. +through this package's own workflow-run-authenticated mirror of the +tenant-admin catalog routes (`./src/routes.ts`'s +`createWorkflowCatalogAdminRoutes`, mounted in `apps/hub` at +`/api/workflow-catalog-admin`) rather than the read-only concept surface +above. The tenant-admin routes themselves +(`vendor/intx/hub-api/src/routes/{models,model-providers, +model-offerings}.ts`) only accept a browser session, which a workflow run +never has — only its sidecar bearer token and run address, the same +credential the read-only surface above already uses. - **`create_offering`** — pairs a model (by canonical name) with a model provider (by name), both already present in this workbench's own catalog, From 401d727d13b16cef098afc32235c795e556293fc Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 08:46:22 -0700 Subject: [PATCH 16/24] Regenerate bun.lock with the pinned bun version's lockfile format The merge from main picked up a v2-format lockfile from a newer local bun than the .bun-version this repo pins (1.3.14) can read, so CI's frozen-lockfile install failed before any check ran. Regenerating with 1.3.14 restores the v1 format CI expects. --- bun.lock | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/bun.lock b/bun.lock index d9600b08a..f5614e3d9 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,5 @@ { - "lockfileVersion": 2, + "lockfileVersion": 1, "configVersion": 1, "workspaces": { "": { @@ -3601,12 +3601,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=="], @@ -3629,10 +3625,6 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.8", "", {}, "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q=="], - "@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=="], - "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=="], @@ -3687,8 +3679,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=="], @@ -3735,8 +3725,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=="], "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], From 987d1623eb7166126aa8a36fdf8da0c373ca138c Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 08:50:54 -0700 Subject: [PATCH 17/24] Move the workflow-access ceiling suite to test/, use dbGate check:structural's db-gate check flagged the hand-rolled describeIfDb; switched to the shared dbGate helper. That import reaches scripts/e2e/db-gate.ts, which the tsconfig-references generator treats as disqualifying for a package's composite src/ project when the import comes from src/ itself -- moving the DB-gated composition suite to test/ (where multi-module suites already live per this repo's convention) keeps access-tools in the composite graph. --- packages/access-tools/{src => test}/routes.test.ts | 7 ++++--- packages/access-tools/tsconfig.json | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) rename packages/access-tools/{src => test}/routes.test.ts (97%) diff --git a/packages/access-tools/src/routes.test.ts b/packages/access-tools/test/routes.test.ts similarity index 97% rename from packages/access-tools/src/routes.test.ts rename to packages/access-tools/test/routes.test.ts index ff8a8de42..5abe5d08b 100644 --- a/packages/access-tools/src/routes.test.ts +++ b/packages/access-tools/test/routes.test.ts @@ -9,17 +9,18 @@ // `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. -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +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 "./routes"; +} from "../src/routes"; function dbConfigFromUrl(databaseUrl: string) { const url = new URL(databaseUrl); @@ -33,7 +34,7 @@ function dbConfigFromUrl(databaseUrl: string) { } const databaseUrl = process.env["DATABASE_URL"]; -const describeIfDb = databaseUrl === undefined ? describe.skip : describe; +const describeIfDb = dbGate(databaseUrl, import.meta.path); describeIfDb("createWorkflowAccessRoutes", () => { let db: DB; diff --git a/packages/access-tools/tsconfig.json b/packages/access-tools/tsconfig.json index edcebf5a1..25dfb52b8 100644 --- a/packages/access-tools/tsconfig.json +++ b/packages/access-tools/tsconfig.json @@ -10,7 +10,7 @@ "tsBuildInfoFile": "tsconfig.tsbuildinfo", "rootDir": "../.." }, - "include": ["src"], + "include": ["src", "test"], "exclude": [], "references": [ { From 8f2e6cf2a0f5d3ca684810f2f62f54a7fe3b9156 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 08:53:05 -0700 Subject: [PATCH 18/24] Fix forbidden non-null assertions in the ceiling test suite --- packages/access-tools/test/routes.test.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/access-tools/test/routes.test.ts b/packages/access-tools/test/routes.test.ts index 5abe5d08b..ee6a8e31c 100644 --- a/packages/access-tools/test/routes.test.ts +++ b/packages/access-tools/test/routes.test.ts @@ -222,15 +222,17 @@ describeIfDb("createWorkflowAccessRoutes", () => { grants: { id: string; resource: string; action: string }[]; }; expect(body.grants).toHaveLength(1); - expect(body.grants[0]?.resource).toBe("room:*"); + const createdGrant = body.grants[0]; + if (createdGrant === undefined) throw new Error("expected a created grant"); + expect(createdGrant.resource).toBe("room:*"); await db.db .delete(schema.grant) - .where(eq(schema.grant.id, body.grants[0]!.id)); + .where(eq(schema.grant.id, createdGrant.id)); }); test("revoke: 403 outside the caller's own ceiling", async () => { - const [ungrantableRow] = await db.db + const inserted = await db.db .insert(schema.grant) .values({ id: generateId("grant"), @@ -247,6 +249,9 @@ describeIfDb("createWorkflowAccessRoutes", () => { updatedAt: new Date(), }) .returning(); + const ungrantableRow = inserted[0]; + if (ungrantableRow === undefined) + throw new Error("expected an inserted grant"); const grantStore = createInMemoryGrantStore([ { @@ -263,18 +268,18 @@ describeIfDb("createWorkflowAccessRoutes", () => { ]); const app = mountedApp(grantStore); const res = await app.request( - request(`/grants/${ungrantableRow!.id}`, { method: "DELETE" }), + 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), + where: eq(schema.grant.id, ungrantableRow.id), }); expect(stillThere).toBeDefined(); await db.db .delete(schema.grant) - .where(eq(schema.grant.id, ungrantableRow!.id)); + .where(eq(schema.grant.id, ungrantableRow.id)); }); test("404 for an unknown grant id", async () => { From 8ebb5d033b48379bdb27e17b70b6891c004334d9 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 08:53:14 -0700 Subject: [PATCH 19/24] Fix Prettier formatting in catalog-tools --- packages/catalog-tools/src/client.ts | 5 +- packages/catalog-tools/src/routes.test.ts | 11 ++-- packages/catalog-tools/src/routes.ts | 74 ++++++++++++----------- packages/catalog-tools/src/tool.test.ts | 8 +-- packages/catalog-tools/src/tool.ts | 3 +- 5 files changed, 54 insertions(+), 47 deletions(-) diff --git a/packages/catalog-tools/src/client.ts b/packages/catalog-tools/src/client.ts index c6130e02f..9628112ff 100644 --- a/packages/catalog-tools/src/client.ts +++ b/packages/catalog-tools/src/client.ts @@ -331,7 +331,10 @@ export async function createOffering( "/offerings", "Creating the offering", (raw) => ModelOfferingResponse(raw), - { method: "POST", body: JSON.stringify(CreateModelOffering.assert(request)) }, + { + method: "POST", + body: JSON.stringify(CreateModelOffering.assert(request)), + }, ); } diff --git a/packages/catalog-tools/src/routes.test.ts b/packages/catalog-tools/src/routes.test.ts index fbc95b77e..74154d10d 100644 --- a/packages/catalog-tools/src/routes.test.ts +++ b/packages/catalog-tools/src/routes.test.ts @@ -30,15 +30,14 @@ const ACTIVE_PRINCIPAL = { function fakeDb(overrides?: { tenant?: typeof ACTIVE_TENANT | undefined; - principal?: (Omit & { status: string }) | undefined; + principal?: + (Omit & { status: string }) | undefined; }) { return { query: { tenant: { findFirst: async () => - overrides && "tenant" in overrides - ? overrides.tenant - : ACTIVE_TENANT, + overrides && "tenant" in overrides ? overrides.tenant : ACTIVE_TENANT, }, principal: { findFirst: async () => @@ -86,7 +85,9 @@ function resolvingAuthenticator(): WorkflowRunAuthenticator { }; } -function requestModels(app: ReturnType) { +function requestModels( + app: ReturnType, +) { return app.request("/models", { headers: { authorization: "Bearer sc-token", diff --git a/packages/catalog-tools/src/routes.ts b/packages/catalog-tools/src/routes.ts index 62e5852c9..b9ca26e80 100644 --- a/packages/catalog-tools/src/routes.ts +++ b/packages/catalog-tools/src/routes.ts @@ -49,7 +49,10 @@ import { type CredentialCipher, } from "@intx/types"; import { makeErrorEnvelope } from "@corbits/error-sink"; -import { pushSourceUpdatesSubtree, type SidecarRouter } from "@intx/hub-sessions"; +import { + pushSourceUpdatesSubtree, + type SidecarRouter, +} from "@intx/hub-sessions"; export type WorkflowCatalogAdminRunScope = { readonly tenantId: string; @@ -258,44 +261,42 @@ export function createWorkflowCatalogAdminRoutes( return c.json(paginatedResponse(rows.map(formatModel), rows, limit)); }); - app.get( - "/providers", - requireGrant("model-provider:*", "read"), - async (c) => { - const tenantCtx = c.get("tenant"); - const { limit, cursor } = parsePageParams({ - cursor: c.req.query("cursor"), - limit: c.req.query("limit"), - }); - const conditions = [eq(modelProvider.tenantId, tenantCtx.id)]; - if (cursor) { - conditions.push( - or( - lt(modelProvider.createdAt, new Date(cursor.t)), - and( - eq(modelProvider.createdAt, new Date(cursor.t)), - lt(modelProvider.id, cursor.id), - ), - ) ?? eq(modelProvider.id, modelProvider.id), - ); - } - const rows = await deps.db.query.modelProvider.findMany({ - where: and(...conditions), - orderBy: [desc(modelProvider.createdAt), desc(modelProvider.id)], - limit, - }); - return c.json( - paginatedResponse(rows.map(formatModelProvider), rows, limit), + app.get("/providers", requireGrant("model-provider:*", "read"), async (c) => { + const tenantCtx = c.get("tenant"); + const { limit, cursor } = parsePageParams({ + cursor: c.req.query("cursor"), + limit: c.req.query("limit"), + }); + const conditions = [eq(modelProvider.tenantId, tenantCtx.id)]; + if (cursor) { + conditions.push( + or( + lt(modelProvider.createdAt, new Date(cursor.t)), + and( + eq(modelProvider.createdAt, new Date(cursor.t)), + lt(modelProvider.id, cursor.id), + ), + ) ?? eq(modelProvider.id, modelProvider.id), ); - }, - ); + } + const rows = await deps.db.query.modelProvider.findMany({ + where: and(...conditions), + orderBy: [desc(modelProvider.createdAt), desc(modelProvider.id)], + limit, + }); + return c.json( + paginatedResponse(rows.map(formatModelProvider), rows, limit), + ); + }); app.post( "/offerings", requireGrant("model-offering:*", "create"), async (c) => { const tenantCtx = c.get("tenant"); - const body = CreateModelOffering(await c.req.json().catch(() => undefined)); + const body = CreateModelOffering( + await c.req.json().catch(() => undefined), + ); if (body instanceof type.errors) { return c.json( makeErrorEnvelope({ @@ -307,7 +308,10 @@ export function createWorkflowCatalogAdminRoutes( } const modelRow = await deps.db.query.model.findFirst({ - where: and(eq(model.id, body.modelId), eq(model.tenantId, tenantCtx.id)), + where: and( + eq(model.id, body.modelId), + eq(model.tenantId, tenantCtx.id), + ), }); if (modelRow === undefined) { return c.json( @@ -389,7 +393,9 @@ export function createWorkflowCatalogAdminRoutes( async (c) => { const tenantCtx = c.get("tenant"); const offeringId = c.req.param("offeringId"); - const body = UpdateModelOffering(await c.req.json().catch(() => undefined)); + const body = UpdateModelOffering( + await c.req.json().catch(() => undefined), + ); if (body instanceof type.errors) { return c.json( makeErrorEnvelope({ diff --git a/packages/catalog-tools/src/tool.test.ts b/packages/catalog-tools/src/tool.test.ts index ed3eb2894..809650f47 100644 --- a/packages/catalog-tools/src/tool.test.ts +++ b/packages/catalog-tools/src/tool.test.ts @@ -299,9 +299,7 @@ const OFFERING_ROW = { /** Routes a stub fetch by which tenant-admin path it hits, rather than * one fixed body — `create_offering` makes several calls in sequence * (list models, list providers, then create). */ -function routedFetch( - handlers: Record Response>, -): typeof fetch { +function routedFetch(handlers: Record Response>): typeof fetch { return (async (input: string) => { const url = new URL(input); for (const [suffix, handler] of Object.entries(handlers)) { @@ -379,9 +377,7 @@ describe("create_offering", () => { new AbortController().signal, ); expect(result.isError).toBe(true); - expect(String(result.content)).toContain( - 'No model named "does-not-exist"', - ); + expect(String(result.content)).toContain('No model named "does-not-exist"'); }); test("a 403 from the hub surfaces as a refusal, not a thrown crash", async () => { diff --git a/packages/catalog-tools/src/tool.ts b/packages/catalog-tools/src/tool.ts index 4b5a38f96..52cfee6bb 100644 --- a/packages/catalog-tools/src/tool.ts +++ b/packages/catalog-tools/src/tool.ts @@ -535,7 +535,8 @@ export const catalogOfferingTools = defineTool({ properties: { offeringId: { type: "string", - description: "The offering's id, from create_offering or " + + description: + "The offering's id, from create_offering or " + "the workbench's own catalog listing.", }, priority: { From 3d8dd14779cd6bd885c2e12b61e72ead3e9fe8c8 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 09:07:23 -0700 Subject: [PATCH 20/24] Add teammate-creation eval and pin/prompt tests for Myra's coordinator clause (CL-7469) Covers the "watch our Exa topics and post a digest" case in the same interview -> specialist -> routine shape as the existing eval cases, and updates the assistant definition tests for the new access-tools pin and the revised teammate-clause wording ahead of the prompt change itself. --- packages/evals/src/cases/exa-topic-digest.ts | 71 ++++++++++++++++++++ packages/evals/src/cases/index.ts | 3 + workflows/assistant/test/definition.test.ts | 10 ++- 3 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 packages/evals/src/cases/exa-topic-digest.ts diff --git a/packages/evals/src/cases/exa-topic-digest.ts b/packages/evals/src/cases/exa-topic-digest.ts new file mode 100644 index 000000000..3009b2977 --- /dev/null +++ b/packages/evals/src/cases/exa-topic-digest.ts @@ -0,0 +1,71 @@ +// CL-7469: "we need someone to watch our Exa topics and post a digest to +// this bench" — Myra coordinates rather than declining or answering +// inline: the same interview -> tool-map -> memory -> specialist -> +// routine flow as ai-daily-research, with the plan itself naming the +// teammate (agent/workflow) it will create. +import { defineEval } from "../define-eval.ts"; +import { + agentCreatedInWorkbench, + approvalGated, + asksQuestions, + judge, + memoryWritten, + namesRequiredTools, + noBuildBeforeAnswers, + noToolCalls, + routineCreated, + routineCreatedOnlyAfterOk, +} from "../scorers/scorers.ts"; +import { + CREATE_AGENT_TOOL, + ROUTINE_CREATE_TOOL, +} from "../scorers/tool-names.ts"; + +export const exaTopicDigestEval = defineEval({ + name: "exa-topic-digest", + description: + "'we need someone to watch our Exa topics and post a digest to this " + + "bench' -> interview (topics/cadence) -> specialist teammate that " + + "watches Exa -> routine posting the digest here", + steps: [ + { + human: + "we need someone to watch our Exa topics and post a digest to " + + "this bench", + expect: [ + asksQuestions({ max: 4 }), + noToolCalls(["create_agent", "routine_create", "routine_run_now"]), + noBuildBeforeAnswers(1), + judge( + "The reply proposes creating a teammate (an agent or workflow) " + + "to own this job rather than declining the request or " + + "trying to answer it inline as a one-off.", + ), + ], + }, + { + human: + "topics: competitor launches and pricing changes; cadence: " + + "daily every morning; post the digest here in this chat", + expect: [ + noBuildBeforeAnswers(1), + memoryWritten(["daily"]), + namesRequiredTools([CREATE_AGENT_TOOL]), + agentCreatedInWorkbench(), + ], + }, + { + human: "yes, set up the daily digest routine", + expect: [ + routineCreatedOnlyAfterOk(2), + approvalGated([ROUTINE_CREATE_TOOL]), + namesRequiredTools([ROUTINE_CREATE_TOOL]), + routineCreated({ trigger: "daily" }), + judge( + "The reply confirms the digest routine is set up in a warm, " + + "direct teammate tone — not a wizard checklist, not pushy.", + ), + ], + }, + ], +}); diff --git a/packages/evals/src/cases/index.ts b/packages/evals/src/cases/index.ts index 8c6861db2..71ee85ae8 100644 --- a/packages/evals/src/cases/index.ts +++ b/packages/evals/src/cases/index.ts @@ -1,14 +1,17 @@ import { aiDailyResearchEval } from "./ai-daily-research.ts"; import { docsOnSdkChangeEval } from "./docs-on-sdk-change.ts"; +import { exaTopicDigestEval } from "./exa-topic-digest.ts"; import { githubPrReviewFactoryEval } from "./github-pr-review-factory.ts"; import type { EvalDefinition } from "../types.ts"; export { aiDailyResearchEval } from "./ai-daily-research.ts"; export { docsOnSdkChangeEval } from "./docs-on-sdk-change.ts"; +export { exaTopicDigestEval } from "./exa-topic-digest.ts"; export { githubPrReviewFactoryEval } from "./github-pr-review-factory.ts"; export const ALL_EVALS: readonly EvalDefinition[] = [ aiDailyResearchEval, docsOnSdkChangeEval, + exaTopicDigestEval, githubPrReviewFactoryEval, ]; diff --git a/workflows/assistant/test/definition.test.ts b/workflows/assistant/test/definition.test.ts index dfd309ae3..605af5d28 100644 --- a/workflows/assistant/test/definition.test.ts +++ b/workflows/assistant/test/definition.test.ts @@ -126,6 +126,7 @@ test("the agent pins memory, capability, and the manager-tools bundles at the ve "@corbits/interaction-tools", "@corbits/manus-tools", "@corbits/workflow-authoring-tools", + "@corbits/access-tools", ]); // A pin the registry cannot resolve fails every assistant deploy, so // each one must name a version the workspace actually publishes. @@ -243,9 +244,12 @@ test("the prompt asks only for facts Myra can't infer, never permission to use t test("the prompt builds the whole team on the person's OK: agents, routines, and memory in one go", () => { expect(ASSISTANT_SYSTEM_PROMPT).toContain( - "On their OK, build the whole thing in one go: create the " + - "specialists (each gets their own chat), create the routines, " + - "and save the facts they gave you to memory", + "On their OK, build the whole thing in one go: for each " + + "specialist, check list_agents first and reuse one that already " + + "fits; otherwise author it, deploy it, grant it only the access " + + "its job needs, and add it to the bench or a DM (each gets its " + + "own chat) — then create the routines and save the facts they " + + "gave you to memory", ); expect(ASSISTANT_SYSTEM_PROMPT).not.toContain("invite them in"); expect(ASSISTANT_SYSTEM_PROMPT).not.toContain("create_channel"); From a1d564dbd461b4d063583d7e5cb9fa59993950d5 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 09:07:28 -0700 Subject: [PATCH 21/24] Assistant: Myra coordinates the bench's agents (CL-7469) Adds a coordinator clause to the system prompt and revises the teammate clause so building a specialist always goes through list_agents (reuse before creating), author, deploy, grant, and add-to-bench in order rather than skipping straight to the result. Pins @corbits/access-tools so Myra can grant a new teammate only the access its job needs, with no @intx/tools-posix. --- workflows/assistant/src/index.ts | 42 ++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/workflows/assistant/src/index.ts b/workflows/assistant/src/index.ts index bca576018..1d83bedff 100644 --- a/workflows/assistant/src/index.ts +++ b/workflows/assistant/src/index.ts @@ -40,7 +40,11 @@ export const ASSISTANT_STEP_ID = "assistant"; * expose tenant-connected MCP servers and the ask-user card; * `@corbits/manus-tools` is pinned so Manus tools exist when the tenant * has connected Manus (launch folds the binding only then — the pin - * itself does not require a credential). + * itself does not require a credential). `@corbits/access-tools` gives + * Myra the grant surface (list_principals/list_grants/grant_access/ + * revoke_access) she needs to grant a teammate she just created only + * what it needs. No `@intx/tools-posix`: Myra never gets raw filesystem + * access. */ export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ { name: "@corbits/memory-tools", version: "0.0.4" }, @@ -53,6 +57,7 @@ export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ { name: "@corbits/interaction-tools", version: "0.0.7" }, { name: "@corbits/manus-tools", version: "0.0.11" }, { name: "@corbits/workflow-authoring-tools", version: "0.0.4" }, + { name: "@corbits/access-tools", version: "0.0.1" }, ]; /** @@ -136,10 +141,33 @@ const ASSISTANT_TEAMMATE_CLAUSE = "URL, a cadence, whichever specifics the plan actually turns on — " + "never 'should I create an agent for that?' or any other question " + "that just asks permission to use the mechanism. On their OK, build " + - "the whole thing in one go: create the specialists (each gets their " + - "own chat), create the routines, and save the facts they gave you to " + - "memory — every write already asks for its own approval, so build " + - "once you have what you need rather than checking in again first."; + "the whole thing in one go: for each specialist, check list_agents " + + "first and reuse one that already fits; otherwise author it, deploy " + + "it, grant it only the access its job needs, and add it to the " + + "bench or a DM (each gets its own chat) — then create the routines " + + "and save the facts they gave you to memory. Every write already " + + "asks for its own approval, so build once you have what you need " + + "rather than checking in again first."; + +/** + * COORDINATOR (CL-7469): Myra never builds a teammate by hand-editing + * files or answering in its place — she authors it as code and puts it + * through the same deploy and grant machinery any other change to the + * bench goes through, and she never stands up a second instance of an + * agent that already exists. + */ +const ASSISTANT_COORDINATOR_CLAUSE = + "You are the coordinator of this bench's agents, not just its " + + "resident assistant: before creating anything, call list_agents and " + + "reuse an existing teammate that already fits rather than standing " + + "up a second one. When the bench genuinely lacks a capability, build " + + "the teammate as code — an agent, workflow, or skill authored with " + + "workflow_author or create_skill — deploy it with workflow_deploy, " + + "grant it, with grant_access, only the access it needs to do the " + + "job, and add it to the bench or a DM so the person can reach it; " + + "each of those steps asks for its own approval, so walk through them " + + "in order rather than skipping to the result. Report back what you " + + "built and what it can now do once it's live."; /** * DISCOVERY (CL-6179): the specific interview-then-propose procedure a @@ -210,6 +238,10 @@ export const ASSISTANT_SYSTEM_PROMPT = ASSISTANT_TEAMMATE_CLAUSE + "\n" + "\n" + + "## Coordinating the bench\n" + + ASSISTANT_COORDINATOR_CLAUSE + + "\n" + + "\n" + "## Tools\n" + "Each tool's own description says how it works; what spans them: " + "invoke tools only through tool calls, never by writing a JSON object " + From b354d4a7aff00a24b86c57ca8476a0d49c347329 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 09:07:32 -0700 Subject: [PATCH 22/24] Update docs: Myra as the default orchestrator PRODUCT.md and the glossary now describe Myra as every tenant's default agent and the coordinator of its other agents, not just the first-run guide. --- PRODUCT.md | 10 ++++++++++ docs/GLOSSARY.md | 1 + 2 files changed, 11 insertions(+) diff --git a/PRODUCT.md b/PRODUCT.md index f44d629db..a9df1e3c8 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -100,6 +100,16 @@ on that rail only after honest usage exists. New benches should not meet an empty Insights / Evals gallery before they have anything to put there. +Myra is every tenant's default agent and the coordinator of its other +agents, not merely the first-run guide. When a request needs a +capability the bench doesn't have, she doesn't decline it or improvise +an inline answer: she proposes a teammate — an agent, workflow, or +skill — authors it as code, deploys it through the normal deploy flow, +grants it only the access its job requires, and adds it to the bench +or a DM, reporting back once it's live. She checks for an existing +teammate that already fits before creating one, so a bench never ends +up with two agents doing the same job. + ### Code review's first minute Code review is the product scene for the definition-driven path: diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index d6191046d..7fc2fd712 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -14,6 +14,7 @@ lower-level name left to list. | **Bench** | tenant | A shared space where a team and its agents work — members, definitions, runs, and grants live here | | **User** | principal | An identity that can act in a bench — human or agent | | **Agent** | principal (agent) | A named coworker principal, not a template; opening the row reopens that agent's one DM. The sidebar mixes that DM with channels in one recency list (pins first) | +| **Myra** | — | The `assistant` workflow: every bench's default agent and the coordinator of its other agents. When asked for a capability the bench lacks, she checks for an existing teammate that already fits, then — reusing rather than duplicating — authors a new agent, workflow, or skill as code, deploys it, grants it only the access it needs, and adds it to the bench or a DM | | **DM** | kind: chat | The one 1:1 tenant with that agent — two opens never clone a second DM | | **Channel** | kind: workbench | A shared room between people and agents (multi-principal tenant). `+` mints an empty one with nobody hosted; named templates mint the same empty channel, then instantiate their Workbench Definition and run its onboarding walkthrough in the room | | **Definition** | workflow definition | A deployable unit of agent behavior, authored as code — not a template you mint into many conversations | From 3fe38a00813d140590557251167dce857dfcc2a1 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 09:40:03 -0700 Subject: [PATCH 23/24] docs: reflow GLOSSARY.md table for prettier --- docs/GLOSSARY.md | 50 ++++++++++++++++++++++++------------------------ 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 7fc2fd712..9cf803bf5 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -9,31 +9,31 @@ Interchange's, so its code identifiers, wire fields, and route segments were cut over to match the product word directly, with no separate lower-level name left to list. -| Product term | Platform term | What it is | -| ------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Bench** | tenant | A shared space where a team and its agents work — members, definitions, runs, and grants live here | -| **User** | principal | An identity that can act in a bench — human or agent | -| **Agent** | principal (agent) | A named coworker principal, not a template; opening the row reopens that agent's one DM. The sidebar mixes that DM with channels in one recency list (pins first) | -| **Myra** | — | The `assistant` workflow: every bench's default agent and the coordinator of its other agents. When asked for a capability the bench lacks, she checks for an existing teammate that already fits, then — reusing rather than duplicating — authors a new agent, workflow, or skill as code, deploys it, grants it only the access it needs, and adds it to the bench or a DM | -| **DM** | kind: chat | The one 1:1 tenant with that agent — two opens never clone a second DM | -| **Channel** | kind: workbench | A shared room between people and agents (multi-principal tenant). `+` mints an empty one with nobody hosted; named templates mint the same empty channel, then instantiate their Workbench Definition and run its onboarding walkthrough in the room | -| **Definition** | workflow definition | A deployable unit of agent behavior, authored as code — not a template you mint into many conversations | -| **Workbench Definition** | — | The arktype description a named template instantiates: its default agents, routines, tools, required/optional plugins, and its ordered onboarding walkthrough — see `templates/index.ts`'s `WorkbenchDefinitionSchema` | -| **Run** | workflow run | A definition executing in a bench; interactive runs carry conversations | -| **Routine** | — | Product name for a scheduled workflow: an authored definition whose frozen projection carries a native `ScheduleTrigger`. Cadence, pause/resume, and run-now live on `@corbits/workflows`; the hub poller ticks matching minutes. See [workflow-model.md](workflow-model.md) | -| **Approval** | approval | A human decision gating an external side effect | -| **Grant** | grant | Permission for a principal to act on a resource | -| **Hub** | hub | The API and coordination service a bench lives on | -| **Sidecar** | sidecar | The execution host that runs definitions on behalf of a hub | -| **Extension** | — | A route factory mounted on the hub to add product surface | -| **Workbenches** | — | The product name and the mint verb ("New workbench"), not a sidebar heading. Conversation tenants (agent DMs and channels) share one recency list, pins first — not two labeled sections | -| **Workbench** | — | The one conversation surface: an agent conversation (named by its agent) or a multi-party conversation (named by its own title) — durable hub-side data with no run of its own; also its own tenant, parented under the bench it was created in, so its membership and grants are its own — see [CHAT.md](CHAT.md) and [workbench-tenancy.md](workbench-tenancy.md) | -| **Timeline** | — | A workbench's own message rows, read back in order, as the conversation record | -| **Participant** | — | An address (human or agent) a workbench's settings list as able to post or be mentioned | -| **Handle** | — | A participant's short, unique-within-workbench mention name (e.g. `echo`), distinct from its address | -| **Mention** | — | `@` plus a participant's handle in message text, triggering fan-out to that participant | -| **Reply bridge** | — | The bridge that turns an invited agent's `connector.reply` events into workbench timeline messages | -| **Concept** | — | A kind of work an agent asks for a model by — `cheap-loop`, `code-work`, `image-reader` — resolved against the bench into an ordered, priced chain of the models it can actually reach; agents never name a model, see [inference-concepts.md](inference-concepts.md) | +| Product term | Platform term | What it is | +| ------------------------ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Bench** | tenant | A shared space where a team and its agents work — members, definitions, runs, and grants live here | +| **User** | principal | An identity that can act in a bench — human or agent | +| **Agent** | principal (agent) | A named coworker principal, not a template; opening the row reopens that agent's one DM. The sidebar mixes that DM with channels in one recency list (pins first) | +| **Myra** | — | The `assistant` workflow: every bench's default agent and the coordinator of its other agents. When asked for a capability the bench lacks, she checks for an existing teammate that already fits, then — reusing rather than duplicating — authors a new agent, workflow, or skill as code, deploys it, grants it only the access it needs, and adds it to the bench or a DM | +| **DM** | kind: chat | The one 1:1 tenant with that agent — two opens never clone a second DM | +| **Channel** | kind: workbench | A shared room between people and agents (multi-principal tenant). `+` mints an empty one with nobody hosted; named templates mint the same empty channel, then instantiate their Workbench Definition and run its onboarding walkthrough in the room | +| **Definition** | workflow definition | A deployable unit of agent behavior, authored as code — not a template you mint into many conversations | +| **Workbench Definition** | — | The arktype description a named template instantiates: its default agents, routines, tools, required/optional plugins, and its ordered onboarding walkthrough — see `templates/index.ts`'s `WorkbenchDefinitionSchema` | +| **Run** | workflow run | A definition executing in a bench; interactive runs carry conversations | +| **Routine** | — | Product name for a scheduled workflow: an authored definition whose frozen projection carries a native `ScheduleTrigger`. Cadence, pause/resume, and run-now live on `@corbits/workflows`; the hub poller ticks matching minutes. See [workflow-model.md](workflow-model.md) | +| **Approval** | approval | A human decision gating an external side effect | +| **Grant** | grant | Permission for a principal to act on a resource | +| **Hub** | hub | The API and coordination service a bench lives on | +| **Sidecar** | sidecar | The execution host that runs definitions on behalf of a hub | +| **Extension** | — | A route factory mounted on the hub to add product surface | +| **Workbenches** | — | The product name and the mint verb ("New workbench"), not a sidebar heading. Conversation tenants (agent DMs and channels) share one recency list, pins first — not two labeled sections | +| **Workbench** | — | The one conversation surface: an agent conversation (named by its agent) or a multi-party conversation (named by its own title) — durable hub-side data with no run of its own; also its own tenant, parented under the bench it was created in, so its membership and grants are its own — see [CHAT.md](CHAT.md) and [workbench-tenancy.md](workbench-tenancy.md) | +| **Timeline** | — | A workbench's own message rows, read back in order, as the conversation record | +| **Participant** | — | An address (human or agent) a workbench's settings list as able to post or be mentioned | +| **Handle** | — | A participant's short, unique-within-workbench mention name (e.g. `echo`), distinct from its address | +| **Mention** | — | `@` plus a participant's handle in message text, triggering fan-out to that participant | +| **Reply bridge** | — | The bridge that turns an invited agent's `connector.reply` events into workbench timeline messages | +| **Concept** | — | A kind of work an agent asks for a model by — `cheap-loop`, `code-work`, `image-reader` — resolved against the bench into an ordered, priced chain of the models it can actually reach; agents never name a model, see [inference-concepts.md](inference-concepts.md) | A bench and a workbench are both tenants underneath, which can read as the same thing twice. They are not: a bench is the scope a team From 38454a127f512b9052956be915d1363ada758a96 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 09:47:07 -0700 Subject: [PATCH 24/24] Assistant: fold the coordinator clause into the teammate clause The coordinator clause (CL-7469) repeated the teammate clause's build sequence almost verbatim. Fold the two into one clause so the build sequence lives in a single place, keeping only what the coordinator text added: Myra coordinates the bench's agents, not just serves as its assistant, and reports back what she built once it's live. Also trims prompt tests down to behavioral assertions: prompt-sentence string matches and the tool-package pin list are dropped, since the former churns on prose edits and the latter is already enforced by the tool-package-pins structural check. --- workflows/assistant/src/index.ts | 78 ++++------ workflows/assistant/test/definition.test.ts | 164 +------------------- 2 files changed, 32 insertions(+), 210 deletions(-) diff --git a/workflows/assistant/src/index.ts b/workflows/assistant/src/index.ts index 1d83bedff..72b64cc61 100644 --- a/workflows/assistant/src/index.ts +++ b/workflows/assistant/src/index.ts @@ -107,8 +107,12 @@ const ASSISTANT_TRIAGE_CLAUSE = "unless you are @-mentioned too or the sender asks you directly."; /** - * TEAMMATE: how Myra offers help without pushing it — folds in the - * skills-capture nudge rather than a separate always-on clause. + * TEAMMATE (CL-7469 folds in COORDINATOR): how Myra offers help without + * pushing it — folds in the skills-capture nudge rather than a separate + * always-on clause. Myra is the coordinator of this bench's agents, not + * just its resident assistant: she never builds a teammate by + * hand-editing files or answering in its place, and never stands up a + * second instance of an agent that already exists. * * The build-arc sentence (CL-5879) is the load-bearing one: it never * waits for someone to name the mechanism ("make an agent", "set up a @@ -128,45 +132,28 @@ const ASSISTANT_TEAMMATE_CLAUSE = "person decide; if they pass, drop it. Don't narrate a checklist or " + "push setup on someone who came to talk. Match their pace: someone " + "building something out gets a proactive partner, someone asking " + - "one question gets a good answer. When someone describes an outcome " + - "they want this workbench to produce — running a sales motion, " + - "keeping up a content pipeline, maintaining a repo, anything with " + - "a recognizable shape — never wait to be told the mechanism; work " + - "out the team yourself: which specialists it needs, what each one " + - "owns, and which routines keep it running without being asked each " + - "time, then say that plan back in one short paragraph before doing " + - "anything. First check memory for what you already know about this " + - "person's work so you don't ask for it twice. Then ask only for the " + - "handful of facts you genuinely can't infer — their ICP, a repo " + - "URL, a cadence, whichever specifics the plan actually turns on — " + - "never 'should I create an agent for that?' or any other question " + - "that just asks permission to use the mechanism. On their OK, build " + - "the whole thing in one go: for each specialist, check list_agents " + - "first and reuse one that already fits; otherwise author it, deploy " + - "it, grant it only the access its job needs, and add it to the " + - "bench or a DM (each gets its own chat) — then create the routines " + - "and save the facts they gave you to memory. Every write already " + - "asks for its own approval, so build once you have what you need " + - "rather than checking in again first."; - -/** - * COORDINATOR (CL-7469): Myra never builds a teammate by hand-editing - * files or answering in its place — she authors it as code and puts it - * through the same deploy and grant machinery any other change to the - * bench goes through, and she never stands up a second instance of an - * agent that already exists. - */ -const ASSISTANT_COORDINATOR_CLAUSE = - "You are the coordinator of this bench's agents, not just its " + - "resident assistant: before creating anything, call list_agents and " + - "reuse an existing teammate that already fits rather than standing " + - "up a second one. When the bench genuinely lacks a capability, build " + - "the teammate as code — an agent, workflow, or skill authored with " + - "workflow_author or create_skill — deploy it with workflow_deploy, " + - "grant it, with grant_access, only the access it needs to do the " + - "job, and add it to the bench or a DM so the person can reach it; " + - "each of those steps asks for its own approval, so walk through them " + - "in order rather than skipping to the result. Report back what you " + + "one question gets a good answer. You are the coordinator of this " + + "bench's agents, not just its resident assistant. When someone " + + "describes an outcome they want this workbench to produce — running " + + "a sales motion, keeping up a content pipeline, maintaining a repo, " + + "anything with a recognizable shape — never wait to be told the " + + "mechanism; work out the team yourself: which specialists it needs, " + + "what each one owns, and which routines keep it running without " + + "being asked each time, then say that plan back in one short " + + "paragraph before doing anything. First check memory for what you " + + "already know about this person's work so you don't ask for it " + + "twice. Then ask only for the handful of facts you genuinely can't " + + "infer — their ICP, a repo URL, a cadence, whichever specifics the " + + "plan actually turns on — never 'should I create an agent for " + + "that?' or any other question that just asks permission to use the " + + "mechanism. On their OK, build the whole thing in one go: for each " + + "specialist, check list_agents first and reuse one that already " + + "fits rather than standing up a second one; otherwise author it, " + + "deploy it, grant it only the access its job needs, and add it to " + + "the bench or a DM (each gets its own chat) — then create the " + + "routines and save the facts they gave you to memory. Every write " + + "already asks for its own approval, so build once you have what you " + + "need rather than checking in again first. Report back what you " + "built and what it can now do once it's live."; /** @@ -197,8 +184,9 @@ const ASSISTANT_DISCOVERY_CLAUSE = * named sections one concern each, cross-tool doctrine only (each * tool's own description says how it works), and runtime facts (who * sent a message) arriving as data on each mail rather than - * baked-in lore. The three behavior clauses above are load-bearing and - * eval-anchored — restructure around them, never reword them casually. + * baked-in lore. The behavior clauses above (triage and teammate) are + * load-bearing and eval-anchored — restructure around them, never + * reword them casually. */ export const ASSISTANT_SYSTEM_PROMPT = "You are Myra, the resident teammate agent inside this team's " + @@ -238,10 +226,6 @@ export const ASSISTANT_SYSTEM_PROMPT = ASSISTANT_TEAMMATE_CLAUSE + "\n" + "\n" + - "## Coordinating the bench\n" + - ASSISTANT_COORDINATOR_CLAUSE + - "\n" + - "\n" + "## Tools\n" + "Each tool's own description says how it works; what spans them: " + "invoke tools only through tool calls, never by writing a JSON object " + diff --git a/workflows/assistant/test/definition.test.ts b/workflows/assistant/test/definition.test.ts index 605af5d28..e148dc5f1 100644 --- a/workflows/assistant/test/definition.test.ts +++ b/workflows/assistant/test/definition.test.ts @@ -72,96 +72,9 @@ test("the agent carries the assistant prompt, the preferences, and inlines no to expect(agent.toolFactories).toEqual([]); }); -test("the prompt tells Myra a canned opener already greeted under her name — answer the first message, never greet again", () => { - expect(ASSISTANT_SYSTEM_PROMPT).toContain( - "posted on the timeline under your name", - ); - expect(ASSISTANT_SYSTEM_PROMPT).toContain( - "Never greet or introduce yourself again", - ); - expect(ASSISTANT_SYSTEM_PROMPT).toContain( - "answer their first message directly", - ); - expect(ASSISTANT_SYSTEM_PROMPT).not.toContain("kickoff brief"); - expect(ASSISTANT_SYSTEM_PROMPT).toContain( - "Never list your capabilities as a menu", - ); - expect(ASSISTANT_SYSTEM_PROMPT).not.toContain("standing job"); -}); - -test("the prompt tells Myra to use ask_user instead of prose lists for enumerable-option interviews", () => { - expect(ASSISTANT_SYSTEM_PROMPT).toContain("ask_user"); - expect(ASSISTANT_SYSTEM_PROMPT).toContain("interactive card"); -}); - -test("the prompt opens with a one-line identity and is organized into named sections", () => { - expect(ASSISTANT_SYSTEM_PROMPT.startsWith("You are Myra")).toBe(true); - expect(ASSISTANT_SYSTEM_PROMPT).toContain("## Where you are"); - expect(ASSISTANT_SYSTEM_PROMPT).toContain("## Deciding what to do"); - expect(ASSISTANT_SYSTEM_PROMPT).toContain("## Tools"); -}); - -test("the prompt grounds Myra inside the workbench — she never asks to be pointed at it", () => { - expect(ASSISTANT_SYSTEM_PROMPT).toContain("already inside the workbench"); - expect(ASSISTANT_SYSTEM_PROMPT).toContain( - "never ask to be pointed at the workbench", - ); -}); - -test("the prompt has Myra load the writing-system-prompts skill before authoring any agent prompt", () => { - expect(ASSISTANT_SYSTEM_PROMPT).toContain("writing-system-prompts"); -}); - -test("the agent pins memory, capability, and the manager-tools bundles at the versions the workspace publishes", async () => { +test("the agent carries the tool package pins as declared", () => { const agent = assistantStep(buildAssistantWorkflow(INPUT)).agent; expect(agent.toolPackagePins).toEqual(ASSISTANT_TOOL_PACKAGE_PINS); - expect(ASSISTANT_TOOL_PACKAGE_PINS.map((pin) => pin.name)).toEqual([ - "@corbits/memory-tools", - "@corbits/capability-tools", - "@corbits/agent-directory-tools", - "@corbits/connections-tools", - "@corbits/catalog-tools", - "@corbits/skills-tools", - "@corbits/mcp-tools", - "@corbits/interaction-tools", - "@corbits/manus-tools", - "@corbits/workflow-authoring-tools", - "@corbits/access-tools", - ]); - // A pin the registry cannot resolve fails every assistant deploy, so - // each one must name a version the workspace actually publishes. - for (const pin of ASSISTANT_TOOL_PACKAGE_PINS) { - const manifestPath = new URL( - `../../../packages/${pin.name.replace("@corbits/", "")}/package.json`, - import.meta.url, - ); - const manifest = (await Bun.file(manifestPath).json()) as { - version: string; - }; - expect({ name: pin.name, version: pin.version }).toEqual({ - name: pin.name, - version: manifest.version, - }); - } -}); - -test("the prompt tells Myra to discover an MCP server's tools before calling one", () => { - expect(ASSISTANT_SYSTEM_PROMPT).toContain("mcp_list_tools"); - expect(ASSISTANT_SYSTEM_PROMPT).toContain("mcp_call"); - expect(ASSISTANT_SYSTEM_PROMPT).toContain("never guess a tool name"); -}); - -test("the prompt never writes tool JSON as reply text and does not memory_search a bare greeting", () => { - expect(ASSISTANT_SYSTEM_PROMPT).toContain("only through tool calls"); - expect(ASSISTANT_SYSTEM_PROMPT).toContain( - "never by writing a JSON object with a tool name into your reply", - ); - expect(ASSISTANT_SYSTEM_PROMPT).toContain( - "never memory_search a bare greeting", - ); - expect(ASSISTANT_SYSTEM_PROMPT).toContain( - "use memory only when you actually need a fact from earlier", - ); }); test("the workflow pins manus-tools and does not require a Manus credential binding", () => { @@ -218,53 +131,6 @@ test("a non-positive or fractional turn timeout is rejected", () => { ).toThrow(/turnTimeoutMs/); }); -// CL-5879: an outcome ("a sales motion", "a content pipeline", "a repo -// to maintain") is enough on its own for Myra to propose a team design -// — she never waits to be told the mechanism ("make an agent"). -test("the prompt tells Myra to propose a team design from an outcome, never wait for the mechanism", () => { - expect(ASSISTANT_SYSTEM_PROMPT).toContain( - "never wait to be told the mechanism", - ); - expect(ASSISTANT_SYSTEM_PROMPT).toContain( - "work out the team yourself: which specialists it needs", - ); - expect(ASSISTANT_SYSTEM_PROMPT).toContain( - "say that plan back in one short paragraph before doing anything", - ); -}); - -test("the prompt asks only for facts Myra can't infer, never permission to use the mechanism", () => { - expect(ASSISTANT_SYSTEM_PROMPT).toContain( - "ask only for the handful of facts you genuinely can't infer", - ); - expect(ASSISTANT_SYSTEM_PROMPT).toContain( - "never 'should I create an agent for that?'", - ); -}); - -test("the prompt builds the whole team on the person's OK: agents, routines, and memory in one go", () => { - expect(ASSISTANT_SYSTEM_PROMPT).toContain( - "On their OK, build the whole thing in one go: for each " + - "specialist, check list_agents first and reuse one that already " + - "fits; otherwise author it, deploy it, grant it only the access " + - "its job needs, and add it to the bench or a DM (each gets its " + - "own chat) — then create the routines and save the facts they " + - "gave you to memory", - ); - expect(ASSISTANT_SYSTEM_PROMPT).not.toContain("invite them in"); - expect(ASSISTANT_SYSTEM_PROMPT).not.toContain("create_channel"); -}); - -test("the prompt has a delegated specialist finish its thread with a summary back to the host/main", () => { - expect(ASSISTANT_SYSTEM_PROMPT).toContain( - "that @mention opens a thread for the deep-dive", - ); - expect(ASSISTANT_SYSTEM_PROMPT).toContain( - "finish its thread with a one-line summary addressed back to you " + - "and the main conversation", - ); -}); - // CL-6179: on a stated outcome, Myra runs a short, bounded discovery // interview before proposing anything — never the open-ended intake // this clause exists to rule out. @@ -301,31 +167,3 @@ test("the prompt runs discovery inside 'Deciding what to do', in order: intervie expect(skillAt).toBeLessThan(createAgentAt); expect(createAgentAt).toBeLessThan(handoffAt); }); - -test("the prompt's discovery interview uses ask_user for enumerable options, never a long open-ended one", () => { - expect(ASSISTANT_SYSTEM_PROMPT).toContain( - "a tappable card via ask_user when the options are enumerable", - ); - expect(ASSISTANT_SYSTEM_PROMPT).toContain( - "never a long, open-ended interview", - ); -}); - -// CL-6350: Myra answers by default and only stands down when a message -// @-mentions a different teammate and not her — the reverse of her own -// @mention-to-delegate rule. -test("the prompt tells Myra to answer by default and stay out of a thread @-mentioning someone else", () => { - expect(ASSISTANT_SYSTEM_PROMPT).toContain("You answer by default"); - expect(ASSISTANT_SYSTEM_PROMPT).toContain( - "that is their turn — stay out of it", - ); -}); - -test("the prompt hands a built team off with the exact discovery closing line", () => { - expect(ASSISTANT_SYSTEM_PROMPT).toContain( - "Their own chats for focused work. Here when you want me to run " + - "the hunt and hand things off.", - ); - expect(ASSISTANT_SYSTEM_PROMPT).not.toContain("invite them into this"); - expect(ASSISTANT_SYSTEM_PROMPT).not.toContain("invite it into this"); -});