From 5290222f980d1ce79662c3e5f0b70958f34efb31 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 18 Sep 2026 09:42:09 +0100 Subject: [PATCH 1/3] refactor(core): split gateway-trust action handlers out of tool.ts Moves gatewayTrust/gatewayUntrust/gatewayListTrusted into their own module as free functions over a narrow GatewayTrustStore slice, matching the same split-a-collaborator-out convention already used for agent-registry.ts, delivery-engine.ts, and friends. Pure move, no behavioural change: keeps tool.ts under the repo's max-lines cap ahead of extending these three actions with a principal flag. --- src/core/gateway-trust-actions.ts | 63 +++++++++++++++++++++++++++++++ src/core/tool.ts | 63 ++++--------------------------- 2 files changed, 71 insertions(+), 55 deletions(-) create mode 100644 src/core/gateway-trust-actions.ts diff --git a/src/core/gateway-trust-actions.ts b/src/core/gateway-trust-actions.ts new file mode 100644 index 0000000..6537128 --- /dev/null +++ b/src/core/gateway-trust-actions.ts @@ -0,0 +1,63 @@ +/** + * CommsTool's gateway-trust action handlers (agent-comms#156) -- split out of tool.ts purely to keep that file under the repo's max-lines cap, the same reason agent-registry.ts, delivery-engine.ts, and their siblings were split from mesh-store.ts. Free functions over MeshOnlyFeatures's own gateway-trust methods rather than class methods, since CommsTool owns no state of its own for this concern -- every one of these is a pure translation from a CommsAction to a CommsResult against whatever store methods are present. + */ +import type { CommsAction } from "./types.js"; +import type { CommsResult, MeshOnlyFeatures } from "./tool.js"; + +/** The slice of MeshOnlyFeatures the three functions below actually need, named so this file doesn't repeat the same three-method Pick inline at every signature. */ +export type GatewayTrustStore = Pick< + MeshOnlyFeatures, + "addTrustedGateway" | "removeTrustedGateway" | "listTrustedGateways" +>; + +/** Uniform "gateway trust isn't available on this store" result. */ +function gatewayTrustUnavailable(): CommsResult { + return { + content: "Gateway trust is not available on this store.", + isError: true, + }; +} + +/** Trusts a remote device-id: this side will merge its gossiped directory entries, dispatch its relayed requests, and route outbound hub requests to it. */ +export function gatewayTrust( + store: Readonly, + action: CommsAction & { action: "gateway_trust" }, +): CommsResult { + if (!store.addTrustedGateway) return gatewayTrustUnavailable(); + store.addTrustedGateway(action.device); + return { + content: `Trusted remote gateway device ${action.device}.`, + isError: false, + }; +} + +/** Withdraws trust from a previously trusted remote device-id. */ +export function gatewayUntrust( + store: Readonly, + action: CommsAction & { action: "gateway_untrust" }, +): CommsResult { + if (!store.removeTrustedGateway) return gatewayTrustUnavailable(); + store.removeTrustedGateway(action.device); + return { + content: `Untrusted remote gateway device ${action.device}.`, + isError: false, + }; +} + +/** Reports every currently trusted remote device-id. */ +export function gatewayListTrusted( + store: Readonly, +): CommsResult { + if (!store.listTrustedGateways) return gatewayTrustUnavailable(); + const trusted = store.listTrustedGateways(); + if (trusted.length === 0) { + return { + content: "No remote gateway devices are trusted.", + isError: false, + }; + } + return { + content: `Trusted remote gateway devices:\n${trusted.map((device) => ` ${device}`).join("\n")}`, + isError: false, + }; +} diff --git a/src/core/tool.ts b/src/core/tool.ts index b0b0f94..9ba773f 100644 --- a/src/core/tool.ts +++ b/src/core/tool.ts @@ -32,6 +32,11 @@ import { handleGatewayGenerateConnectionCode, handleGatewayRedeemConnectionCode, } from "./connection-code-tool.js"; +import { + gatewayTrust, + gatewayUntrust, + gatewayListTrusted, +} from "./gateway-trust-actions.js"; /** Table column widths for the plain-text listing helpers below, chosen to line up with the existing aligned output. */ const ROOM_TYPE_COLUMN_WIDTH = 7; @@ -255,11 +260,11 @@ export class CommsTool { case "mesh_get_visibility": return this.meshGetVisibility(action); case "gateway_trust": - return this.gatewayTrust(action); + return gatewayTrust(this.store, action); case "gateway_untrust": - return this.gatewayUntrust(action); + return gatewayUntrust(this.store, action); case "gateway_list_trusted": - return this.gatewayListTrusted(); + return gatewayListTrusted(this.store); case "gateway_generate_connection_code": return await handleGatewayGenerateConnectionCode(this.store, action); case "gateway_redeem_connection_code": @@ -678,58 +683,6 @@ export class CommsTool { }; } - private gatewayTrust( - action: CommsAction & { action: "gateway_trust" }, - ): CommsResult { - if (!this.store.addTrustedGateway) { - return { - content: "Gateway trust is not available on this store.", - isError: true, - }; - } - this.store.addTrustedGateway(action.device); - return { - content: `Trusted remote gateway device ${action.device}.`, - isError: false, - }; - } - - private gatewayUntrust( - action: CommsAction & { action: "gateway_untrust" }, - ): CommsResult { - if (!this.store.removeTrustedGateway) { - return { - content: "Gateway trust is not available on this store.", - isError: true, - }; - } - this.store.removeTrustedGateway(action.device); - return { - content: `Untrusted remote gateway device ${action.device}.`, - isError: false, - }; - } - - private gatewayListTrusted(): CommsResult { - if (!this.store.listTrustedGateways) { - return { - content: "Gateway trust is not available on this store.", - isError: true, - }; - } - const trusted = this.store.listTrustedGateways(); - if (trusted.length === 0) { - return { - content: "No remote gateway devices are trusted.", - isError: false, - }; - } - return { - content: `Trusted remote gateway devices:\n${trusted.map((device) => ` ${device}`).join("\n")}`, - isError: false, - }; - } - private async meshConnect( _ctx: Readonly, action: CommsAction & { action: "mesh_connect" }, From 1be4dde6544bde9bf06bfab0f9aa9bc1433ecd6b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 18 Sep 2026 09:44:00 +0100 Subject: [PATCH 2/3] feat(core): accept a principal flag on gateway_trust/gateway_untrust Adds an optional principal field to the gateway_trust/gateway_untrust action schema and MCP tool params, and the matching addTrustedGatewayPrincipal/removeTrustedGatewayPrincipal/listTrustedGatewayPrincipals methods to MeshOnlyFeatures and MeshStore, wrapping GatewayTrust.addPrincipal/removePrincipal/listPrincipals (agent-comms#187). Nothing reads the flag yet; the action handlers are wired up next. --- src/core/bridge.ts | 14 ++++++++++++-- src/core/mesh-store.ts | 15 +++++++++++++++ src/core/tool.ts | 3 +++ src/core/types.ts | 4 ++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/core/bridge.ts b/src/core/bridge.ts index 92ec7d1..5580f87 100644 --- a/src/core/bridge.ts +++ b/src/core/bridge.ts @@ -116,6 +116,8 @@ export const MCP_TOOL_PARAMS = z.object({ /** The redeemed code's own signature field (gateway_redeem_connection_code), or a PGP fingerprint the caller already independently trusts, used either to pin a supplied publicKey or to fetch one from a keyserver when publicKey is omitted. */ signature: z.string().optional(), fingerprint: z.string().optional(), + /** For gateway_trust/gateway_untrust: when true, `device` names a user principal (agent-comms#187) rather than a bare remote device-id (agent-comms#193). */ + principal: z.boolean().optional(), }); export type ToolParams = z.infer; @@ -400,11 +402,19 @@ export function buildAction(params: Record): CommsAction { case "gateway_trust": if (p.device === undefined) throw new BuildActionError("gateway_trust", "device"); - return { action: "gateway_trust", device: p.device }; + return { + action: "gateway_trust", + device: p.device, + ...(p.principal !== undefined && { principal: p.principal }), + }; case "gateway_untrust": if (p.device === undefined) throw new BuildActionError("gateway_untrust", "device"); - return { action: "gateway_untrust", device: p.device }; + return { + action: "gateway_untrust", + device: p.device, + ...(p.principal !== undefined && { principal: p.principal }), + }; case "gateway_list_trusted": return { action: "gateway_list_trusted" }; case "gateway_generate_connection_code": diff --git a/src/core/mesh-store.ts b/src/core/mesh-store.ts index 9ccbc60..6ca4e91 100644 --- a/src/core/mesh-store.ts +++ b/src/core/mesh-store.ts @@ -842,6 +842,21 @@ export class MeshStore implements CommsStore { return this.gatewayTrust.list(); } + /** Trusts a remote user-principal device-id (hex, agent-comms#187): a peer presenting a token whose delegation chain roots at this principal is trusted via GatewayTrust.isTrustedFor, without its own bare device-id ever needing individual trust. Entirely independent of the bare-device allowlist addTrustedGateway manages. */ + addTrustedGatewayPrincipal(deviceHex: string): void { + this.gatewayTrust.addPrincipal(deviceHex); + } + + /** Withdraws trust from a remote user-principal device-id (hex). A no-op if it was never trusted. */ + removeTrustedGatewayPrincipal(deviceHex: string): void { + this.gatewayTrust.removePrincipal(deviceHex); + } + + /** Every currently trusted remote user-principal device-id (hex). */ + listTrustedGatewayPrincipals(): string[] { + return this.gatewayTrust.listPrincipals(); + } + // ----------------------------------------------------------------------- // Connection codes (agent-comms#188) -- bootstrapping gateway trust with no existing mesh connection between the two devices // ----------------------------------------------------------------------- diff --git a/src/core/tool.ts b/src/core/tool.ts index 9ba773f..5ddf2bb 100644 --- a/src/core/tool.ts +++ b/src/core/tool.ts @@ -129,6 +129,9 @@ export interface MeshOnlyFeatures { candidate: Readonly, options: Readonly, ) => Promise; + addTrustedGatewayPrincipal?: (deviceHex: string) => void; + removeTrustedGatewayPrincipal?: (deviceHex: string) => void; + listTrustedGatewayPrincipals?: () => string[]; } /** Uniform "this bridge isn't backed by a mesh transport" result for a MeshOnlyFeatures method that isn't present on the current store. */ diff --git a/src/core/types.ts b/src/core/types.ts index 1c301a7..5b0eced 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -444,10 +444,14 @@ export const CommsActionSchema = defineSchema( z.object({ action: z.literal("gateway_trust"), device: z.string(), + /** When true, `device` is trusted as a user principal (GatewayTrust.addPrincipal, agent-comms#187) rather than a bare remote device-id (agent-comms#193). Omitted or false keeps the original bare-device behaviour. */ + principal: z.boolean().optional(), }), z.object({ action: z.literal("gateway_untrust"), device: z.string(), + /** When true, `device` is withdrawn from the principal allowlist (GatewayTrust.removePrincipal, agent-comms#187) rather than the bare-device one (agent-comms#193). Omitted or false keeps the original bare-device behaviour. */ + principal: z.boolean().optional(), }), z.object({ action: z.literal("gateway_list_trusted") }), z.object({ From 7406c3eebbda83a76adfc6f2cd8881fcdd8f0b33 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 18 Sep 2026 09:44:12 +0100 Subject: [PATCH 3/3] feat(core): route gateway_trust/gateway_untrust to principal trust gatewayTrust/gatewayUntrust now check action.principal: when true, they call the principal-keyed store methods (addTrustedGatewayPrincipal/ removeTrustedGatewayPrincipal) instead of the bare-device ones, so the MCP tool layer can finally reach GatewayTrust's principal allowlist (agent-comms#187) alongside the original bare-device path. gatewayListTrusted now reports both the trusted devices and the trusted principals together, since listing isn't mutually exclusive the way trusting/untrusting is. Closes #193 --- src/core/gateway-trust-actions.ts | 55 ++++++++++--- src/test/gateway-trust-tool.test.ts | 123 +++++++++++++++++++++++++++- 2 files changed, 166 insertions(+), 12 deletions(-) diff --git a/src/core/gateway-trust-actions.ts b/src/core/gateway-trust-actions.ts index 6537128..5db10e7 100644 --- a/src/core/gateway-trust-actions.ts +++ b/src/core/gateway-trust-actions.ts @@ -1,16 +1,21 @@ /** - * CommsTool's gateway-trust action handlers (agent-comms#156) -- split out of tool.ts purely to keep that file under the repo's max-lines cap, the same reason agent-registry.ts, delivery-engine.ts, and their siblings were split from mesh-store.ts. Free functions over MeshOnlyFeatures's own gateway-trust methods rather than class methods, since CommsTool owns no state of its own for this concern -- every one of these is a pure translation from a CommsAction to a CommsResult against whatever store methods are present. + * CommsTool's gateway-trust action handlers (agent-comms#156, extended by agent-comms#187's principal-keyed allowlist and agent-comms#193's tool-level `principal` flag) -- split out of tool.ts purely to keep that file under the repo's max-lines cap, the same reason agent-registry.ts, delivery-engine.ts, and their siblings were split from mesh-store.ts. Free functions over MeshOnlyFeatures's own gateway-trust methods rather than class methods, since CommsTool owns no state of its own for this concern -- every one of these is a pure translation from a CommsAction to a CommsResult against whatever store methods are present. */ import type { CommsAction } from "./types.js"; import type { CommsResult, MeshOnlyFeatures } from "./tool.js"; -/** The slice of MeshOnlyFeatures the three functions below actually need, named so this file doesn't repeat the same three-method Pick inline at every signature. */ +/** The slice of MeshOnlyFeatures the three functions below actually need, named so this file doesn't repeat the same six-method Pick inline at every signature. */ export type GatewayTrustStore = Pick< MeshOnlyFeatures, - "addTrustedGateway" | "removeTrustedGateway" | "listTrustedGateways" + | "addTrustedGateway" + | "removeTrustedGateway" + | "listTrustedGateways" + | "addTrustedGatewayPrincipal" + | "removeTrustedGatewayPrincipal" + | "listTrustedGatewayPrincipals" >; -/** Uniform "gateway trust isn't available on this store" result. */ +/** Uniform "gateway trust isn't available on this store" result, mirroring tool.ts's own notMeshBacked helper for the other MeshOnlyFeatures methods. */ function gatewayTrustUnavailable(): CommsResult { return { content: "Gateway trust is not available on this store.", @@ -18,11 +23,19 @@ function gatewayTrustUnavailable(): CommsResult { }; } -/** Trusts a remote device-id: this side will merge its gossiped directory entries, dispatch its relayed requests, and route outbound hub requests to it. */ +/** Trusts action.device: as a user principal (GatewayTrust.addPrincipal, agent-comms#187) when action.principal is true, or as a bare remote device-id (the original agent-comms#156 behaviour) otherwise. */ export function gatewayTrust( store: Readonly, action: CommsAction & { action: "gateway_trust" }, ): CommsResult { + if (action.principal === true) { + if (!store.addTrustedGatewayPrincipal) return gatewayTrustUnavailable(); + store.addTrustedGatewayPrincipal(action.device); + return { + content: `Trusted remote gateway principal ${action.device}.`, + isError: false, + }; + } if (!store.addTrustedGateway) return gatewayTrustUnavailable(); store.addTrustedGateway(action.device); return { @@ -31,11 +44,19 @@ export function gatewayTrust( }; } -/** Withdraws trust from a previously trusted remote device-id. */ +/** Withdraws trust from action.device: from the principal allowlist (GatewayTrust.removePrincipal, agent-comms#187) when action.principal is true, or from the bare-device allowlist (the original agent-comms#156 behaviour) otherwise. */ export function gatewayUntrust( store: Readonly, action: CommsAction & { action: "gateway_untrust" }, ): CommsResult { + if (action.principal === true) { + if (!store.removeTrustedGatewayPrincipal) return gatewayTrustUnavailable(); + store.removeTrustedGatewayPrincipal(action.device); + return { + content: `Untrusted remote gateway principal ${action.device}.`, + isError: false, + }; + } if (!store.removeTrustedGateway) return gatewayTrustUnavailable(); store.removeTrustedGateway(action.device); return { @@ -44,20 +65,32 @@ export function gatewayUntrust( }; } -/** Reports every currently trusted remote device-id. */ +/** Reports every currently trusted remote device-id and every currently trusted principal device-id (agent-comms#187) together -- listing is never mutually exclusive between the two sets, so, unlike gatewayTrust/gatewayUntrust above, this needs no `principal` flag of its own. */ export function gatewayListTrusted( store: Readonly, ): CommsResult { if (!store.listTrustedGateways) return gatewayTrustUnavailable(); - const trusted = store.listTrustedGateways(); - if (trusted.length === 0) { + const devices = store.listTrustedGateways(); + const principals = store.listTrustedGatewayPrincipals?.() ?? []; + if (devices.length === 0 && principals.length === 0) { return { - content: "No remote gateway devices are trusted.", + content: "No remote gateway devices or principals are trusted.", isError: false, }; } + const sections: string[] = []; + if (devices.length > 0) { + sections.push( + `Trusted remote gateway devices:\n${devices.map((device) => ` ${device}`).join("\n")}`, + ); + } + if (principals.length > 0) { + sections.push( + `Trusted remote gateway principals:\n${principals.map((principal) => ` ${principal}`).join("\n")}`, + ); + } return { - content: `Trusted remote gateway devices:\n${trusted.map((device) => ` ${device}`).join("\n")}`, + content: sections.join("\n"), isError: false, }; } diff --git a/src/test/gateway-trust-tool.test.ts b/src/test/gateway-trust-tool.test.ts index 832f1ad..00ccda5 100644 --- a/src/test/gateway-trust-tool.test.ts +++ b/src/test/gateway-trust-tool.test.ts @@ -1,5 +1,5 @@ /** - * CommsTool's gateway-trust actions (agent-comms#156) -- add/remove/list a trusted remote device-id, mirroring visibility.integration.test.ts's own "CommsTool visibility actions" pattern for the sibling mesh-only, MeshOnlyFeatures-gated config surface. + * CommsTool's gateway-trust actions (agent-comms#156) -- add/remove/list a trusted remote device-id, mirroring visibility.integration.test.ts's own "CommsTool visibility actions" pattern for the sibling mesh-only, MeshOnlyFeatures-gated config surface. Also covers the `principal` flag (agent-comms#193) that routes gateway_trust/gateway_untrust to GatewayTrust's principal-keyed allowlist (agent-comms#187) instead of the bare-device one, and gateway_list_trusted's own reporting of both sets together. */ import { test, describe, expect } from "vitest"; import { MeshStore } from "../core/mesh-store.js"; @@ -9,6 +9,7 @@ import { wireTestTransport } from "./test-transport.js"; const TEST_PORT = 0; const DEVICE_HEX = "aabbccdd"; +const PRINCIPAL_HEX = "eeff0011"; describe("CommsTool gateway trust actions", () => { test("gateway_trust adds a device to the allowlist", async () => { @@ -63,6 +64,61 @@ describe("CommsTool gateway trust actions", () => { await store.shutdown(); }); + test("gateway_trust with principal: true adds a principal, not a bare device", async () => { + const store = new MeshStore(TEST_PORT); + await wireTestTransport(store); + await store.init(); + const agent = await store.registerAgent({ + name: "gateway-trust-principal-test", + harness: "test", + cwd: "/test", + pid: process.pid, + visibility: "visible", + tags: [], + }); + const tool = new CommsTool(store); + + const result = await tool.handle( + { agentId: agent.id, harness: "test", cwd: "/test", pid: process.pid }, + { action: "gateway_trust", device: PRINCIPAL_HEX, principal: true }, + ); + + expect(result.isError, result.content).toBe(false); + expect(result.content).toContain(PRINCIPAL_HEX); + expect(store.listTrustedGatewayPrincipals()).toEqual([PRINCIPAL_HEX]); + expect(store.listTrustedGateways()).toEqual([]); + + await store.shutdown(); + }); + + test("gateway_untrust with principal: true removes a principal, not a bare device", async () => { + const store = new MeshStore(TEST_PORT); + await wireTestTransport(store); + await store.init(); + const agent = await store.registerAgent({ + name: "gateway-untrust-principal-test", + harness: "test", + cwd: "/test", + pid: process.pid, + visibility: "visible", + tags: [], + }); + const tool = new CommsTool(store); + store.addTrustedGatewayPrincipal(PRINCIPAL_HEX); + store.addTrustedGateway(DEVICE_HEX); + + const result = await tool.handle( + { agentId: agent.id, harness: "test", cwd: "/test", pid: process.pid }, + { action: "gateway_untrust", device: PRINCIPAL_HEX, principal: true }, + ); + + expect(result.isError, result.content).toBe(false); + expect(store.listTrustedGatewayPrincipals()).toEqual([]); + expect(store.listTrustedGateways()).toEqual([DEVICE_HEX]); + + await store.shutdown(); + }); + test("gateway_list_trusted lists every currently trusted device", async () => { const store = new MeshStore(TEST_PORT); await wireTestTransport(store); @@ -89,6 +145,34 @@ describe("CommsTool gateway trust actions", () => { await store.shutdown(); }); + test("gateway_list_trusted reports both trusted devices and trusted principals", async () => { + const store = new MeshStore(TEST_PORT); + await wireTestTransport(store); + await store.init(); + const agent = await store.registerAgent({ + name: "gateway-list-principal-test", + harness: "test", + cwd: "/test", + pid: process.pid, + visibility: "visible", + tags: [], + }); + const tool = new CommsTool(store); + store.addTrustedGateway(DEVICE_HEX); + store.addTrustedGatewayPrincipal(PRINCIPAL_HEX); + + const result = await tool.handle( + { agentId: agent.id, harness: "test", cwd: "/test", pid: process.pid }, + { action: "gateway_list_trusted" }, + ); + + expect(result.isError, result.content).toBe(false); + expect(result.content).toContain(DEVICE_HEX); + expect(result.content).toContain(PRINCIPAL_HEX); + + await store.shutdown(); + }); + test("gateway_list_trusted reports none trusted by default", async () => { const store = new MeshStore(TEST_PORT); await wireTestTransport(store); @@ -150,4 +234,41 @@ describe("buildAction gateway trust parsing", () => { test("buildAction throws for gateway_untrust without device", () => { expect(() => buildAction({ action: "gateway_untrust" })).toThrow(/device/); }); + + test("buildAction parses gateway_trust with principal: true", () => { + const action = buildAction({ + action: "gateway_trust", + device: PRINCIPAL_HEX, + principal: true, + }); + expect(action.action).toBe("gateway_trust"); + if (action.action === "gateway_trust") { + expect(action.device).toBe(PRINCIPAL_HEX); + expect(action.principal).toBe(true); + } + }); + + test("buildAction parses gateway_untrust with principal: true", () => { + const action = buildAction({ + action: "gateway_untrust", + device: PRINCIPAL_HEX, + principal: true, + }); + expect(action.action).toBe("gateway_untrust"); + if (action.action === "gateway_untrust") { + expect(action.device).toBe(PRINCIPAL_HEX); + expect(action.principal).toBe(true); + } + }); + + test("buildAction omits principal for gateway_trust when not given", () => { + const action = buildAction({ + action: "gateway_trust", + device: DEVICE_HEX, + }); + expect(action.action).toBe("gateway_trust"); + if (action.action === "gateway_trust") { + expect(action.principal).toBeUndefined(); + } + }); });