From 45905da570339c3028f4fbc8a78d61a62d83b979 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 16:54:44 -0700 Subject: [PATCH 1/7] Add tests for versioned tool-package pin resolution (CL-7389) Covers the new resolvePinnedVersion resolver (highest published version among a tenant's, or its ancestor's, corbits-tools registry tarballs; fails closed when the registry or package is absent) and withAgentToolPackagePin's rejection of a "*" version. Updates the existing create/capability-add route tests, which previously asserted the old hardcoded "*" pin, to resolve real versions through their fake asset services instead. --- .../src/agent-workflow.test.ts | 51 ++++- packages/agent-directory/test/routes.test.ts | 98 +++++++--- .../test/tool-package-version.drizzle.test.ts | 185 ++++++++++++++++++ .../test/workflow-capability-routes.test.ts | 21 +- .../test/workflow-create-routes.test.ts | 20 +- 5 files changed, 346 insertions(+), 29 deletions(-) create mode 100644 packages/agent-directory/test/tool-package-version.drizzle.test.ts diff --git a/packages/agent-directory/src/agent-workflow.test.ts b/packages/agent-directory/src/agent-workflow.test.ts index 7c5a9b9112..f4bf48b6b8 100644 --- a/packages/agent-directory/src/agent-workflow.test.ts +++ b/packages/agent-directory/src/agent-workflow.test.ts @@ -5,7 +5,12 @@ // could never resolve at launch. import { describe, expect, test } from "bun:test"; import { describeCorbitsToolPackages } from "@corbits/tool-registry-publish"; -import { SKILLS_TOOL_PACKAGE_PIN } from "./agent-workflow"; +import { + buildAgentDefinitionWorkflow, + serializeAgentDefinitionWorkflow, + SKILLS_TOOL_PACKAGE_PIN, + withAgentToolPackagePin, +} from "./agent-workflow"; describe("SKILLS_TOOL_PACKAGE_PIN", () => { test("resolves through the corbits-tools registry", async () => { @@ -17,3 +22,47 @@ describe("SKILLS_TOOL_PACKAGE_PIN", () => { expect(match?.version).toBe(SKILLS_TOOL_PACKAGE_PIN.version); }); }); + +// CL-7389: a runtime tool-package pin must resolve to a concrete, +// published version — never the npm "any version" range `*` — so a +// later tarball landing in the registry never silently changes what an +// already-deployed specialist runs. +describe("withAgentToolPackagePin", () => { + function freshWorkflowJson(): string { + return serializeAgentDefinitionWorkflow( + buildAgentDefinitionWorkflow({ + handle: "pin-test", + tenantDomain: "example.test", + description: "", + systemPrompt: "You are a test agent.", + }), + ); + } + + test("rejects a wildcard version", () => { + expect(() => + withAgentToolPackagePin(freshWorkflowJson(), { + name: "@corbits/memory-tools", + version: "*", + }), + ).toThrow(/never "\*"/); + }); + + test("accepts a concrete version", () => { + const nextWorkflowJson = withAgentToolPackagePin(freshWorkflowJson(), { + name: "@corbits/memory-tools", + version: "1.2.3", + }); + const definition = JSON.parse(nextWorkflowJson) as { + steps: Record< + string, + { agent: { toolPackagePins?: { name: string; version: string }[] } } + >; + }; + const [step] = Object.values(definition.steps); + expect(step?.agent.toolPackagePins).toContainEqual({ + name: "@corbits/memory-tools", + version: "1.2.3", + }); + }); +}); diff --git a/packages/agent-directory/test/routes.test.ts b/packages/agent-directory/test/routes.test.ts index 70c7e0bc06..44d8013a60 100644 --- a/packages/agent-directory/test/routes.test.ts +++ b/packages/agent-directory/test/routes.test.ts @@ -16,6 +16,7 @@ import type { AssetService } from "@intx/hub-sessions"; import type { DB } from "@intx/db"; import { SkillRegistryError } from "@corbits/skills"; +import { CORBITS_TOOLS_REGISTRY } from "@corbits/tool-registry-publish"; import { buildAgentDefinitionWorkflow, @@ -100,6 +101,38 @@ const PRINCIPAL = { updatedAt: new Date(), }; +/** `resolvePinnedVersion`'s `resolveAssetByName` lookup, stubbed for tests + * that create/pin a tool package: a tenant-owned `corbits-tools` + * package-registry asset, so `db.query.tenant.findFirst`/`db.query.asset.findFirst` + * resolve exactly like the real ancestor-chain walk would for a + * single-tenant (no-parent) fixture. */ +const CORBITS_TOOLS_REGISTRY_ASSET = { + id: "ast_corbits_tools", + tenantId: TENANT.id, + kind: "package-registry" as const, + name: CORBITS_TOOLS_REGISTRY, + displayName: CORBITS_TOOLS_REGISTRY, + creatorPrincipalId: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +/** Merges the `db.query.tenant`/`db.query.asset` lookups + * `resolvePinnedVersion` needs into a fake db's `query` object, leaving + * every other query untouched. */ +function withToolPackageRegistryQueries( + db: T, +): T { + return { + ...db, + query: { + ...db.query, + tenant: { findFirst: async () => ({ parentId: null }) }, + asset: { findFirst: async () => CORBITS_TOOLS_REGISTRY_ASSET }, + }, + }; +} + function fakeAssetService(overrides: Partial = {}): AssetService { return { createAsset: () => { @@ -136,6 +169,14 @@ function liveDefinitionAsset(initial: Uint8Array): AssetService { current = new TextEncoder().encode(entry); return { commitSha: "deadbeef" }; }, + // Every tool package a `liveDefinitionAsset` test pins by name, at a + // fixed version — enough for `resolvePinnedVersion` to resolve + // without each concurrent-write test needing its own tarball list. + listAssetBlobs: () => + Promise.resolve([ + "corbits-github-tools-3.1.0.tgz", + "corbits-memory-tools-1.4.0.tgz", + ]), }); } @@ -598,7 +639,7 @@ test("a create request without skills records an empty skills list", async () => expect(await skillsStore.getSkills("ast_1")).toEqual([]); }); -test("a create request with toolPackagePins pins each named package at version *", async () => { +test("a create request with toolPackagePins pins each named package at its highest published version", async () => { let writtenFiles: Record | undefined; const app = buildApp( fakeAssetService({ @@ -617,8 +658,13 @@ test("a create request with toolPackagePins pins each named package at version * writtenFiles = params.tree.files; return Promise.resolve({ commitSha: "deadbeef" }); }, + listAssetBlobs: () => + Promise.resolve([ + "corbits-memory-tools-1.4.0.tgz", + "corbits-web-search-tools-2.1.0.tgz", + ]), }), - fakeCreateDb(), + withToolPackageRegistryQueries(fakeCreateDb()), ); const response = await post(app, { name: "Scout", @@ -629,8 +675,8 @@ test("a create request with toolPackagePins pins each named package at version * expect(response.status).toBe(201); const workflowJson = definitionFrom(writtenFiles); expect(pinsFrom(workflowJson)).toEqual([ - { name: "@corbits/memory-tools", version: "*" }, - { name: "@corbits/web-search-tools", version: "*" }, + { name: "@corbits/memory-tools", version: "1.4.0" }, + { name: "@corbits/web-search-tools", version: "2.1.0" }, ]); }); @@ -1644,12 +1690,16 @@ test("adding a tool package pin merges it into the definition in one commit, nam writtenMessage = params.tree.message; return Promise.resolve({ commitSha: "deadbeef" }); }, - }), - fakeInstructionsDb({ - id: "def_1", - assetId: "ast_1", - name: "research-buddy", - }), + listAssetBlobs: () => + Promise.resolve(["corbits-github-tools-3.1.0.tgz"]), + }), + withToolPackageRegistryQueries( + fakeInstructionsDb({ + id: "def_1", + assetId: "ast_1", + name: "research-buddy", + }), + ), ); const response = await postTo(app, "/def_1/capabilities", { kind: "toolPackage", @@ -1658,14 +1708,14 @@ test("adding a tool package pin merges it into the definition in one commit, nam expect(response.status).toBe(200); expect(Object.keys(writtenFiles ?? {})).toEqual(SOURCE_TREE_PATHS); expect(pinsFrom(definitionFrom(writtenFiles))).toEqual([ - { name: "@corbits/github-tools", version: "*" }, + { name: "@corbits/github-tools", version: "3.1.0" }, ]); expect(writtenMessage).toBe("Add @corbits/github-tools to research-buddy"); const body = (await response.json()) as { toolPackagePins: { name: string; version: string }[]; }; expect(body.toolPackagePins).toEqual([ - { name: "@corbits/github-tools", version: "*" }, + { name: "@corbits/github-tools", version: "3.1.0" }, ]); }); @@ -1850,11 +1900,13 @@ test("two concurrent capability-adds on the same definition both land", async () const assetService = liveDefinitionAsset(storedDefinitionBytes()); const app = buildApp( assetService, - fakeInstructionsDb({ - id: "def_1", - assetId: "ast_1", - name: "research-buddy", - }), + withToolPackageRegistryQueries( + fakeInstructionsDb({ + id: "def_1", + assetId: "ast_1", + name: "research-buddy", + }), + ), allowAllRequireGrant, fakeHistory(), inventory, @@ -1956,11 +2008,13 @@ test("concurrent DELETE model and a capability-add both land", async () => { ); const app = buildApp( assetService, - fakeInstructionsDb({ - id: "def_1", - assetId: "ast_1", - name: "research-buddy", - }), + withToolPackageRegistryQueries( + fakeInstructionsDb({ + id: "def_1", + assetId: "ast_1", + name: "research-buddy", + }), + ), ); const [delRes, capRes] = await Promise.all([ diff --git a/packages/agent-directory/test/tool-package-version.drizzle.test.ts b/packages/agent-directory/test/tool-package-version.drizzle.test.ts new file mode 100644 index 0000000000..aaf2c9822a --- /dev/null +++ b/packages/agent-directory/test/tool-package-version.drizzle.test.ts @@ -0,0 +1,185 @@ +// DB-gated: skipped when no DATABASE_URL is reachable (a fresh checkout +// still runs the unit gates), mirroring +// `visible-definitions.drizzle.test.ts`. Only `resolveAssetByName`'s +// tenant-ancestor-chain walk needs a real Postgres — the tarball +// listing itself is a hand-rolled fake `AssetService` (`routes.test.ts`'s +// convention), never a real git substrate. +// +// Proves CL-7389: a runtime tool-package pin resolves to the HIGHEST +// published version among the tenant's (possibly inherited) +// `corbits-tools` registry tarballs, never `*` — and fails closed with +// the same `CapabilityOutOfInventoryError` the guided-capability-add +// path already maps to a 4xx when the registry or the package is +// absent. +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { createDB, dropSchema, runMigrations, schema } from "@intx/db"; +import type { AssetService } from "@intx/hub-sessions"; +import { CORBITS_TOOLS_REGISTRY } from "@corbits/tool-registry-publish"; + +import { dbTargetFromUrl } from "../../../scripts/db-setup"; +import { e2eDatabaseUrl } from "../../../scripts/e2e/harness"; +import { dbGate } from "../../../scripts/e2e/db-gate"; +import { CapabilityOutOfInventoryError } from "../src/capability-inventory"; +import { resolvePinnedVersion } from "../src/tool-package-version"; + +const databaseUrl = e2eDatabaseUrl(); +const describeIfDb = dbGate(databaseUrl, import.meta.path); + +const SCHEMA = "agent_directory_tool_package_version_test"; + +function fakeAssetService(filenamesByDir: Record): AssetService { + return { + createAsset: () => { + throw new Error("not used in these tests"); + }, + populateAsset: () => { + throw new Error("not used in these tests"); + }, + readAssetBlob: () => { + throw new Error("not used in these tests"); + }, + listAssetBlobs: ({ dir }) => Promise.resolve(filenamesByDir[dir] ?? []), + }; +} + +async function seedTenant( + db: Awaited>["db"], + input: { id: string; parentId?: string }, +) { + await db.insert(schema.tenant).values({ + id: input.id, + name: input.id, + slug: input.id.replace(/_/g, "-"), + domain: `${input.id.replace(/_/g, "-")}.workbench.test`, + ...(input.parentId !== undefined ? { parentId: input.parentId } : {}), + }); +} + +describeIfDb( + "resolvePinnedVersion: resolves against the tenant's corbits-tools registry", + () => { + const target = dbTargetFromUrl( + databaseUrl ?? "postgres://localhost:5432/unused", + ); + + beforeAll(async () => { + await runMigrations(target, { schema: SCHEMA }); + }); + + afterAll(async () => { + await dropSchema(target, { schema: SCHEMA }); + }); + + test("resolves the highest published version among the registry's tarballs", async () => { + const { db, close } = createDB({ ...target, schema: SCHEMA }); + try { + const tenantId = `tnt_tpv_${randomUUID().slice(0, 8)}`; + await seedTenant(db, { id: tenantId }); + await db.insert(schema.asset).values({ + id: `asset_${tenantId}`, + tenantId, + kind: "package-registry", + name: CORBITS_TOOLS_REGISTRY, + }); + + const assetService = fakeAssetService({ + tarballs: [ + "corbits-memory-tools-1.2.0.tgz", + "corbits-memory-tools-1.10.0.tgz", + "corbits-memory-tools-1.3.0.tgz", + "corbits-capability-tools-0.0.2.tgz", + ], + }); + const resolved = await resolvePinnedVersion( + { db, assetService }, + tenantId, + "@corbits/memory-tools", + ); + expect(resolved).toEqual({ + name: "@corbits/memory-tools", + version: "1.10.0", + }); + } finally { + await close(); + } + }); + + test("a child tenant resolves against its inherited (ancestor-owned) registry", async () => { + const { db, close } = createDB({ ...target, schema: SCHEMA }); + try { + const rootId = `tnt_tpv_root_${randomUUID().slice(0, 8)}`; + const childId = `tnt_tpv_child_${randomUUID().slice(0, 8)}`; + await seedTenant(db, { id: rootId }); + await seedTenant(db, { id: childId, parentId: rootId }); + await db.insert(schema.asset).values({ + id: `asset_${rootId}`, + tenantId: rootId, + kind: "package-registry", + name: CORBITS_TOOLS_REGISTRY, + }); + + const assetService = fakeAssetService({ + tarballs: ["corbits-capability-tools-0.0.2.tgz"], + }); + const resolved = await resolvePinnedVersion( + { db, assetService }, + childId, + "@corbits/capability-tools", + ); + expect(resolved).toEqual({ + name: "@corbits/capability-tools", + version: "0.0.2", + }); + } finally { + await close(); + } + }); + + test("pinning a package absent from the registry fails closed", async () => { + const { db, close } = createDB({ ...target, schema: SCHEMA }); + try { + const tenantId = `tnt_tpv_${randomUUID().slice(0, 8)}`; + await seedTenant(db, { id: tenantId }); + await db.insert(schema.asset).values({ + id: `asset_${tenantId}`, + tenantId, + kind: "package-registry", + name: CORBITS_TOOLS_REGISTRY, + }); + + const assetService = fakeAssetService({ + tarballs: ["corbits-memory-tools-1.0.0.tgz"], + }); + await expect( + resolvePinnedVersion( + { db, assetService }, + tenantId, + "@corbits/no-such-tools", + ), + ).rejects.toBeInstanceOf(CapabilityOutOfInventoryError); + } finally { + await close(); + } + }); + + test("a tenant with no visible corbits-tools registry fails closed", async () => { + const { db, close } = createDB({ ...target, schema: SCHEMA }); + try { + const tenantId = `tnt_tpv_${randomUUID().slice(0, 8)}`; + await seedTenant(db, { id: tenantId }); + + const assetService = fakeAssetService({}); + await expect( + resolvePinnedVersion( + { db, assetService }, + tenantId, + "@corbits/memory-tools", + ), + ).rejects.toBeInstanceOf(CapabilityOutOfInventoryError); + } finally { + await close(); + } + }); + }, +); diff --git a/packages/agent-directory/test/workflow-capability-routes.test.ts b/packages/agent-directory/test/workflow-capability-routes.test.ts index 89af7e90f2..a11f63d3ac 100644 --- a/packages/agent-directory/test/workflow-capability-routes.test.ts +++ b/packages/agent-directory/test/workflow-capability-routes.test.ts @@ -31,6 +31,7 @@ import { } from "../src/skills-store"; import { SOURCE_TREE_PATHS } from "./source-tree"; import type { CapabilityInventoryProvider } from "../src/capability-inventory"; +import { CORBITS_TOOLS_REGISTRY } from "@corbits/tool-registry-publish"; const TENANT_ID = "tnt_1"; const RUN_ID = "run_1"; @@ -96,6 +97,20 @@ function fakeAssetService(overrides: Partial = {}): AssetService { }; } +/** `resolvePinnedVersion`'s `resolveAssetByName` lookup, stubbed as a + * tenant-owned `corbits-tools` package-registry asset with no parent — + * mirrors `routes.test.ts`'s `CORBITS_TOOLS_REGISTRY_ASSET` fixture. */ +const CORBITS_TOOLS_REGISTRY_ASSET = { + id: "ast_corbits_tools", + tenantId: TENANT_ID, + kind: "package-registry" as const, + name: CORBITS_TOOLS_REGISTRY, + displayName: CORBITS_TOOLS_REGISTRY, + creatorPrincipalId: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + function fakeDb(): DB["db"] { return { query: { @@ -113,6 +128,8 @@ function fakeDb(): DB["db"] { name: "research-buddy", }), }, + tenant: { findFirst: async () => ({ parentId: null }) }, + asset: { findFirst: async () => CORBITS_TOOLS_REGISTRY_ASSET }, }, } as unknown as DB["db"]; } @@ -254,6 +271,8 @@ test("a run may add a capability to its own definition without any grant check", writtenMessage = params.tree.message; return Promise.resolve({ commitSha: "deadbeef" }); }, + listAssetBlobs: () => + Promise.resolve(["corbits-capability-tools-0.0.2.tgz"]), }), deployer, }); @@ -275,7 +294,7 @@ test("a run may add a capability to its own definition without any grant check", toolPackagePins: { name: string; version: string }[]; }; expect(body.toolPackagePins).toEqual([ - { name: "@corbits/capability-tools", version: "*" }, + { name: "@corbits/capability-tools", version: "0.0.2" }, ]); }); diff --git a/packages/agent-directory/test/workflow-create-routes.test.ts b/packages/agent-directory/test/workflow-create-routes.test.ts index 599ae89270..f19ab18d4d 100644 --- a/packages/agent-directory/test/workflow-create-routes.test.ts +++ b/packages/agent-directory/test/workflow-create-routes.test.ts @@ -21,6 +21,7 @@ import type { import type { PinnedSkillIndexResolver } from "../src/routes"; import { createInMemoryDefinitionSkillsStore } from "../src/skills-store"; import type { CapabilityInventoryProvider } from "../src/capability-inventory"; +import { CORBITS_TOOLS_REGISTRY } from "@corbits/tool-registry-publish"; import { definitionFrom, SOURCE_TREE_PATHS } from "./source-tree"; const TENANT_ID = "tnt_1"; @@ -52,9 +53,9 @@ function fakeAssetService(overrides: Partial = {}): AssetService { readAssetBlob: () => { throw new Error("not used in these tests"); }, - listAssetBlobs: () => { - throw new Error("not used in these tests"); - }, + // `resolvePinnedVersion`'s tarball listing — every create test in + // this file pins (or gets baseline-pinned) `@corbits/memory-tools`. + listAssetBlobs: () => Promise.resolve(["corbits-memory-tools-1.4.0.tgz"]), ...overrides, } as unknown as AssetService; } @@ -104,10 +105,19 @@ function fakeDb( return { query: { tenant: { - findFirst: async () => ({ id: TENANT_ID, domain: "acme.example" }), + findFirst: async () => ({ + id: TENANT_ID, + domain: "acme.example", + parentId: null, + }), }, asset: { - findFirst: async () => undefined, + findFirst: async () => ({ + id: "ast_corbits_tools", + tenantId: TENANT_ID, + kind: "package-registry" as const, + name: CORBITS_TOOLS_REGISTRY, + }), }, workflowDefinition: { findFirst: async () => createdRow, From 9f9418225e3cb9c3546f941dee3eb2e593b6ddce Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 16:54:53 -0700 Subject: [PATCH 2/7] Runtime tool-package pins resolve to a published version, never * (CL-7389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three runtime sites that pinned a tool package by name only (create_agent's toolPackagePins, guided capability-add on both the tenant-session and workflow-run capability routes) hardcoded version "*" — a new tarball landing in the tenant's corbits-tools registry would silently change what an already-deployed specialist runs, with no record of the change. Adds resolvePinnedVersion, which resolves a bare package name against the tenant's (possibly inherited) corbits-tools package-registry asset and picks the highest published version among its tarballs, throwing the same CapabilityOutOfInventoryError the guided-capability-add inventory check already throws when the registry or package is absent — every caller's existing 4xx mapping covers it with no new wiring. withAgentToolPackagePin now rejects a "*" version outright as a second line of defense. A plain redeploy never re-resolves an existing pin's version; only a newly added or newly created pin does. --- bun.lock | 8 +- packages/agent-directory/package.json | 6 +- .../agent-directory/src/agent-workflow.ts | 49 ++++++-- .../agent-directory/src/capability-add.ts | 13 ++- packages/agent-directory/src/index.ts | 7 ++ packages/agent-directory/src/routes.ts | 1 + .../src/tool-package-version.ts | 106 ++++++++++++++++++ .../src/workflow-capability-routes.ts | 1 + 8 files changed, 175 insertions(+), 16 deletions(-) create mode 100644 packages/agent-directory/src/tool-package-version.ts diff --git a/bun.lock b/bun.lock index 054936bb28..87cfc54aac 100644 --- a/bun.lock +++ b/bun.lock @@ -214,6 +214,7 @@ "@corbits/error-sink": "workspace:*", "@corbits/folded-run-one-shot": "workspace:*", "@corbits/skills": "workspace:*", + "@corbits/tool-registry-publish": "workspace:*", "@corbits/workflows": "workspace:*", "@intx/agent": "workspace:*", "@intx/db": "workspace:*", @@ -228,11 +229,12 @@ "hono": "catalog:", "isomorphic-git": "catalog:", "postgres": "catalog:", + "semver": "catalog:", }, "devDependencies": { - "@corbits/tool-registry-publish": "workspace:*", "@intx/crypto": "0.3.0", "@types/bun": "catalog:", + "@types/semver": "catalog:", "typescript": "catalog:", }, }, @@ -3549,7 +3551,7 @@ "@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/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=="], @@ -3573,7 +3575,7 @@ "@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/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=="], diff --git a/packages/agent-directory/package.json b/packages/agent-directory/package.json index f7b4971aa9..bbcb3b300d 100644 --- a/packages/agent-directory/package.json +++ b/packages/agent-directory/package.json @@ -18,6 +18,7 @@ "@corbits/error-sink": "workspace:*", "@corbits/folded-run-one-shot": "workspace:*", "@corbits/skills": "workspace:*", + "@corbits/tool-registry-publish": "workspace:*", "@workbench/templates": "workspace:*", "@corbits/workflows": "workspace:*", "@intx/agent": "workspace:*", @@ -31,12 +32,13 @@ "drizzle-orm": "catalog:", "hono": "catalog:", "isomorphic-git": "catalog:", - "postgres": "catalog:" + "postgres": "catalog:", + "semver": "catalog:" }, "devDependencies": { - "@corbits/tool-registry-publish": "workspace:*", "@intx/crypto": "0.3.0", "@types/bun": "catalog:", + "@types/semver": "catalog:", "typescript": "catalog:" } } diff --git a/packages/agent-directory/src/agent-workflow.ts b/packages/agent-directory/src/agent-workflow.ts index 5837d13c53..506eac480a 100644 --- a/packages/agent-directory/src/agent-workflow.ts +++ b/packages/agent-directory/src/agent-workflow.ts @@ -32,6 +32,7 @@ import { type AgentDefinitionDeployer, } from "./definition-asset"; import type { DefinitionSkillsStore } from "./skills-store"; +import { resolvePinnedVersion } from "./tool-package-version"; export const AGENT_DEFINITION_STEP_ID = "agent"; @@ -199,15 +200,47 @@ export function readAgentCapabilities( : { toolPackagePins: step.agent.toolPackagePins ?? [] }; } +/** + * A tool-package pin resolved to a concrete, published version — never + * the npm "any version" range `*`. `ToolPackagePin` itself (the wire + * type `@intx/types/tool-packages` declares) still accepts `*`, because + * an operator hand-authoring a workflow source file is free to write + * one; but every RUNTIME site this package writes a pin from + * (`create_agent`'s tool-package pins, guided capability-add) must name + * a version the resolver actually offers today (CL-7389) — a `*` pin + * means a later tarball landing in the registry silently changes what + * an already-deployed specialist runs, with no record of the change. + * `./tool-package-version.ts`'s `resolvePinnedVersion` is how a caller + * that only has a package name gets one of these. + */ +export const NonWildcardToolPackagePin = type({ + name: "string", + version: "string", +}).narrow((pin, ctx) => + pin.version !== "*" + ? true + : ctx.mustBe( + 'a concrete published version, never "*" — a wildcard pin would let a later tarball silently change what this pin resolves to (CL-7389)', + ), +); +export type NonWildcardToolPackagePin = typeof NonWildcardToolPackagePin.infer; + /** Adds or replaces one tool-package pin by name, leaving every other * pin — including the skills bundle `reindexPinnedSkills` manages — * untouched. Mirrors `withSkillsToolPin`'s replace-by-name shape, * generalized to a caller-supplied pin rather than the fixed skills - * bundle. */ + * bundle. Rejects a `"*"` version outright (see `NonWildcardToolPackagePin`) — + * every runtime caller must supply a concrete, resolved version. */ export function withAgentToolPackagePin( workflowJson: string, - pin: ToolPackagePin, + pin: NonWildcardToolPackagePin, ): string { + const parsedPin = NonWildcardToolPackagePin(pin); + if (parsedPin instanceof type.errors) { + throw new Error( + `withAgentToolPackagePin: pin must be ${parsedPin.summary}`, + ); + } const raw: unknown = JSON.parse(workflowJson); const definition = DefinitionWithAgentSteps(raw); if (definition instanceof type.errors) { @@ -217,9 +250,9 @@ export function withAgentToolPackagePin( } for (const step of Object.values(definition.steps)) { const others = (step.agent.toolPackagePins ?? []).filter( - (existing) => existing.name !== pin.name, + (existing) => existing.name !== parsedPin.name, ); - step.agent.toolPackagePins = [...others, { ...pin }]; + step.agent.toolPackagePins = [...others, { ...parsedPin }]; } return JSON.stringify(definition); } @@ -504,10 +537,12 @@ export async function createAgentDefinitionCore( ), ); for (const name of input.toolPackagePins ?? []) { - workflowJson = withAgentToolPackagePin(workflowJson, { + const resolvedPin = await resolvePinnedVersion( + { db: deps.db, assetService: deps.assetService }, + input.tenantId, name, - version: "*", - }); + ); + workflowJson = withAgentToolPackagePin(workflowJson, resolvedPin); } let assetId: string; diff --git a/packages/agent-directory/src/capability-add.ts b/packages/agent-directory/src/capability-add.ts index 456e8abfdb..9d7d01ff39 100644 --- a/packages/agent-directory/src/capability-add.ts +++ b/packages/agent-directory/src/capability-add.ts @@ -7,6 +7,7 @@ // in `./asset-write.ts` so sibling RMW routes share it. import type { PinnedSkillIndexEntry } from "@corbits/skills"; +import type { DB } from "@intx/db"; import type { AssetService } from "@intx/hub-sessions"; import { @@ -22,8 +23,10 @@ import { type AgentDefinitionDeployer, } from "./definition-asset"; import type { DefinitionSkillsStore } from "./skills-store"; +import { resolvePinnedVersion } from "./tool-package-version"; export type CommitAgentCapabilityAddArgs = { + db: DB["db"]; assetService: AssetService; deployer: AgentDefinitionDeployer; skillsStore: DefinitionSkillsStore; @@ -109,10 +112,12 @@ async function prepareCapabilityAdd( switch (args.body.kind) { case "toolPackage": { - nextWorkflowJson = withAgentToolPackagePin(workflowJson, { - name: args.body.name, - version: "*", - }); + const resolvedPin = await resolvePinnedVersion( + { db: args.db, assetService: args.assetService }, + args.tenantId, + args.body.name, + ); + nextWorkflowJson = withAgentToolPackagePin(workflowJson, resolvedPin); message = `Add ${args.body.name} to ${args.handle}`; break; } diff --git a/packages/agent-directory/src/index.ts b/packages/agent-directory/src/index.ts index 0bbda128b3..a7684473ec 100644 --- a/packages/agent-directory/src/index.ts +++ b/packages/agent-directory/src/index.ts @@ -9,12 +9,19 @@ export { createAgentDefinitionCore, DuplicateAgentHandleError, AGENT_DEFINITION_STEP_ID, + NonWildcardToolPackagePin, type AgentDefinitionCapabilities, type AgentDefinitionWorkflowInput, type CreateAgentDefinitionCoreDeps, type CreateAgentDefinitionCoreInput, type CreateAgentDefinitionCoreResult, } from "./agent-workflow"; +export type { NonWildcardToolPackagePin as NonWildcardToolPackagePinType } from "./agent-workflow"; +export { + resolvePinnedVersion, + type ResolvePinnedVersionDeps, + type ResolvedToolPackagePin, +} from "./tool-package-version"; export { agentDefinitionSourceTree, parseAgentDefinitionEntry, diff --git a/packages/agent-directory/src/routes.ts b/packages/agent-directory/src/routes.ts index be6ce1cea1..e9ece5f6b7 100644 --- a/packages/agent-directory/src/routes.ts +++ b/packages/agent-directory/src/routes.ts @@ -543,6 +543,7 @@ export function createAgentDefinitionRoutes({ assertCapabilityInInventory(body, inventory); const added = await commitAgentCapabilityAdd({ + db, assetService, deployer, skillsStore, diff --git a/packages/agent-directory/src/tool-package-version.ts b/packages/agent-directory/src/tool-package-version.ts new file mode 100644 index 0000000000..18c6037536 --- /dev/null +++ b/packages/agent-directory/src/tool-package-version.ts @@ -0,0 +1,106 @@ +// CL-7389: a runtime tool-package pin must resolve to a concrete, +// published version — never the npm "any version" range `*` — so a new +// tarball landing in the tenant's registry never silently changes what +// an already-deployed specialist runs. `withAgentToolPackagePin` (see +// `./agent-workflow.ts`) rejects `"*"` at its own boundary; this module +// is the one place a caller that only has a package *name* (guided +// capability-add, `create_agent`'s own tool-package pins) resolves it to +// the version to pin instead. +// +// The tenant's usable tool packages are published as npm-style tarballs +// into its (possibly inherited) `corbits-tools` `package-registry` +// asset — the exact same asset `@intx/tool-packaging`'s +// `AssetRegistrySource` reads from at deploy-assembly time +// (`vendor/intx/hub-sessions/src/session-service.ts`'s `buildAndResolve`). +// Reading the tarball listing directly here, rather than re-deriving a +// packument, keeps this resolver a plain filename scan: exactly the +// same `tarballCoversPackage` filter `@corbits/tool-registry-publish` +// already uses to decide whether a registry carries a given package. +import semver from "semver"; + +import type { DB } from "@intx/db"; +import { resolveAssetByName } from "@intx/db"; +import type { AssetService } from "@intx/hub-sessions"; + +import { + CORBITS_TOOLS_REGISTRY, + tarballCoversPackage, +} from "@corbits/tool-registry-publish"; + +import { CapabilityOutOfInventoryError } from "./capability-inventory"; + +const TARBALLS_DIR = "tarballs"; + +/** A resolved runtime pin: a package name paired with the highest + * published version the tenant's registry currently carries for it. */ +export type ResolvedToolPackagePin = { + readonly name: string; + readonly version: string; +}; + +export type ResolvePinnedVersionDeps = { + readonly db: DB["db"]; + readonly assetService: AssetService; +}; + +/** The `-.tgz` filename convention + * `tarballFilenameFor`/`packToolPackageTarball` (`@corbits/tool-registry-publish`'s + * `pack.ts`) write into a registry's `tarballs/` tree. Returns `null` + * for a filename that does not cover `packageName` or does not parse as + * a valid semver version — a defensively-shaped filename is treated as + * absent rather than crashing the resolution. */ +function versionFromTarballFilename( + filename: string, + packageName: string, +): string | null { + if (!tarballCoversPackage(filename, packageName)) return null; + const prefix = `${packageName.replace(/^@/, "").replace("/", "-")}-`; + const version = filename.slice(prefix.length, -".tgz".length); + return semver.valid(version) !== null ? version : null; +} + +/** + * Resolves `packageName` against the tenant's (possibly inherited) + * `corbits-tools` registry to `{ name, version }`, `version` always the + * highest published semver among the registry's tarballs for that + * package — never `*`, so a runtime pin is reproducible: a later + * tarball landing in the registry changes what a *new* pin resolves to, + * never what an already-deployed definition's stored pin resolves to. + * + * Throws `CapabilityOutOfInventoryError` — the same fail-closed error + * `assertCapabilityInInventory` throws for any other out-of-inventory + * addition, so every caller's existing `CapabilityOutOfInventoryError` + * -> 4xx mapping (`./routes.ts`, `./workflow-capability-routes.ts`) + * covers this case with no new wiring — when the tenant has no visible + * `corbits-tools` registry, or that registry carries no tarball for + * `packageName`. + */ +export async function resolvePinnedVersion( + deps: ResolvePinnedVersionDeps, + tenantId: string, + packageName: string, +): Promise { + const registryAsset = await resolveAssetByName( + deps.db, + tenantId, + "package-registry", + CORBITS_TOOLS_REGISTRY, + ); + if (registryAsset === null) { + throw new CapabilityOutOfInventoryError("toolPackage", packageName); + } + + const filenames = await deps.assetService.listAssetBlobs({ + assetId: registryAsset.id, + dir: TARBALLS_DIR, + }); + const versions = filenames + .map((filename) => versionFromTarballFilename(filename, packageName)) + .filter((version): version is string => version !== null); + const highest = versions.sort(semver.compare).at(-1); + if (highest === undefined) { + throw new CapabilityOutOfInventoryError("toolPackage", packageName); + } + + return { name: packageName, version: highest }; +} diff --git a/packages/agent-directory/src/workflow-capability-routes.ts b/packages/agent-directory/src/workflow-capability-routes.ts index 49506b6e19..bd2cc748da 100644 --- a/packages/agent-directory/src/workflow-capability-routes.ts +++ b/packages/agent-directory/src/workflow-capability-routes.ts @@ -236,6 +236,7 @@ export function createWorkflowCapabilityRoutes( assertCapabilityInInventory(body, inventory); const added = await commitAgentCapabilityAdd({ + db: deps.db, assetService: deps.assetService, deployer: deps.deployer, skillsStore: deps.skillsStore, From 66ef084fae594a28d541892691d7a693327ddf6a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 16:54:58 -0700 Subject: [PATCH 3/7] Update docs: versioned tool-package pins (CL-7389) Documents resolvePinnedVersion and why every runtime pin this package writes resolves to a concrete version rather than "*". --- packages/agent-directory/README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/agent-directory/README.md b/packages/agent-directory/README.md index 7b0cfe1cf0..924994759a 100644 --- a/packages/agent-directory/README.md +++ b/packages/agent-directory/README.md @@ -7,6 +7,27 @@ and the hub routes that create a definition and manage its attached skills (`routes.ts`) — server-side, backed by `@intx/agent`, `@intx/workflow`, `@intx/hub-sessions`, and Postgres via `@intx/db`. +## Runtime tool-package pins are always versioned + +Every pin this package writes onto a definition (`create_agent`'s +`toolPackagePins`, guided capability-add's `POST /:definitionId/capabilities`) +resolves to a concrete, published version — never the npm "any version" +range `*`. `resolvePinnedVersion` (`src/tool-package-version.ts`) resolves a +bare package name to `{ name, version }` by reading the tenant's (possibly +inherited) `corbits-tools` `package-registry` asset and picking the highest +version among its tarballs; it throws the same `CapabilityOutOfInventoryError` +guided capability-add's inventory check throws when the registry or the +package is absent, so every caller's existing 4xx mapping covers it with no +new wiring. `withAgentToolPackagePin` (`src/agent-workflow.ts`) rejects a +`"*"` version outright as a second line of defense. + +This matters because a `*` pin would let a later tarball landing in the +registry silently change what an already-deployed specialist runs, with no +record of the change (CL-7389). Resolving a version only happens when a +pin is newly added or a definition is newly created with named pins — a +plain redeploy of an existing definition never re-resolves; it keeps +whatever version its existing pins already carry. + ## `/client` subpath contract `@corbits/agent-directory/client` (`src/client.ts`) is the browser-safe From 019adf0d125cc67d89f74e5c7ca3ff3bf21eca14 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 17:04:57 -0700 Subject: [PATCH 4/7] Fix prettier formatting in CL-7389 test changes --- packages/agent-directory/test/routes.test.ts | 7 ++----- .../test/tool-package-version.drizzle.test.ts | 4 +++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/agent-directory/test/routes.test.ts b/packages/agent-directory/test/routes.test.ts index 44d8013a60..909b9660af 100644 --- a/packages/agent-directory/test/routes.test.ts +++ b/packages/agent-directory/test/routes.test.ts @@ -120,9 +120,7 @@ const CORBITS_TOOLS_REGISTRY_ASSET = { /** Merges the `db.query.tenant`/`db.query.asset` lookups * `resolvePinnedVersion` needs into a fake db's `query` object, leaving * every other query untouched. */ -function withToolPackageRegistryQueries( - db: T, -): T { +function withToolPackageRegistryQueries(db: T): T { return { ...db, query: { @@ -1690,8 +1688,7 @@ test("adding a tool package pin merges it into the definition in one commit, nam writtenMessage = params.tree.message; return Promise.resolve({ commitSha: "deadbeef" }); }, - listAssetBlobs: () => - Promise.resolve(["corbits-github-tools-3.1.0.tgz"]), + listAssetBlobs: () => Promise.resolve(["corbits-github-tools-3.1.0.tgz"]), }), withToolPackageRegistryQueries( fakeInstructionsDb({ diff --git a/packages/agent-directory/test/tool-package-version.drizzle.test.ts b/packages/agent-directory/test/tool-package-version.drizzle.test.ts index aaf2c9822a..adaea07392 100644 --- a/packages/agent-directory/test/tool-package-version.drizzle.test.ts +++ b/packages/agent-directory/test/tool-package-version.drizzle.test.ts @@ -28,7 +28,9 @@ const describeIfDb = dbGate(databaseUrl, import.meta.path); const SCHEMA = "agent_directory_tool_package_version_test"; -function fakeAssetService(filenamesByDir: Record): AssetService { +function fakeAssetService( + filenamesByDir: Record, +): AssetService { return { createAsset: () => { throw new Error("not used in these tests"); From 28cb03f02bef9df4d23a8dc4a97eaf7d84c74a87 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 18:20:52 -0700 Subject: [PATCH 5/7] Add tests for versioned-pin fixes (CL-7389) Covers the confirmed critique findings on the versioned-pins work: a PUT that only edits name/prompt must never re-touch tool-package pins even after a newer tarball lands; re-adding an already-pinned package via guided capability-add must keep its stored version rather than silently bumping; NonWildcardToolPackagePin must reject any non-exact version (latest, ^1, ~1.2, >=1.0.0, 1.x, empty), not just "*"; resolvePinnedVersion must prefer the highest stable version over a higher-sorting prerelease; and a multi-pin create must share one registry lookup and tarball listing across all of its pins instead of doing one round trip per pin. --- .../src/agent-workflow.test.ts | 122 +++++++++++ .../src/tool-package-version.test.ts | 194 ++++++++++++++++++ packages/agent-directory/test/routes.test.ts | 136 ++++++++++++ 3 files changed, 452 insertions(+) create mode 100644 packages/agent-directory/src/tool-package-version.test.ts diff --git a/packages/agent-directory/src/agent-workflow.test.ts b/packages/agent-directory/src/agent-workflow.test.ts index f4bf48b6b8..4cfb4d5178 100644 --- a/packages/agent-directory/src/agent-workflow.test.ts +++ b/packages/agent-directory/src/agent-workflow.test.ts @@ -4,13 +4,17 @@ // a definition pinning skills carried a pin the corbits-tools registry // could never resolve at launch. import { describe, expect, test } from "bun:test"; +import type { DB } from "@intx/db"; +import type { AssetService } from "@intx/hub-sessions"; import { describeCorbitsToolPackages } from "@corbits/tool-registry-publish"; import { buildAgentDefinitionWorkflow, + createAgentDefinitionCore, serializeAgentDefinitionWorkflow, SKILLS_TOOL_PACKAGE_PIN, withAgentToolPackagePin, } from "./agent-workflow"; +import { createInMemoryDefinitionSkillsStore } from "./skills-store"; describe("SKILLS_TOOL_PACKAGE_PIN", () => { test("resolves through the corbits-tools registry", async () => { @@ -48,6 +52,18 @@ describe("withAgentToolPackagePin", () => { ).toThrow(/never "\*"/); }); + test.each(["latest", "^1", "~1.2", "", ">=1.0.0", "1.x"])( + "rejects a non-exact version %p", + (version) => { + expect(() => + withAgentToolPackagePin(freshWorkflowJson(), { + name: "@corbits/memory-tools", + version, + }), + ).toThrow(); + }, + ); + test("accepts a concrete version", () => { const nextWorkflowJson = withAgentToolPackagePin(freshWorkflowJson(), { name: "@corbits/memory-tools", @@ -66,3 +82,109 @@ describe("withAgentToolPackagePin", () => { }); }); }); + +// CL-7389: a `create_agent`/`POST /agent-definitions` call pinning several +// tool packages by name shares one registry resolver across all of them +// (`createPinnedVersionResolver`), so it costs one ancestor walk and one +// tarball listing, never one per pin. +describe("createAgentDefinitionCore: shared registry resolution across pins", () => { + test("a five-pin create does one tenant lookup, one asset lookup, and one tarball listing", async () => { + const counters = { tenant: 0, asset: 0, list: 0 }; + const db = { + query: { + tenant: { + findFirst: async () => { + counters.tenant++; + return { parentId: null }; + }, + }, + asset: { + findFirst: async () => { + counters.asset++; + return { + id: "ast_corbits_tools", + tenantId: "tnt_1", + kind: "package-registry" as const, + name: "corbits-tools", + }; + }, + }, + workflowDefinition: { + findFirst: async () => ({ + id: "def_1", + tenantId: "tnt_1", + assetId: "ast_1", + name: "scout", + description: null, + currentVersion: "1", + status: "deployed", + createdAt: new Date(), + updatedAt: new Date(), + }), + }, + }, + } as unknown as DB["db"]; + const assetService: AssetService = { + createAsset: () => + Promise.resolve({ + id: "ast_1", + tenantId: "tnt_1", + kind: "workflow" as const, + name: "scout", + displayName: "Scout", + creatorPrincipalId: "prn_1", + createdAt: new Date(), + updatedAt: new Date(), + }), + populateAsset: () => Promise.resolve({ commitSha: "deadbeef" }), + readAssetBlob: () => { + throw new Error("not used"); + }, + listAssetBlobs: () => { + counters.list++; + return Promise.resolve([ + "corbits-a-tools-1.0.0.tgz", + "corbits-b-tools-1.0.0.tgz", + "corbits-c-tools-1.0.0.tgz", + "corbits-d-tools-1.0.0.tgz", + "corbits-e-tools-1.0.0.tgz", + ]); + }, + }; + await createAgentDefinitionCore( + { + db, + assetService, + skillIndex: { resolve: () => Promise.resolve([]) }, + skillsStore: createInMemoryDefinitionSkillsStore(), + deployer: { + deploy: () => + Promise.resolve({ + deploymentId: "dep_1", + definitionAssetId: "ast_1", + status: "deployed" as const, + }), + }, + }, + { + tenantId: "tnt_1", + principalId: "prn_1", + tenantDomain: "acme.example", + handle: "scout", + name: "Scout", + systemPrompt: "You are Scout.", + skills: [], + toolPackagePins: [ + "@corbits/a-tools", + "@corbits/b-tools", + "@corbits/c-tools", + "@corbits/d-tools", + "@corbits/e-tools", + ], + }, + ); + expect(counters.list).toBe(1); + expect(counters.asset).toBe(1); + expect(counters.tenant).toBe(1); + }); +}); diff --git a/packages/agent-directory/src/tool-package-version.test.ts b/packages/agent-directory/src/tool-package-version.test.ts new file mode 100644 index 0000000000..f2c23f7744 --- /dev/null +++ b/packages/agent-directory/src/tool-package-version.test.ts @@ -0,0 +1,194 @@ +// CL-7389: `resolvePinnedVersion` must resolve to a concrete, reproducible +// version — semver-sorted (never string-sorted), matched to the exact +// package name (never a prefix/suffix neighbor), and preferring the +// highest STABLE version over a higher-sorting prerelease. These are +// pure-fake-db unit tests: no real Postgres, since `resolveAssetByName`'s +// ancestor-chain walk only needs `db.query.tenant`/`db.query.asset` to +// answer plausibly (`test/tool-package-version.drizzle.test.ts` covers the +// real ancestor-chain walk against Postgres). +import { expect, test } from "bun:test"; +import type { DB } from "@intx/db"; +import type { AssetService } from "@intx/hub-sessions"; +import { CORBITS_TOOLS_REGISTRY } from "@corbits/tool-registry-publish"; + +import { CapabilityOutOfInventoryError } from "./capability-inventory"; +import { + createPinnedVersionResolver, + resolvePinnedVersion, +} from "./tool-package-version"; + +const TENANT_ID = "tnt_1"; + +const REGISTRY_ASSET = { + id: "ast_corbits_tools", + tenantId: TENANT_ID, + kind: "package-registry" as const, + name: CORBITS_TOOLS_REGISTRY, + displayName: CORBITS_TOOLS_REGISTRY, + creatorPrincipalId: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +function registryDb(counters: { tenant: number; asset: number }): DB["db"] { + return { + query: { + tenant: { + findFirst: async () => { + counters.tenant++; + return { parentId: null }; + }, + }, + asset: { + findFirst: async () => { + counters.asset++; + return REGISTRY_ASSET; + }, + }, + }, + } as unknown as DB["db"]; +} + +function assetServiceWith( + tarballs: string[], + counters: { list: number }, +): AssetService { + return { + createAsset: () => { + throw new Error("not used"); + }, + populateAsset: () => { + throw new Error("not used"); + }, + readAssetBlob: () => { + throw new Error("not used"); + }, + listAssetBlobs: () => { + counters.list++; + return Promise.resolve(tarballs); + }, + }; +} + +test("a stable version wins over a higher-sorting prerelease", async () => { + const c = { tenant: 0, asset: 0, list: 0 }; + const resolved = await resolvePinnedVersion( + { + db: registryDb(c), + assetService: assetServiceWith( + [ + "corbits-memory-tools-1.9.0.tgz", + "corbits-memory-tools-2.0.0-rc.1.tgz", + ], + c, + ), + }, + TENANT_ID, + "@corbits/memory-tools", + ); + expect(resolved.version).toBe("1.9.0"); +}); + +test("a prerelease wins only when the registry carries no stable version", async () => { + const c = { tenant: 0, asset: 0, list: 0 }; + const resolved = await resolvePinnedVersion( + { + db: registryDb(c), + assetService: assetServiceWith( + [ + "corbits-memory-tools-2.0.0-rc.1.tgz", + "corbits-memory-tools-2.0.0-rc.2.tgz", + ], + c, + ), + }, + TENANT_ID, + "@corbits/memory-tools", + ); + expect(resolved.version).toBe("2.0.0-rc.2"); +}); + +test("string sort would pick 1.9.0 over 1.10.0; semver picks 1.10.0", async () => { + const c = { tenant: 0, asset: 0, list: 0 }; + const resolved = await resolvePinnedVersion( + { + db: registryDb(c), + assetService: assetServiceWith( + ["corbits-memory-tools-1.9.0.tgz", "corbits-memory-tools-1.10.0.tgz"], + c, + ), + }, + TENANT_ID, + "@corbits/memory-tools", + ); + expect(resolved.version).toBe("1.10.0"); +}); + +test("a name that is a prefix of another package does not steal its tarballs", async () => { + const c = { tenant: 0, asset: 0, list: 0 }; + await expect( + resolvePinnedVersion( + { + db: registryDb(c), + assetService: assetServiceWith(["corbits-memory-tools-1.0.0.tgz"], c), + }, + TENANT_ID, + "@corbits/memory", + ), + ).rejects.toBeInstanceOf(CapabilityOutOfInventoryError); +}); + +test("a longer package name does not match a shorter package's tarball", async () => { + const c = { tenant: 0, asset: 0, list: 0 }; + await expect( + resolvePinnedVersion( + { + db: registryDb(c), + assetService: assetServiceWith(["corbits-memory-1.0.0.tgz"], c), + }, + TENANT_ID, + "@corbits/memory-tools", + ), + ).rejects.toBeInstanceOf(CapabilityOutOfInventoryError); +}); + +test("package name case must match the tarball filename exactly", async () => { + const c = { tenant: 0, asset: 0, list: 0 }; + await expect( + resolvePinnedVersion( + { + db: registryDb(c), + assetService: assetServiceWith(["corbits-memory-tools-1.0.0.tgz"], c), + }, + TENANT_ID, + "@Corbits/Memory-Tools", + ), + ).rejects.toBeInstanceOf(CapabilityOutOfInventoryError); +}); + +test("createPinnedVersionResolver loads the registry and its listing at most once across several names", async () => { + const c = { tenant: 0, asset: 0, list: 0 }; + const resolve = createPinnedVersionResolver( + { + db: registryDb(c), + assetService: assetServiceWith( + [ + "corbits-a-tools-1.0.0.tgz", + "corbits-b-tools-2.0.0.tgz", + "corbits-c-tools-3.0.0.tgz", + ], + c, + ), + }, + TENANT_ID, + ); + const a = await resolve("@corbits/a-tools"); + const b = await resolve("@corbits/b-tools"); + const cc = await resolve("@corbits/c-tools"); + expect(a.version).toBe("1.0.0"); + expect(b.version).toBe("2.0.0"); + expect(cc.version).toBe("3.0.0"); + expect(c.list).toBe(1); + expect(c.asset).toBe(1); + expect(c.tenant).toBe(1); +}); diff --git a/packages/agent-directory/test/routes.test.ts b/packages/agent-directory/test/routes.test.ts index 909b9660af..f04d59123a 100644 --- a/packages/agent-directory/test/routes.test.ts +++ b/packages/agent-directory/test/routes.test.ts @@ -21,6 +21,7 @@ import { CORBITS_TOOLS_REGISTRY } from "@corbits/tool-registry-publish"; import { buildAgentDefinitionWorkflow, serializeAgentDefinitionWorkflow, + withAgentToolPackagePin, } from "../src/agent-workflow"; import { agentDefinitionSourceTree, @@ -1064,6 +1065,70 @@ test("PUT /:definitionId writes the new system prompt in a single source-tree co }); }); +// CL-7389: PUT /:definitionId only rewrites the name and system prompt — +// it must never re-touch tool-package pins, so a tarball that lands in +// the registry after this definition deployed never silently moves an +// already-deployed specialist's stored pin. +test("PUT instructions after a newer tarball lands keeps the stored pin version", async () => { + const pinned = withAgentToolPackagePin( + serializeAgentDefinitionWorkflow( + buildAgentDefinitionWorkflow({ + handle: "research-buddy", + tenantDomain: TENANT.domain, + description: "", + systemPrompt: "You are a careful research assistant.", + }), + ), + { name: "@corbits/memory-tools", version: "1.4.0" }, + ); + const tree = agentDefinitionSourceTree({ + handle: "research-buddy", + workflowJson: pinned, + }); + let current = new TextEncoder().encode(tree[AGENT_DEFINITION_ENTRY_PATH]); + let writtenEntry: string | undefined; + let listCalls = 0; + const app = buildApp( + fakeAssetService({ + readAssetBlob: async () => current, + populateAsset: (params) => { + const entry = params.tree.files[AGENT_DEFINITION_ENTRY_PATH]; + if (typeof entry !== "string") { + throw new Error("populateAsset wrote no entry module"); + } + writtenEntry = entry; + current = new TextEncoder().encode(entry); + return Promise.resolve({ commitSha: "deadbeef" }); + }, + // A newer tarball has landed since this definition deployed. The + // PUT below must never look at it. + listAssetBlobs: () => { + listCalls++; + return Promise.resolve([ + "corbits-memory-tools-1.4.0.tgz", + "corbits-memory-tools-1.5.0.tgz", + ]); + }, + }), + fakeInstructionsDb({ + id: "def_1", + assetId: "ast_1", + name: "research-buddy", + }), + ); + const response = await put(app, "/def_1", { + name: "Research Buddy", + systemPrompt: "You are now a blunt, no-nonsense researcher.", + }); + expect(response.status).toBe(200); + expect(listCalls).toBe(0); + expect( + pinsFrom( + definitionFrom({ [AGENT_DEFINITION_ENTRY_PATH]: writtenEntry ?? "" }), + ), + ).toEqual([{ name: "@corbits/memory-tools", version: "1.4.0" }]); +}); + test("PUT /:definitionId 404s for an unknown definition", async () => { const app = buildApp(fakeAssetService(), fakeInstructionsDb(undefined)); const response = await put(app, "/def_missing", { @@ -1716,6 +1781,77 @@ test("adding a tool package pin merges it into the definition in one commit, nam ]); }); +test("re-adding an already-pinned tool package keeps its stored version after a newer tarball lands", async () => { + let writtenFiles: Record | undefined; + let writtenMessage: string | undefined; + const app = buildApp( + fakeAssetService({ + readAssetBlob: readAssetBlobFor( + new TextEncoder().encode( + agentDefinitionSourceTree({ + handle: "research-buddy", + workflowJson: withAgentToolPackagePin( + serializeAgentDefinitionWorkflow( + buildAgentDefinitionWorkflow({ + handle: "research-buddy", + tenantDomain: TENANT.domain, + description: "", + systemPrompt: "You are a careful research assistant.", + }), + ), + { name: "@corbits/memory-tools", version: "1.4.0" }, + ), + })[AGENT_DEFINITION_ENTRY_PATH] as string, + ), + ), + populateAsset: (params) => { + writtenFiles = params.tree.files; + writtenMessage = params.tree.message; + return Promise.resolve({ commitSha: "deadbeef" }); + }, + // A newer tarball has landed since the pin was first added — this + // re-add must not bump onto it. + listAssetBlobs: () => + Promise.resolve([ + "corbits-memory-tools-1.4.0.tgz", + "corbits-memory-tools-1.5.0.tgz", + ]), + }), + withToolPackageRegistryQueries( + fakeInstructionsDb({ + id: "def_1", + assetId: "ast_1", + name: "research-buddy", + }), + ), + allowAllRequireGrant, + fakeHistory(), + { + resolve: () => + Promise.resolve({ + toolPackages: [{ name: "@corbits/memory-tools" }], + skills: [], + models: [], + }), + }, + ); + const response = await postTo(app, "/def_1/capabilities", { + kind: "toolPackage", + name: "@corbits/memory-tools", + }); + expect(response.status).toBe(200); + expect(pinsFrom(definitionFrom(writtenFiles))).toEqual([ + { name: "@corbits/memory-tools", version: "1.4.0" }, + ]); + expect(writtenMessage).not.toContain("Add"); + const body = (await response.json()) as { + toolPackagePins: { name: string; version: string }[]; + }; + expect(body.toolPackagePins).toEqual([ + { name: "@corbits/memory-tools", version: "1.4.0" }, + ]); +}); + test("adding a tool package pin the tenant's inventory doesn't offer is a 400, never written", async () => { let populateCalled = false; const app = buildApp( From ca703f68b66f0c4b0eafc1d8cdd3f0cb096915df Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 18:21:06 -0700 Subject: [PATCH 6/7] Fix versioned-pin critique findings (CL-7389) - resolvePinnedVersion now prefers the highest STABLE published version; a prerelease only wins when the registry carries no stable version for the package at all. - NonWildcardToolPackagePin now requires an exact semver.valid version, rejecting any range or tag ("*", "latest", "^1", "~1.2", ">=1.0.0", "1.x", empty) instead of just the literal "*". - Guided capability-add re-adding a package already pinned on the definition keeps its existing stored version and skips registry resolution entirely, so a newer tarball landing in the registry never silently bumps an existing pin; the commit message no longer claims an add in that case. Resolution still happens for a genuinely new pin. - createAgentDefinitionCore and guided capability-add now share one createPinnedVersionResolver per call, so a multi-pin create pays for one registry-asset lookup and one tarball listing total, not one per pin. --- .../agent-directory/src/agent-workflow.ts | 21 ++-- .../agent-directory/src/capability-add.ts | 29 +++-- .../src/tool-package-version.ts | 113 ++++++++++++++---- 3 files changed, 123 insertions(+), 40 deletions(-) diff --git a/packages/agent-directory/src/agent-workflow.ts b/packages/agent-directory/src/agent-workflow.ts index 506eac480a..197e838dab 100644 --- a/packages/agent-directory/src/agent-workflow.ts +++ b/packages/agent-directory/src/agent-workflow.ts @@ -26,13 +26,14 @@ import { type PinnedSkillIndexEntry, } from "@corbits/skills"; import { type } from "arktype"; +import semver from "semver"; import { writeAndDeployAgentDefinition, type AgentDefinitionDeployer, } from "./definition-asset"; import type { DefinitionSkillsStore } from "./skills-store"; -import { resolvePinnedVersion } from "./tool-package-version"; +import { createPinnedVersionResolver } from "./tool-package-version"; export const AGENT_DEFINITION_STEP_ID = "agent"; @@ -217,10 +218,10 @@ export const NonWildcardToolPackagePin = type({ name: "string", version: "string", }).narrow((pin, ctx) => - pin.version !== "*" + semver.valid(pin.version) !== null ? true : ctx.mustBe( - 'a concrete published version, never "*" — a wildcard pin would let a later tarball silently change what this pin resolves to (CL-7389)', + 'a concrete published version, never "*", "latest", or a range/tag like "^1", "~1.2", ">=1.0.0", "1.x" — anything but an exact version would let a later tarball silently change what this pin resolves to (CL-7389)', ), ); export type NonWildcardToolPackagePin = typeof NonWildcardToolPackagePin.infer; @@ -536,12 +537,16 @@ export async function createAgentDefinitionCore( input.skills, ), ); + // One resolver shared across every named pin: it loads the tenant's + // registry asset and tarball listing at most once, so a five-pin + // create still costs one ancestor walk and one listing, not five + // (CL-7389). + const resolvePin = createPinnedVersionResolver( + { db: deps.db, assetService: deps.assetService }, + input.tenantId, + ); for (const name of input.toolPackagePins ?? []) { - const resolvedPin = await resolvePinnedVersion( - { db: deps.db, assetService: deps.assetService }, - input.tenantId, - name, - ); + const resolvedPin = await resolvePin(name); workflowJson = withAgentToolPackagePin(workflowJson, resolvedPin); } diff --git a/packages/agent-directory/src/capability-add.ts b/packages/agent-directory/src/capability-add.ts index 9d7d01ff39..3ecf06f90a 100644 --- a/packages/agent-directory/src/capability-add.ts +++ b/packages/agent-directory/src/capability-add.ts @@ -112,13 +112,28 @@ async function prepareCapabilityAdd( switch (args.body.kind) { case "toolPackage": { - const resolvedPin = await resolvePinnedVersion( - { db: args.db, assetService: args.assetService }, - args.tenantId, - args.body.name, - ); - nextWorkflowJson = withAgentToolPackagePin(workflowJson, resolvedPin); - message = `Add ${args.body.name} to ${args.handle}`; + // A package already pinned keeps its stored version: re-adding it + // (e.g. a person re-clicking "add" on something already listed) is + // a no-op on the version, never a silent bump to whatever the + // registry's newest tarball happens to be today. Only a name with + // no existing pin resolves fresh against the registry. An explicit + // bump is a distinct, explicit input this does not add (CL-7389). + const packageName = args.body.name; + const existingPin = readAgentCapabilities( + workflowJson, + ).toolPackagePins.find((pin) => pin.name === packageName); + const pin = + existingPin ?? + (await resolvePinnedVersion( + { db: args.db, assetService: args.assetService }, + args.tenantId, + packageName, + )); + nextWorkflowJson = withAgentToolPackagePin(workflowJson, pin); + message = + existingPin !== undefined + ? `${args.handle} already pins ${packageName} at ${existingPin.version}` + : `Add ${args.body.name} to ${args.handle}`; break; } case "skill": { diff --git a/packages/agent-directory/src/tool-package-version.ts b/packages/agent-directory/src/tool-package-version.ts index 18c6037536..3e66e34bf6 100644 --- a/packages/agent-directory/src/tool-package-version.ts +++ b/packages/agent-directory/src/tool-package-version.ts @@ -59,11 +59,92 @@ function versionFromTarballFilename( return semver.valid(version) !== null ? version : null; } +/** The highest version in `versions`, preferring a stable (non-prerelease) + * version whenever one exists — a prerelease only wins when the registry + * carries no stable version for the package at all. Without this, a + * `2.0.0-rc.1` tarball would outrank an already-published `1.9.0` purely + * because prerelease identifiers still sort after a lower release under + * plain semver comparison for a higher major/minor/patch, silently + * pinning a pre-release build a person never asked for. */ +function highestPreferringStable( + versions: readonly string[], +): string | undefined { + const stable = versions.filter( + (version) => semver.prerelease(version) === null, + ); + const candidates = stable.length > 0 ? stable : versions; + return candidates.slice().sort(semver.compare).at(-1); +} + +/** + * Resolves `packageName` against the tenant's (possibly inherited) + * `corbits-tools` registry against the given, already-loaded tarball + * `filenames` listing (see `createPinnedVersionResolver` for the case + * where the caller wants that listing loaded once and reused across + * several package names). + */ +function resolveFromFilenames( + filenames: readonly string[], + packageName: string, +): ResolvedToolPackagePin { + const versions = filenames + .map((filename) => versionFromTarballFilename(filename, packageName)) + .filter((version): version is string => version !== null); + const highest = highestPreferringStable(versions); + if (highest === undefined) { + throw new CapabilityOutOfInventoryError("toolPackage", packageName); + } + return { name: packageName, version: highest }; +} + +/** + * Builds a resolver against the tenant's (possibly inherited) + * `corbits-tools` registry that loads the registry asset and its tarball + * listing at most once — the first `resolve` call pays for the ancestor + * walk and the listing round trip, every subsequent call against the + * same resolver reuses that listing. `resolvePinnedVersion` below is the + * one-name convenience wrapper; a caller resolving several names in one + * request (e.g. `create_agent`'s own `toolPackagePins`) should build one + * resolver and call it once per name instead, so a five-pin create still + * costs one ancestor walk and one listing, not five (CL-7389). + */ +export function createPinnedVersionResolver( + deps: ResolvePinnedVersionDeps, + tenantId: string, +): (packageName: string) => Promise { + let filenamesPromise: Promise | undefined; + const loadFilenames = (): Promise => { + filenamesPromise ??= (async () => { + const registryAsset = await resolveAssetByName( + deps.db, + tenantId, + "package-registry", + CORBITS_TOOLS_REGISTRY, + ); + if (registryAsset === null) return null; + return deps.assetService.listAssetBlobs({ + assetId: registryAsset.id, + dir: TARBALLS_DIR, + }); + })(); + return filenamesPromise; + }; + + return async (packageName: string) => { + const filenames = await loadFilenames(); + if (filenames === null) { + throw new CapabilityOutOfInventoryError("toolPackage", packageName); + } + return resolveFromFilenames(filenames, packageName); + }; +} + /** * Resolves `packageName` against the tenant's (possibly inherited) * `corbits-tools` registry to `{ name, version }`, `version` always the - * highest published semver among the registry's tarballs for that - * package — never `*`, so a runtime pin is reproducible: a later + * highest published STABLE semver among the registry's tarballs for that + * package (a prerelease wins only when the registry carries no stable + * version at all) — never `*`, so a runtime pin is reproducible: a later * tarball landing in the registry changes what a *new* pin resolves to, * never what an already-deployed definition's stored pin resolves to. * @@ -74,33 +155,15 @@ function versionFromTarballFilename( * covers this case with no new wiring — when the tenant has no visible * `corbits-tools` registry, or that registry carries no tarball for * `packageName`. + * + * Resolving several names for one tenant in the same request? Build a + * `createPinnedVersionResolver` once instead — this wrapper always pays + * for its own ancestor walk and listing round trip. */ export async function resolvePinnedVersion( deps: ResolvePinnedVersionDeps, tenantId: string, packageName: string, ): Promise { - const registryAsset = await resolveAssetByName( - deps.db, - tenantId, - "package-registry", - CORBITS_TOOLS_REGISTRY, - ); - if (registryAsset === null) { - throw new CapabilityOutOfInventoryError("toolPackage", packageName); - } - - const filenames = await deps.assetService.listAssetBlobs({ - assetId: registryAsset.id, - dir: TARBALLS_DIR, - }); - const versions = filenames - .map((filename) => versionFromTarballFilename(filename, packageName)) - .filter((version): version is string => version !== null); - const highest = versions.sort(semver.compare).at(-1); - if (highest === undefined) { - throw new CapabilityOutOfInventoryError("toolPackage", packageName); - } - - return { name: packageName, version: highest }; + return createPinnedVersionResolver(deps, tenantId)(packageName); } From ac40f16c20b435a756639d175e05545241d9d227 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 4 Sep 2026 18:21:10 -0700 Subject: [PATCH 7/7] Update docs: versioned-pin redeploy and re-add semantics (CL-7389) Documents the exact-version narrowing, the stable-over-prerelease preference, the shared per-call registry resolver, and that both a plain redeploy and a re-add of an already-pinned package leave the stored pin version untouched. --- packages/agent-directory/README.md | 46 +++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/packages/agent-directory/README.md b/packages/agent-directory/README.md index 924994759a..42af79deb6 100644 --- a/packages/agent-directory/README.md +++ b/packages/agent-directory/README.md @@ -12,21 +12,39 @@ and the hub routes that create a definition and manage its attached skills Every pin this package writes onto a definition (`create_agent`'s `toolPackagePins`, guided capability-add's `POST /:definitionId/capabilities`) resolves to a concrete, published version — never the npm "any version" -range `*`. `resolvePinnedVersion` (`src/tool-package-version.ts`) resolves a -bare package name to `{ name, version }` by reading the tenant's (possibly +range `*`, and never a range or tag like `latest`, `^1`, `~1.2`, `>=1.0.0`, +or `1.x`; `NonWildcardToolPackagePin` (`src/agent-workflow.ts`) requires +`semver.valid`, an exact version. `resolvePinnedVersion` (and +`createPinnedVersionResolver`, `src/tool-package-version.ts`) resolves a bare +package name to `{ name, version }` by reading the tenant's (possibly inherited) `corbits-tools` `package-registry` asset and picking the highest -version among its tarballs; it throws the same `CapabilityOutOfInventoryError` -guided capability-add's inventory check throws when the registry or the -package is absent, so every caller's existing 4xx mapping covers it with no -new wiring. `withAgentToolPackagePin` (`src/agent-workflow.ts`) rejects a -`"*"` version outright as a second line of defense. - -This matters because a `*` pin would let a later tarball landing in the -registry silently change what an already-deployed specialist runs, with no -record of the change (CL-7389). Resolving a version only happens when a -pin is newly added or a definition is newly created with named pins — a -plain redeploy of an existing definition never re-resolves; it keeps -whatever version its existing pins already carry. +version among its tarballs — preferring a stable version over a +higher-sorting prerelease; a prerelease only wins when the registry carries +no stable version for that package at all. Resolution throws the same +`CapabilityOutOfInventoryError` guided capability-add's inventory check +throws when the registry or the package is absent, so every caller's +existing 4xx mapping covers it with no new wiring. `create_agent`'s own +multi-pin create shares one `createPinnedVersionResolver` across every named +pin, so a five-pin create still costs one registry lookup and one tarball +listing, not five. + +This matters because a `*` (or any non-exact) pin would let a later tarball +landing in the registry silently change what an already-deployed specialist +runs, with no record of the change (CL-7389). Resolving a version only +happens when a pin is newly added or a definition is newly created with +named pins: + +- A plain redeploy of an existing definition (`PUT /:definitionId`) never + touches tool-package pins at all, let alone re-resolves one — it keeps + whatever version its existing pins already carry, even after a newer + tarball lands in the registry. +- Guided capability-add re-adding a package **already pinned** on the + definition also keeps its existing stored version rather than + re-resolving — re-adding is a no-op on the version, never a silent bump + to whatever the registry's newest tarball happens to be today. Only a + name with no existing pin resolves fresh against the registry. Bumping an + existing pin to a newer published version is a distinct, explicit action + this package does not yet expose. ## `/client` subpath contract