From 8c4a59eadbf60df4e51b9cd7ea05771a21444b9f Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sat, 5 Sep 2026 23:50:16 -0700 Subject: [PATCH 01/12] 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/12] 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/12] 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 86e40412ae26700215f7342ae22b5056ad3f0b34 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 08:44:16 -0700 Subject: [PATCH 04/12] 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 05/12] 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 06/12] 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 07/12] 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 8ebb5d033b48379bdb27e17b70b6891c004334d9 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 08:53:14 -0700 Subject: [PATCH 08/12] 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 67bdbe7edf10047d9c350d9f9986d9f6f155659b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 09:03:36 -0700 Subject: [PATCH 09/12] catalog-tools: shorten offering-bundle id to fit the 64-char tool-name cap --- packages/catalog-tools/src/tool.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/catalog-tools/src/tool.ts b/packages/catalog-tools/src/tool.ts index 52cfee6bb..4226f227e 100644 --- a/packages/catalog-tools/src/tool.ts +++ b/packages/catalog-tools/src/tool.ts @@ -477,7 +477,7 @@ export const catalogTools = defineTool({ * `pick_models`' own `order` param is ungated. */ export const catalogOfferingTools = defineTool({ - id: "@corbits/catalog-tools/offerings", + id: "@corbits/catalog-tools/off", requires: ["hubCatalogUrl", "sidecarToken", "address"], definitions: [ { name: CREATE_OFFERING_TOOL, approval: "ask" }, From 090ffe003b483bfe7c97775b1ecae115dcd29407 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 09:03:51 -0700 Subject: [PATCH 10/12] Shorten catalog-tools offerings bundle id to fit the OpenAI tool-name cap --- packages/catalog-tools/src/tool.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/catalog-tools/src/tool.ts b/packages/catalog-tools/src/tool.ts index 52cfee6bb..4226f227e 100644 --- a/packages/catalog-tools/src/tool.ts +++ b/packages/catalog-tools/src/tool.ts @@ -477,7 +477,7 @@ export const catalogTools = defineTool({ * `pick_models`' own `order` param is ungated. */ export const catalogOfferingTools = defineTool({ - id: "@corbits/catalog-tools/offerings", + id: "@corbits/catalog-tools/off", requires: ["hubCatalogUrl", "sidecarToken", "address"], definitions: [ { name: CREATE_OFFERING_TOOL, approval: "ask" }, From 8a6ae2cf366bf906573baa7f66452b3a00b554cc Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 09:27:00 -0700 Subject: [PATCH 11/12] tool-registry-publish: assert catalog-tools' offering bundle packs by id pack.test.ts already re-imports every @corbits tool package's packed tarball and checks it exports at least one tool factory, but a bundle dropped from catalog-tools' index.ts re-exports would still leave that count above zero. Assert both catalogTools and catalogOfferingTools resolve by id from the actual packed tool.mjs the sidecar loads. --- packages/tool-registry-publish/src/pack.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/tool-registry-publish/src/pack.test.ts b/packages/tool-registry-publish/src/pack.test.ts index f0808342f..9328ecf19 100644 --- a/packages/tool-registry-publish/src/pack.test.ts +++ b/packages/tool-registry-publish/src/pack.test.ts @@ -71,6 +71,20 @@ describe("packToolPackageTarball", () => { "id" in (value as object), ); expect(factories.length).toBeGreaterThan(0); + + if (tarball.name === "@corbits/catalog-tools") { + // Both bundles this package exports (the read-only + // `catalogTools` and CL-7468's write-capable + // `catalogOfferingTools`) must resolve by id from the packed + // tarball the sidecar actually loads, not just from the + // workspace source tree — a bundle dropped from `./index.ts`'s + // re-exports would still leave `factories.length` above 0. + const ids = factories.map( + (factory) => (factory as { id: string }).id, + ); + expect(ids).toContain("@corbits/catalog-tools/catalog"); + expect(ids).toContain("@corbits/catalog-tools/off"); + } } finally { await rm(extractDir, { recursive: true, force: true }); } From f8093baf2d50a8fb68c1792cd76a233ec523fa87 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sun, 6 Sep 2026 09:27:05 -0700 Subject: [PATCH 12/12] catalog-tools: drop tautological or() fallback in cursor pagination or() is called with two always-defined branches in both the /models and /providers cursor conditions, so it never actually returns undefined; the `?? eq(x.id, x.id)` fallback was a no-op that always matched. Replace it with `?? sql\`false\``, the same fallback vendor/intx/hub-api's own cursorCondition uses for the identical never-undefined case. --- packages/catalog-tools/src/routes.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/catalog-tools/src/routes.ts b/packages/catalog-tools/src/routes.ts index b9ca26e80..36d585463 100644 --- a/packages/catalog-tools/src/routes.ts +++ b/packages/catalog-tools/src/routes.ts @@ -26,7 +26,7 @@ // 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 { and, desc, eq, lt, or, sql } from "drizzle-orm"; import { Hono } from "hono"; import { type } from "arktype"; @@ -247,10 +247,13 @@ export function createWorkflowCatalogAdminRoutes( const conditions = [eq(model.tenantId, tenantCtx.id)]; if (cursor) { conditions.push( + // Both branches always produce valid SQL, so or() never actually + // returns undefined here (mirroring + // `vendor/intx/hub-api/src/pagination.ts`'s `cursorCondition`). 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), + ) ?? sql`false`, ); } const rows = await deps.db.query.model.findMany({ @@ -270,13 +273,16 @@ export function createWorkflowCatalogAdminRoutes( const conditions = [eq(modelProvider.tenantId, tenantCtx.id)]; if (cursor) { conditions.push( + // Both branches always produce valid SQL, so or() never actually + // returns undefined here (mirroring + // `vendor/intx/hub-api/src/pagination.ts`'s `cursorCondition`). 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), + ) ?? sql`false`, ); } const rows = await deps.db.query.modelProvider.findMany({