From 6d55b5bf275167d5811cf7fc7415dcd567546650 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 15 Sep 2026 11:50:39 -0700 Subject: [PATCH 1/3] test(seeding,onboarding): expect native model discovery, not the resolved-offerings mirror (CL-7588 red) --- .../test/complete-credential.test.ts | 80 ++++++++++++------- 1 file changed, 49 insertions(+), 31 deletions(-) diff --git a/packages/onboarding/test/complete-credential.test.ts b/packages/onboarding/test/complete-credential.test.ts index 9aa8f44740..de1484c7b7 100644 --- a/packages/onboarding/test/complete-credential.test.ts +++ b/packages/onboarding/test/complete-credential.test.ts @@ -114,24 +114,33 @@ function seedHandshake(method: string, path: string): ApiResult | undefined { } // `ensureDeployment` resolves a real (non-noop-pinned) workflow's deploy -// source from the tenant's own catalog offerings (CL-7461). A test that +// source from the tenant's native model discovery (CL-7588). A test that // never seeds its own catalog (it stubs `seedCatalogFn` instead) still -// needs one listable offering for the "assistant" workflow's own deploy -// to succeed — this fixed single row is that. -function fixedOfferingsResponse() { +// needs one discoverable offering for the "assistant" workflow's own +// deploy to succeed — this fixed single row is that. +function fixedDiscoveredModelsResponse() { return { status: 200, - data: { - offerings: [ - { - id: "off_1", - priority: 0, - modelId: "mdl_1", - providerId: "mpr_1", - origin: { tenantId: TENANT_ID, direct: true }, - }, - ], - }, + data: [ + { + id: "mdl_1", + canonicalName: "model-1", + displayName: null, + description: null, + offerings: [ + { + offeringId: "off_1", + providerId: "mpr_1", + providerName: "anthropic", + plugin: "anthropic", + priority: 0, + deploymentTags: [], + capabilities: [], + pricing: [], + }, + ], + }, + ], cookies: [], }; } @@ -850,11 +859,11 @@ describe("completeCredentialSetup", () => { } if ( method === "GET" && - path === `/api/tenants/${TENANT_ID}/catalog/resolved-offerings` + path === `/api/tenants/${TENANT_ID}/models` ) { return { status: 200, - data: { offerings: [] }, + data: [], cookies: [], }; } @@ -908,9 +917,9 @@ describe("completeCredentialSetup", () => { } if ( method === "GET" && - path === `/api/tenants/${TENANT_ID}/catalog/resolved-offerings` + path === `/api/tenants/${TENANT_ID}/models` ) { - return fixedOfferingsResponse(); + return fixedDiscoveredModelsResponse(); } if ( method === "GET" && @@ -1543,19 +1552,28 @@ describe("completeCredentialSetup", () => { } if ( method === "GET" && - path === `/api/tenants/${TENANT_ID}/catalog/resolved-offerings` + path === `/api/tenants/${TENANT_ID}/models` ) { return { status: 200, - data: { - offerings: catalogOfferings.map((o) => ({ - id: o.id, - modelId: o.modelId, - providerId: o.providerId, - priority: o.priority, - origin: { tenantId: TENANT_ID, direct: true }, - })), - }, + data: catalogOfferings.map((o) => ({ + id: o.modelId, + canonicalName: o.modelId, + displayName: null, + description: null, + offerings: [ + { + offeringId: o.id, + providerId: o.providerId, + providerName: "anthropic", + plugin: "anthropic", + priority: o.priority, + deploymentTags: [], + capabilities: [], + pricing: [], + }, + ], + })), cookies: [], }; } @@ -1941,9 +1959,9 @@ describe("ensureSeeded (the slow half)", () => { const api: ApiCall = async (method, path, body) => { if ( method === "GET" && - path === `/api/tenants/${TENANT_ID}/catalog/resolved-offerings` + path === `/api/tenants/${TENANT_ID}/models` ) { - return fixedOfferingsResponse(); + return fixedDiscoveredModelsResponse(); } if ( method === "GET" && From a01053da5768e55772ea3a9c7d78f331e59e8243 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 15 Sep 2026 12:03:14 -0700 Subject: [PATCH 2/3] feat(catalog): catalog administration becomes UI-only over native reads (CL-7588 green) --- apps/hub/package.json | 1 - apps/hub/src/index.ts | 22 - bun.lock | 28 +- packages/catalog-tools/README.md | 30 +- packages/catalog-tools/package.json | 12 +- packages/catalog-tools/src/client.ts | 200 +------- packages/catalog-tools/src/index.ts | 18 +- packages/catalog-tools/src/routes.test.ts | 164 ------- packages/catalog-tools/src/routes.ts | 456 ------------------ packages/catalog-tools/src/tool.test.ts | 204 +------- packages/catalog-tools/src/tool.ts | 296 +----------- packages/onboarding/src/tenant-seed.ts | 71 ++- .../test/complete-credential.test.ts | 20 +- .../tool-registry-publish/src/pack.test.ts | 14 +- 14 files changed, 75 insertions(+), 1461 deletions(-) delete mode 100644 packages/catalog-tools/src/routes.test.ts delete mode 100644 packages/catalog-tools/src/routes.ts diff --git a/apps/hub/package.json b/apps/hub/package.json index ca8aa1e3f2..25a8505f9c 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -20,7 +20,6 @@ "@corbits/artifacts": "github:corbitsdev/corbits-artifacts#dc435ba749b4378f19e5de772159c2a57e932938", "@corbits/artifacts-hub": "workspace:*", "@corbits/bench": "workspace:*", - "@corbits/catalog-tools": "workspace:*", "@corbits/chat": "workspace:*", "@corbits/commands": "workspace:*", "@corbits/error-sink": "workspace:*", diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 3dd2ca7892..d771b0687a 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -158,7 +158,6 @@ import { createResolvedOfferingsRoutes, createWorkflowCatalogRoutes, } from "@corbits/inference-catalog"; -import { createWorkflowCatalogAdminRoutes } from "@corbits/catalog-tools/routes"; import { createWorkflowAccessRoutes } from "@corbits/access-tools/routes"; import { generateId } from "@intx/hub-common"; @@ -3047,27 +3046,6 @@ 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, - }), - ); // Myra's own principal/grant surface (`@corbits/access-tools`' // `list_principals`/`list_grants`/`grant_access`/`revoke_access`): the // workflow-run-authenticated counterpart to the tenant-session diff --git a/bun.lock b/bun.lock index 5a86a54149..20f64dcf9c 100644 --- a/bun.lock +++ b/bun.lock @@ -46,7 +46,6 @@ "@corbits/artifacts": "github:corbitsdev/corbits-artifacts#dc435ba749b4378f19e5de772159c2a57e932938", "@corbits/artifacts-hub": "workspace:*", "@corbits/bench": "workspace:*", - "@corbits/catalog-tools": "workspace:*", "@corbits/chat": "workspace:*", "@corbits/codex-provider": "workspace:*", "@corbits/commands": "workspace:*", @@ -441,16 +440,9 @@ "name": "@corbits/catalog-tools", "version": "0.0.5", "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:", @@ -3600,11 +3592,17 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/inbox/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#118a2cd", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-118a2cd", "sha512-jVRl6/IH34JBPVUz7mqWyBm1w7uJxvYAI27gjw7zL1dFO4L+D0E5q/8b+mpwKMNOKKlGXU6UF5pdHnY8ilYx8g=="], + "@corbits/artifacts-hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@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-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], + + "@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/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "@corbits/memory-hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + "@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=="], @@ -3628,12 +3626,12 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.8", "", {}, "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q=="], - "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#118a2cd", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-118a2cd", "sha512-jVRl6/IH34JBPVUz7mqWyBm1w7uJxvYAI27gjw7zL1dFO4L+D0E5q/8b+mpwKMNOKKlGXU6UF5pdHnY8ilYx8g=="], - - "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@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-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], "@workbench/sidecar/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e1e69e6", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-e1e69e6", "sha512-wUrD73iVyk/Dtb4yRn3hCh6N8syfsEvAkRz4XYqT0FIB3sYDSMYEWHcDMJx9nq64my/HBOeN1J6PHIOtpjhtPg=="], + "@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=="], @@ -3688,8 +3686,6 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "@corbits/inbox/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -3736,8 +3732,6 @@ "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "@workbench/hub/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], diff --git a/packages/catalog-tools/README.md b/packages/catalog-tools/README.md index da252d3101..83f42d42a9 100644 --- a/packages/catalog-tools/README.md +++ b/packages/catalog-tools/README.md @@ -23,29 +23,9 @@ 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 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 +Catalog administration is UI-only (CL-7588): models, providers, and +offerings are managed through the tenant-admin catalog routes (`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, - 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. +model-offerings}.ts`) behind a browser session. This package exposes no +write tools and no hub routes — the three read-only tools above are the +whole surface. diff --git a/packages/catalog-tools/package.json b/packages/catalog-tools/package.json index 51ac120e0e..5c9cbef48b 100644 --- a/packages/catalog-tools/package.json +++ b/packages/catalog-tools/package.json @@ -6,24 +6,16 @@ "license": "LGPL-2.1-or-later", "type": "module", "exports": { - ".": "./src/index.ts", - "./routes": "./src/routes.ts" + ".": "./src/index.ts" }, "scripts": { "typecheck": "tsc --noEmit", "test": "bun test" }, "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:" + "arktype": "catalog:" }, "devDependencies": { "@types/bun": "catalog:", diff --git a/packages/catalog-tools/src/client.ts b/packages/catalog-tools/src/client.ts index 9628112ff4..a655bf9339 100644 --- a/packages/catalog-tools/src/client.ts +++ b/packages/catalog-tools/src/client.ts @@ -6,15 +6,7 @@ // Authenticated the same way: sidecar bearer token plus run address, never a // human browser session. import { type } from "arktype"; -import { - Capability, - CreateModelOffering, - ModelOfferingResponse, - ModelProviderResponse, - ModelResponse, - UpdateModelOffering, - paginatedSchema, -} from "@intx/types"; +import { Capability } from "@intx/types"; export interface CatalogToolClientConfig { /** The hub's plain HTTP origin — the same value connections-tools' @@ -186,190 +178,6 @@ export async function fetchEstimate( // --- Catalog administration ------------------------------------------- // -// `list_model_concepts`/`pick_models`/`estimate_run_cost` above read the -// run-authenticated `/api/workflow-inference-catalog` surface. The tools -// 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; - /** 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/workflow-catalog-admin${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, - `/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, - `/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, - "/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, - `/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, - `/offerings/${offeringId}`, - "Disabling the offering", - (raw) => ModelOfferingResponse(raw), - { - method: "PATCH", - body: JSON.stringify(UpdateModelOffering.assert({ disabled: true })), - }, - ); -} +// UI-only (CL-7588): catalog writes go through the tenant-admin catalog +// routes behind a browser session, never through a workflow child, so +// this client exposes no write surface — the reads above are the whole API. diff --git a/packages/catalog-tools/src/index.ts b/packages/catalog-tools/src/index.ts index 29bbe3e7bc..2c9ef42d44 100644 --- a/packages/catalog-tools/src/index.ts +++ b/packages/catalog-tools/src/index.ts @@ -2,9 +2,6 @@ export { fetchChain, fetchEstimate, listConcepts, - UnknownCanonicalNameError, - UnknownProviderNameError, - type CatalogAdminClientConfig, type CatalogToolClientConfig, type ChainEntry, type ConceptSummary, @@ -12,21 +9,14 @@ 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"; -// 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. +// The read-only tools call the workflow-run-authenticated catalog surface +// `@corbits/inference-catalog` mounts in `apps/hub` at +// `/api/workflow-inference-catalog`. Catalog administration is UI-only +// (CL-7588): this package exposes no write tools and no hub routes. diff --git a/packages/catalog-tools/src/routes.test.ts b/packages/catalog-tools/src/routes.test.ts deleted file mode 100644 index 74154d10d0..0000000000 --- a/packages/catalog-tools/src/routes.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -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 deleted file mode 100644 index 36d5854639..0000000000 --- a/packages/catalog-tools/src/routes.ts +++ /dev/null @@ -1,456 +0,0 @@ -// 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, sql } 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( - // 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)), - ) ?? sql`false`, - ); - } - 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( - // 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), - ), - ) ?? sql`false`, - ); - } - 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/src/tool.test.ts b/packages/catalog-tools/src/tool.test.ts index 98cf5b45fb..c2be0a8e14 100644 --- a/packages/catalog-tools/src/tool.test.ts +++ b/packages/catalog-tools/src/tool.test.ts @@ -2,16 +2,11 @@ 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 = { @@ -206,200 +201,5 @@ 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", - } 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("create_offering", () => { - test("resolves both names to ids, then creates the offering", async () => { - globalThis.fetch = routedFetch({ - "/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, { - 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({ - "/models": ok({ data: [], nextCursor: null }), - "/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({ - "/models": () => forbidden(), - "/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({ - "/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({ - "/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({ - "/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({ - "/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); - }); -}); +// Catalog administration is UI-only (CL-7588): no write tools, so no +// write-tool tests — the read-only describes above are the whole suite. diff --git a/packages/catalog-tools/src/tool.ts b/packages/catalog-tools/src/tool.ts index 4226f227ed..f5a0f01130 100644 --- a/packages/catalog-tools/src/tool.ts +++ b/packages/catalog-tools/src/tool.ts @@ -20,20 +20,10 @@ 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, @@ -42,9 +32,6 @@ 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`. */ @@ -110,46 +97,6 @@ function clientConfig(env: WorkflowCatalogEnv): CatalogToolClientConfig { }; } -/** Env `create_offering`/`set_offering_priority`/`disable_offering` need - * beyond `BaseEnv`: the same hub-reach credential as - * {@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; -} - -function adminClientConfig( - env: WorkflowCatalogOfferingEnv, -): CatalogAdminClientConfig { - return { - hubCatalogUrl: env.hubCatalogUrl, - sidecarToken: env.sidecarToken, - address: env.address, - }; -} - -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[] }): { @@ -270,90 +217,6 @@ 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. @@ -467,160 +330,5 @@ 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/off", - requires: ["hubCatalogUrl", "sidecarToken", "address"], - 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}"`), - ), - ); - } - }, - }), -}); +// Catalog administration is UI-only (CL-7588): this package exposes no +// write tools — the three read-only tools above are the whole bundle. diff --git a/packages/onboarding/src/tenant-seed.ts b/packages/onboarding/src/tenant-seed.ts index c1ee2320c9..25e25788f7 100644 --- a/packages/onboarding/src/tenant-seed.ts +++ b/packages/onboarding/src/tenant-seed.ts @@ -32,6 +32,11 @@ import { AssetResponse, AssetWithOriginResponse, GrantResponse, + ModelInfo, + ModelOfferingResponse, + ModelProviderResponse, + ModelResponse, + ProviderResponse, WorkflowRunHealth, WorkflowDefinitionResponse, paginatedSchema, @@ -546,8 +551,8 @@ export const SEED_GRANTS: readonly { resource: string; action: string }[] = [ // Grants planted ONLY on Myra's own run principal (see // `plantAssistantRunPrincipalGrants`), not the tenant's shared principal -// `SEED_GRANTS` above reconciles — these let Myra mint/revoke grants and -// administer the catalog for her own specialist agents without handing +// `SEED_GRANTS` above reconciles — these let Myra mint/revoke grants +// for her own specialist agents without handing // every seeded principal in the tenant that same reach. // // `@corbits/access-tools`' workflow-run-authenticated routes @@ -558,14 +563,13 @@ export const SEED_GRANTS: readonly { resource: string; action: string }[] = [ // mint grants for every principal in the tenant, not just its own // specialist agents. // -// 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`). Scoped to Myra's run principal for the same -// reason as the access-tools grants above: a tenant-wide grant would let -// every seeded principal administer the catalog, not just Myra. +// CL-7588: catalog administration is UI-only — writes go through the +// tenant-admin catalog routes (`vendor/intx/hub-api/src/routes/ +// {models,model-providers,model-offerings}.ts`) behind a browser session, +// never through a workflow child. Myra's run principal keeps the catalog +// reads her `list_model_concepts`/`pick_models`/`estimate_run_cost` tools +// resolve through, and nothing more: a tenant-wide grant would let every +// seeded principal administer the catalog, not just Myra. const ASSISTANT_RUN_PRINCIPAL_GRANTS: readonly { resource: string; action: string; @@ -577,8 +581,6 @@ const ASSISTANT_RUN_PRINCIPAL_GRANTS: readonly { { 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 @@ -968,44 +970,39 @@ async function ensureDeployment( return deployment.id; } -const ResolvedOfferingResponse = type({ - id: "string", - priority: "number", - modelId: "string", - providerId: "string", - origin: { tenantId: "string", direct: "boolean" }, -}); - -const ResolvedOfferingsResponse = type({ - offerings: ResolvedOfferingResponse.array(), -}); +const DiscoveredModelsResponse = ModelInfo.array(); /** - * Lists the offerings visible to a tenant, including any inherited from - * an ancestor tenant — the same resolved view `listVisibleOfferings` - * (`@intx/db`) gives the hub's own workflow deployer - * (`apps/hub/src/index.ts`'s `workflowDeployer`), exposed at - * `GET .../catalog/resolved-offerings`. Seeding a tenant whose catalog is + * Lists the offering ids visible to a tenant by flattening the platform's + * own model discovery (`GET .../models`): every model resolved for the + * tenant after inheritance, shadowing, and disable suppression, with each + * model's offerings in resolution order. Seeding a tenant whose catalog is * inherited rather than directly owned (e.g. a sub-tenant under a * parent that already carries a real provider key) needs this resolved - * list, not the tenant-owned-only `GET .../catalog/offerings`. + * view, not the tenant-owned-only `GET .../catalog/offerings`. */ -async function listResolvedCatalogOfferings( +async function listDiscoveredModelOfferings( api: ApiCall, cookies: string[], tenantId: string, -): Promise<(typeof ResolvedOfferingResponse.infer)[]> { +): Promise<{ id: string; priority: number }[]> { const listed = await api( "GET", - `/api/tenants/${tenantId}/catalog/resolved-offerings`, + `/api/tenants/${tenantId}/models`, undefined, cookies, ); - return parseAs( - ResolvedOfferingsResponse, + const models = parseAs( + DiscoveredModelsResponse, listed.data, - "resolved catalog offerings response", - ).offerings; + "discovered models response", + ); + return models.flatMap((model) => + model.offerings.map((offering) => ({ + id: offering.offeringId, + priority: offering.priority, + })), + ); } /** @@ -1035,7 +1032,7 @@ async function resolveRealSourceOfferingIds( sourceOfferingIds: readonly string[]; defaultSourceOfferingId: string; }> { - const offerings = (await listResolvedCatalogOfferings(api, cookies, tenantId)) + const offerings = (await listDiscoveredModelOfferings(api, cookies, tenantId)) .filter((offering) => offering.id !== excludeOfferingId) .sort((a, b) => a.priority - b.priority); const defaultSourceOfferingId = offerings[0]?.id; diff --git a/packages/onboarding/test/complete-credential.test.ts b/packages/onboarding/test/complete-credential.test.ts index de1484c7b7..a1e4b94bea 100644 --- a/packages/onboarding/test/complete-credential.test.ts +++ b/packages/onboarding/test/complete-credential.test.ts @@ -857,10 +857,7 @@ describe("completeCredentialSetup", () => { cookies: [], }; } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/models` - ) { + if (method === "GET" && path === `/api/tenants/${TENANT_ID}/models`) { return { status: 200, data: [], @@ -915,10 +912,7 @@ describe("completeCredentialSetup", () => { if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { return tenantResponse(); } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/models` - ) { + if (method === "GET" && path === `/api/tenants/${TENANT_ID}/models`) { return fixedDiscoveredModelsResponse(); } if ( @@ -1550,10 +1544,7 @@ describe("completeCredentialSetup", () => { cookies: [], }; } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/models` - ) { + if (method === "GET" && path === `/api/tenants/${TENANT_ID}/models`) { return { status: 200, data: catalogOfferings.map((o) => ({ @@ -1957,10 +1948,7 @@ describe("ensureSeeded (the slow half)", () => { let deploymentCreatePosts = 0; const api: ApiCall = async (method, path, body) => { - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/models` - ) { + if (method === "GET" && path === `/api/tenants/${TENANT_ID}/models`) { return fixedDiscoveredModelsResponse(); } if ( diff --git a/packages/tool-registry-publish/src/pack.test.ts b/packages/tool-registry-publish/src/pack.test.ts index 188c847d2a..ae8b796cdd 100644 --- a/packages/tool-registry-publish/src/pack.test.ts +++ b/packages/tool-registry-publish/src/pack.test.ts @@ -99,17 +99,17 @@ describe("packToolPackageTarball", () => { } 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. + // The single read-only `catalogTools` bundle 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. Catalog administration is UI-only + // (CL-7588), so there is no second bundle anymore. const ids = factories.map( (factory) => (factory as { id: string }).id, ); expect(ids).toContain("@corbits/catalog-tools/catalog"); - expect(ids).toContain("@corbits/catalog-tools/off"); + expect(ids).not.toContain("@corbits/catalog-tools/off"); } } finally { await rm(extractDir, { recursive: true, force: true }); From e1ee06d89e5eba28710562a66fbe418d848b9ea7 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 15 Sep 2026 16:12:00 -0700 Subject: [PATCH 3/3] fix(catalog): drop unused model imports after seeding move (CL-7588) --- packages/onboarding/src/tenant-seed.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/onboarding/src/tenant-seed.ts b/packages/onboarding/src/tenant-seed.ts index 25e25788f7..8c68c9c3a5 100644 --- a/packages/onboarding/src/tenant-seed.ts +++ b/packages/onboarding/src/tenant-seed.ts @@ -33,10 +33,6 @@ import { AssetWithOriginResponse, GrantResponse, ModelInfo, - ModelOfferingResponse, - ModelProviderResponse, - ModelResponse, - ProviderResponse, WorkflowRunHealth, WorkflowDefinitionResponse, paginatedSchema,