Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 13 additions & 15 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
createSidecarAllocationStore,
createSignalCorrelationStore,
createWorkflowRunDispatchStore,
listAssetsForTenant,
listVisibleOfferings,
resolveCredentialByName,
resolveCredentialRequirement,
Expand Down Expand Up @@ -360,7 +361,6 @@ import { type Context, Hono, type Next } from "hono";
import { upgradeWebSocket, websocket } from "hono/bun";
import {
CORBITS_TOOLS_REGISTRY,
describeCorbitsToolPackages,
publishCorbitsToolsRegistry,
} from "@corbits/tool-registry-publish";
import {
Expand Down Expand Up @@ -834,20 +834,6 @@ export async function createHub(config: HubConfig) {
},
};
const hubPublicKey = hexEncode(signingKey.publicKey);
// CL-6149: a launch's pinned tool packages (`toolPackagePins`) carry
// no grants of their own — the deploy-time capability walk
// (`vendor/intx/workflow-deploy/src/capability-walk.ts`) only derives
// `tool:` grants for inline tool factories, so a pinned package's
// tools failed every call closed with "No matching grants". Every
// `@corbits/*-tools` package's namespaced tool ids and approval marks
// are read once here (`describeCorbitsToolPackages`), so
// `toolGrantsForPins` — the port `createHubChatPlatform`'s
// `CreateHubChatPlatformDeps` is built with — can synchronously turn a
// launch's pins into the `tool:<qualifiedId>` grants minted against
// the run's own principal.
const toolGrantsForPins = createToolGrantsForPins(
await describeCorbitsToolPackages(),
);
// Same owning check GET /connections uses — see
// `@corbits/connections`' `workflow-connection-routes.ts` and the
// `createWorkflowConnectionRoutes` wiring below. Not
Expand Down Expand Up @@ -1064,6 +1050,18 @@ export async function createHub(config: HubConfig) {
assetService,
repoStore: agentRepoStore.repoStore,
});
// CL-6149: a launch's pinned tool packages (`toolPackagePins`) carry
// no grants of their own — the deploy-time capability walk
// (`vendor/intx/workflow-deploy/src/capability-walk.ts`) only derives
// `tool:` grants for inline tool factories. `toolGrantsForPins` — the
// port `createHubChatPlatform`'s `CreateHubChatPlatformDeps` is built
// with — turns a launch's pins into `tool:<qualifiedId>` grants read
// from the tenant-resolved `corbits-tools` asset's packed manifests
// (CL-7582), through the same cached launch-path read seam above.
const toolGrantsForPins = createToolGrantsForPins({
listAssets: (tenantId, kind) => listAssetsForTenant(db, tenantId, kind),
assetService: launchCaches.assetService,
});
const launchAgentRepoStore: AgentRepoStore = {
writeDeployTree: agentRepoStore.writeDeployTree,
createDeployPack: agentRepoStore.createDeployPack,
Expand Down
220 changes: 138 additions & 82 deletions apps/hub/src/tool-grants.test.ts
Original file line number Diff line number Diff line change
@@ -1,109 +1,165 @@
// CL-6149: proves the hub's `toolGrantsForPins` port turns a launch's
// `toolPackagePins` into the exact `tool:<qualifiedId>` grants the
// workflow child's authz gate matches against.
// workflow child's authz gate matches against — derived, since CL-7582,
// from the installed `corbits-tools` asset's packed manifests rather
// than any source import.
import { describe, expect, test } from "bun:test";
import { describeCorbitsToolPackages } from "@corbits/tool-registry-publish";
import type { AssetWithOrigin } from "@intx/db";
import type {
AssetService,
ListAssetBlobsParams,
ReadAssetBlobParams,
} from "@intx/hub-sessions";
import {
packToolPackageTarball,
tarballFilenameFor,
} from "@corbits/tool-registry-publish";
import { createToolGrantsForPins } from "./tool-grants";

const DESCRIPTIONS = [
{
name: "@corbits/memory-tools",
version: "0.0.1",
tools: [
{
qualifiedId: "@corbits/memory-tools/memory:memory_add",
approval: "ask" as const,
},
{
qualifiedId: "@corbits/memory-tools/memory:memory_list",
},
],
},
{
name: "@corbits/connections-tools",
version: "0.0.1",
tools: [
{
qualifiedId: "@corbits/connections-tools/connections:list_connections",
},
],
},
];
async function fakeRegistryAssetService(
tarballs: Awaited<ReturnType<typeof packToolPackageTarball>>[],
): Promise<Pick<AssetService, "listAssetBlobs" | "readAssetBlob">> {
const blobs = new Map(
tarballs.map((tarball) => [
`tarballs/${tarballFilenameFor(tarball.name, tarball.version)}`,
tarball.bytes,
]),
);
return {
listAssetBlobs: (params: ListAssetBlobsParams) =>
Promise.resolve(
[...blobs.keys()].filter((path) => path.startsWith(`${params.dir}/`)),
),
readAssetBlob: (params: ReadAssetBlobParams) => {
const bytes = blobs.get(params.path);
if (bytes === undefined) {
return Promise.reject(new Error(`no ${params.path}`));
}
return Promise.resolve(bytes);
},
};
}

function assetRow(id: string, direct: boolean): AssetWithOrigin {
return {
id,
name: "corbits-tools",
origin: { tenantId: "tenant_root", direct },
} as AssetWithOrigin;
}

const MEMORY_DIR = new URL("../../../packages/memory-tools", import.meta.url)
.pathname;
const MANUS_DIR = new URL("../../../packages/manus-tools", import.meta.url)
.pathname;

async function grantsFor(
assetService: Pick<AssetService, "listAssetBlobs" | "readAssetBlob">,
assets: readonly AssetWithOrigin[],
pins: readonly { name: string; version: string }[],
) {
return createToolGrantsForPins({
listAssets: (tenantId, kind) => {
expect(tenantId).toBe("tenant_child");
expect(kind).toBe("package-registry");
return Promise.resolve(assets);
},
assetService,
})("tenant_child", pins);
}

describe("createToolGrantsForPins", () => {
test("mints tool:<qualifiedId>/invoke for every tool a pinned package declares", () => {
const toolGrantsForPins = createToolGrantsForPins(DESCRIPTIONS);
const grants = toolGrantsForPins([
{ name: "@corbits/memory-tools", version: "^1" },
test("mints tool:<qualifiedId>/invoke for every tool the installed manifest declares", async () => {
const assetService = await fakeRegistryAssetService([
await packToolPackageTarball(MEMORY_DIR),
]);
expect(grants).toEqual([
{
resource: "tool:@corbits/memory-tools/memory:memory_add",
action: "invoke",
effect: "ask",
},
{
resource: "tool:@corbits/memory-tools/memory:memory_list",
action: "invoke",
effect: "allow",
},
const grants = await grantsFor(
assetService,
[assetRow("asset_1", true)],
[{ name: "@corbits/memory-tools", version: "^1" }],
);
expect(grants.map((grant) => grant.resource)).toEqual([
"tool:@corbits/memory-tools/memory:memory_search",
"tool:@corbits/memory-tools/memory:memory_add",
"tool:@corbits/memory-tools/memory:memory_list",
]);
expect(grants.every((grant) => grant.action === "invoke")).toBe(true);
});

test('floors an unmarked tool at allow and a `approval: "ask"` tool at ask', () => {
const toolGrantsForPins = createToolGrantsForPins(DESCRIPTIONS);
const grants = toolGrantsForPins([
{ name: "@corbits/memory-tools", version: "^1" },
test('floors an unmarked tool at allow and a `approval: "ask"` tool at ask', async () => {
const assetService = await fakeRegistryAssetService([
await packToolPackageTarball(MANUS_DIR),
]);
expect(grants.find((g) => g.resource.endsWith("memory_add"))?.effect).toBe(
"ask",
);
expect(grants.find((g) => g.resource.endsWith("memory_list"))?.effect).toBe(
"allow",
const grants = await grantsFor(
assetService,
[assetRow("asset_1", true)],
[{ name: "@corbits/manus-tools", version: "*" }],
);
expect(
grants.find((grant) => grant.resource.endsWith(":webhook_create"))
?.effect,
).toBe("ask");
expect(
grants.find((grant) => grant.resource.endsWith(":create_slides"))?.effect,
).toBe("allow");
expect(
grants.find((grant) => grant.resource.endsWith(":task_list"))?.effect,
).toBe("allow");
});

test("unions grants across every pinned package", () => {
const toolGrantsForPins = createToolGrantsForPins(DESCRIPTIONS);
const grants = toolGrantsForPins([
{ name: "@corbits/memory-tools", version: "^1" },
{ name: "@corbits/connections-tools", version: "^1" },
]);
expect(grants.map((g) => g.resource)).toEqual([
"tool:@corbits/memory-tools/memory:memory_add",
"tool:@corbits/memory-tools/memory:memory_list",
"tool:@corbits/connections-tools/connections:list_connections",
test("unions grants across every pinned package and skips unknown pins", async () => {
const assetService = await fakeRegistryAssetService([
await packToolPackageTarball(MEMORY_DIR),
await packToolPackageTarball(MANUS_DIR),
]);
const grants = await grantsFor(
assetService,
[assetRow("asset_1", true)],
[
{ name: "@corbits/memory-tools", version: "^1" },
{ name: "@corbits/manus-tools", version: "*" },
{ name: "@corbits/unknown-tools", version: "^1" },
],
);
expect(
grants.some((grant) => grant.resource.includes("memory-tools")),
).toBe(true);
expect(grants.some((grant) => grant.resource.includes("manus-tools"))).toBe(
true,
);
expect(grants.some((grant) => grant.resource.includes("unknown"))).toBe(
false,
);
});

test("a pin naming a package the hub does not describe yields no grants, never throws", () => {
const toolGrantsForPins = createToolGrantsForPins(DESCRIPTIONS);
const grants = toolGrantsForPins([
{ name: "@corbits/unknown-tools", version: "^1" },
test("resolves an inherited registry, not only a direct one", async () => {
const assetService = await fakeRegistryAssetService([
await packToolPackageTarball(MEMORY_DIR),
]);
expect(grants).toEqual([]);
const grants = await grantsFor(
assetService,
[assetRow("asset_root", false)],
[{ name: "@corbits/memory-tools", version: "^1" }],
);
expect(grants.length).toBeGreaterThan(0);
});

test("no pins yields no grants", () => {
const toolGrantsForPins = createToolGrantsForPins(DESCRIPTIONS);
expect(toolGrantsForPins([])).toEqual([]);
test("a tenant with no corbits-tools asset yields no grants, never throws", async () => {
const assetService = await fakeRegistryAssetService([]);
const grants = await grantsFor(
assetService,
[],
[{ name: "@corbits/memory-tools", version: "^1" }],
);
expect(grants).toEqual([]);
});

test("assistant pin of webhook_create is ask, not allow", async () => {
const toolGrantsForPins = createToolGrantsForPins(
await describeCorbitsToolPackages(),
);
const grants = toolGrantsForPins([
{ name: "@corbits/manus-tools", version: "*" },
test("no pins yields no grants", async () => {
const assetService = await fakeRegistryAssetService([
await packToolPackageTarball(MEMORY_DIR),
]);
expect(
grants.find((g) => g.resource.endsWith(":webhook_create"))?.effect,
).toBe("ask");
expect(
grants.find((g) => g.resource.endsWith(":create_slides"))?.effect,
).toBe("allow");
expect(grants.find((g) => g.resource.endsWith(":task_list"))?.effect).toBe(
"allow",
);
await grantsFor(assetService, [assetRow("asset_1", true)], []),
).toEqual([]);
});
});
62 changes: 49 additions & 13 deletions apps/hub/src/tool-grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,62 @@
// grants of their own — the deploy-time capability walk
// (`vendor/intx/workflow-deploy/src/capability-walk.ts`) only derives
// `tool:` grants for inline tool factories, so a pinned package's tools
// failed every call closed with "No matching grants". This builds the
// `ToolGrantsForPins` port `createHubChatPlatform`'s
// `CreateHubChatPlatformDeps` is wired with: given a launch's pins, look
// up each pin's package by name
// in the hub's own `describeCorbitsToolPackages()` read and mint one
// `tool:<qualifiedId>` / `invoke` declaration per tool, floored at `ask`
// for a tool the package itself marks `approval: "ask"`.
// failed every call closed with "No matching grants". Given a launch's
// pins, this reads the tenant-resolved `corbits-tools` package-registry
// asset's packed tarballs through `readToolSurfaceManifests` and mints
// one `tool:<qualifiedId>` / `invoke` declaration per tool, floored at
// `ask` for a tool the package itself marks `approval: "ask"` — the
// grants come from the installed asset's own manifest, never from a
// source import.
import type {
PinnedToolGrantDeclaration,
ToolGrantsForPins,
} from "@corbits/chat";
import type { CorbitsToolPackageDescription } from "@corbits/tool-registry-publish";
import {
CORBITS_TOOLS_REGISTRY,
readToolSurfaceManifests,
type ToolSurfaceBlobSource,
} from "@corbits/tool-registry-publish";
import type { AssetWithOrigin } from "@intx/db";
import type { AssetService } from "@intx/hub-sessions";

export type CreateToolGrantsForPinsDeps = {
/**
* Resolves the `package-registry` assets a tenant sees, local first,
* shadows-wins down the ancestor chain. The hub binds
* `@intx/db`'s `listAssetsForTenant` to its own db handle.
*/
listAssets: (
tenantId: string,
kind: string,
) => Promise<readonly AssetWithOrigin[]>;
/** The launch-path asset service — the launch caches' SHA-keyed wrapper. */
assetService: Pick<AssetService, "listAssetBlobs" | "readAssetBlob">;
};

export function createToolGrantsForPins(
descriptions: readonly CorbitsToolPackageDescription[],
deps: CreateToolGrantsForPinsDeps,
): ToolGrantsForPins {
const toolsByPackageName = new Map(
descriptions.map((description) => [description.name, description.tools]),
);
return (pins) => {
return async (tenantId, pins) => {
const assets = await deps.listAssets(tenantId, "package-registry");
const registry = assets.find((row) => row.name === CORBITS_TOOLS_REGISTRY);
if (registry === undefined) return [];

const source: ToolSurfaceBlobSource = {
listBlobs: (dir) =>
deps.assetService.listAssetBlobs({ assetId: registry.id, dir }),
readBlob: (path) =>
deps.assetService.readAssetBlob({ assetId: registry.id, path }),
rootDir: "tarballs",
};
const manifests = await readToolSurfaceManifests(source);
const toolsByPackageName = new Map(
manifests.map((manifest) => [
manifest.name,
manifest.surface.filter((entry) => entry.kind === "tool"),
]),
);

const grants: PinnedToolGrantDeclaration[] = [];
for (const pin of pins) {
const tools = toolsByPackageName.get(pin.name);
Expand Down
8 changes: 6 additions & 2 deletions docs/local-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,12 @@ A workflow that pins a `@corbits/*` tool package (e.g. **assistant** pinning
tenant when onboarding or an explicit `@corbits/seeding` caller asks;
descendants inherit it, and `seedTenant` does not pack. After changing a
tool package's source, bump its version, then publish onto the tenant that
owns the registry — restarting the hub does not republish.
Resolution and the sidecar's materialized store key on
owns the registry — restarting the hub does not republish. The operator
path is `bun run publish-tools` (with `HUB_ADMIN_EMAIL`/`HUB_ADMIN_PASSWORD`
set, and `--tenant <id-or-slug>` when the admin belongs to more than one
tenant): it signs in, resolves the target tenant, and installs the
registry onto that already-existing tenant over the hub's native asset
routes. Resolution and the sidecar's materialized store key on
`name@version`, not on content, so republishing unchanged-version bytes
never reaches a running or freshly-launched agent; `tool-registry-publish`
refuses to overwrite an existing `name@version` with different content for
Expand Down
Loading
Loading