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
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,3 +322,54 @@ This works for both room messages and DMs.
## Stale agent cleanup

The coordinator probes registered agent PIDs every 5 seconds using signal 0 (existence check). Dead agents are marked offline and the status is broadcast to all peers. Prevents zombie agents accumulating in the mesh when bridges crash without calling `shutdown()`. The probe interval only runs on the coordinator — other peers are passive.

## Gateway trust and connection codes

Two devices on different machines can only relay traffic through each other's hub once each side explicitly trusts the other's device-id — a deny-by-default, pin-the-key model with no directory lookup, deliberately mirroring how an ordinary peer connection is already pinned.

```
# Trust a remote device you already know the device-id for
agent_comms({ action: "gateway_trust", device: "1a2b3c..." })

# Stop trusting it
agent_comms({ action: "gateway_untrust", device: "1a2b3c..." })

# List every currently trusted device
agent_comms({ action: "gateway_list_trusted" })
```

The device-id itself normally has to be learned out of band (Slack, email, a phone call) before it can be pasted into `gateway_trust`. A connection code is a single-use, short-lived artifact that makes that hand-off itself verifiable instead of a bare, unauthenticated string:

```
ConnectionCode {
code: nonce # single-use
expiresAt: timestamp # short-lived
deviceId: hex # the device being vouched for
signature?: PgpSignature over (code, expiresAt, deviceId)
}
```

`code`/`expiresAt` are the always-checked half: proof that whoever redeems this was on the other end of this exact exchange, recently. `signature` is optional and answers a different question — this claim was made by whoever holds a specific long-lived PGP key. It's checked only when present, and never required: a device with no PGP identity still generates and redeems a bare code.

```
# Generate a code vouching for this device, unsigned
agent_comms({ action: "gateway_generate_connection_code" })

# ...or signed with a PGP private key you already have (agent-comms never generates or stores PGP key material itself -- you supply it per call, the same way you would to `gpg --sign` directly)
agent_comms({ action: "gateway_generate_connection_code", privateKey: "-----BEGIN PGP PRIVATE KEY BLOCK-----..." })

# Relay the printed code to the counterpart out of band, then redeem it on their side -- a successful redemption trusts the device it vouches for, exactly as if gateway_trust had been called directly
agent_comms({
action: "gateway_redeem_connection_code",
code: "...", expiresAt: "2026-01-01T00:15:00.000Z", device: "1a2b3c...",
})

# Redeeming a signed code needs the signer's public key to verify against -- paste it directly, or supply a fingerprint you already trust and let the redeemer fetch it from keys.openpgp.org
agent_comms({
action: "gateway_redeem_connection_code",
code: "...", expiresAt: "...", device: "1a2b3c...", signature: "...",
fingerprint: "aabbccddeeff00112233445566778899aabbccd",
})
```

The fingerprint is never a trust anchor supplied by the keyserver — it's the thing the redeemer already independently trusts (from a business card, a prior verification, wherever), and the redeemer's own check is that the key actually fetched or pasted verifies to that exact fingerprint, not merely that *some* key was found. Redemption never calls back to whoever generated the code: the whole point is bootstrapping trust before any connection between the two devices exists.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
"@modelcontextprotocol/sdk": "1.30.0",
"cbor2": "2.3.0",
"cc-peer": "1.4.1",
"openpgp": "6.3.1",
"preact": "10.29.7",
"typebox": "1.3.6",
"wire-mesh-core": "1.48.2",
Expand Down
14 changes: 14 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/core/bridge-mesh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*
* createBridgeMeshSync/createBridgeMesh own loadOrCreateIdentity's slot lock on the caller's behalf; createBridgeMeshSyncFromIdentity/createBridgeMeshFromIdentity take an already-loaded identity instead and never touch the lock at all -- the cc-peer front (agent-comms#157) uses these directly, via loadIdentityForFront's lock-free load, to build a mesh identity for a not-yet-live session's slot while leaving that slot's own lock free for its real bridge to acquire normally later.
*
* Passes slot through to MeshStore's own constructor (agent-comms#186) so the gatewayTrust allowlist it builds loads whatever remote device-ids were trusted before the last restart, and persists every subsequent addTrustedGateway/removeTrustedGateway back to that same slot's storage.
* Passes slot through to MeshStore's own constructor (agent-comms#186) so the gatewayTrust allowlist it builds loads whatever remote device-ids were trusted before the last restart, and persists every subsequent addTrustedGateway/removeTrustedGateway back to that same slot's storage. The same slot also backs MeshStore's connectionCodes ledger (agent-comms#188), for the same reason.
*/

import { deviceIdToHex } from "wire-mesh-core/domain/device-id";
Expand Down
44 changes: 43 additions & 1 deletion src/core/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export const MCP_TOOL_PARAMS = z.object({
"gateway_trust",
"gateway_untrust",
"gateway_list_trusted",
"gateway_generate_connection_code",
"gateway_redeem_connection_code",
]),
name: z.string().optional(),
visibility: VisibilityEnum.optional(),
Expand Down Expand Up @@ -98,8 +100,22 @@ export const MCP_TOOL_PARAMS = z.object({
capability: z.string().optional(),
expires: z.number().optional(),
delegationsRemaining: z.number().optional(),
/** A remote gateway's own device-id (hex), for gateway_trust/gateway_untrust. */
/** A remote gateway's own device-id (hex), for gateway_trust/gateway_untrust, and for gateway_redeem_connection_code (the code's own embedded deviceId field). */
device: z.string().optional(),
/** The connection code's own nonce (gateway_redeem_connection_code). */
code: z.string().optional(),
/** ISO-8601 expiry for gateway_generate_connection_code's ttlMs override, or the redeemed code's own expiresAt field for gateway_redeem_connection_code. */
expiresAt: z.string().optional(),
/** ttlMs override for gateway_generate_connection_code; defaults to DEFAULT_CONNECTION_CODE_TTL_MS when omitted. */
ttlMs: z.number().optional(),
/** Armored PGP private key to sign a generated code with (gateway_generate_connection_code), or armored PGP public key to verify a redeemed code's signature against (gateway_redeem_connection_code). Both are supplied per call -- agent-comms never generates, stores, or manages PGP key material itself. */
privateKey: z.string().optional(),
publicKey: z.string().optional(),
/** Passphrase for privateKey, if it is passphrase-protected (gateway_generate_connection_code). */
passphrase: z.string().optional(),
/** 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(),
});

export type ToolParams = z.infer<typeof MCP_TOOL_PARAMS>;
Expand Down Expand Up @@ -391,6 +407,32 @@ export function buildAction(params: Record<string, unknown>): CommsAction {
return { action: "gateway_untrust", device: p.device };
case "gateway_list_trusted":
return { action: "gateway_list_trusted" };
case "gateway_generate_connection_code":
return {
action: "gateway_generate_connection_code",
...(p.ttlMs !== undefined && { ttlMs: p.ttlMs }),
...(p.privateKey !== undefined && { privateKey: p.privateKey }),
...(p.passphrase !== undefined && { passphrase: p.passphrase }),
};
case "gateway_redeem_connection_code":
if (p.code === undefined)
throw new BuildActionError("gateway_redeem_connection_code", "code");
if (p.expiresAt === undefined)
throw new BuildActionError(
"gateway_redeem_connection_code",
"expiresAt",
);
if (p.device === undefined)
throw new BuildActionError("gateway_redeem_connection_code", "device");
return {
action: "gateway_redeem_connection_code",
code: p.code,
expiresAt: p.expiresAt,
device: p.device,
...(p.signature !== undefined && { signature: p.signature }),
...(p.publicKey !== undefined && { publicKey: p.publicKey }),
...(p.fingerprint !== undefined && { fingerprint: p.fingerprint }),
};
default:
return p.action satisfies never;
}
Expand Down
92 changes: 92 additions & 0 deletions src/core/connection-code-tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* CommsTool's gateway_generate_connection_code/gateway_redeem_connection_code handlers (agent-comms#188), extracted out of tool.ts's own class body purely to keep that file under its own line-count budget -- these two handlers have no dependency on CommsTool's other state (agentId/harness/cwd context every other handler takes), so free functions taking the store and action directly are a cleaner shape than private methods would have been anyway.
*/

import type { CommsResult, MeshOnlyFeatures } from "./tool.js";
import { tryMeshAction } from "./tool.js";
import type { CommsAction, ConnectionCode } from "./types.js";

/**
* Generates a fresh connection code vouching for this store's own device-id, optionally PGP-signed with a caller-supplied private key. See ConnectionCodeLedger.generate's own doc comment for what each option means.
*/
export async function handleGatewayGenerateConnectionCode(
store: Readonly<MeshOnlyFeatures>,
action: CommsAction & { action: "gateway_generate_connection_code" },
): Promise<CommsResult> {
if (!store.generateConnectionCode) {
return {
content: "Connection codes are not available on this store.",
isError: true,
};
}
const generateConnectionCode = store.generateConnectionCode.bind(store);
return tryMeshAction("generate connection code", async () => {
const code = await generateConnectionCode({
...(action.ttlMs !== undefined && { ttlMs: action.ttlMs }),
...(action.privateKey !== undefined && {
privateKeyArmored: action.privateKey,
}),
...(action.passphrase !== undefined && {
passphrase: action.passphrase,
}),
});
const signedNote =
code.signature !== undefined ? " (PGP-signed)" : " (unsigned)";
return `Connection code generated${signedNote}, valid until ${code.expiresAt}. Share it with the counterpart out of band; it can be redeemed once:\n${JSON.stringify(code)}`;
});
}

/**
* Validates a candidate connection code (assembled from action's own code/expiresAt/device/signature fields) and, on success, trusts the device-id it vouches for. When the candidate carries a signature but action supplies a fingerprint with no publicKey, fetches the signer's public key from a keyserver first via fetchPgpPublicKeyByFingerprintImpl -- injected rather than imported directly so a test can supply a fake resolver instead of making a real network call.
*/
export async function handleGatewayRedeemConnectionCode(
store: Readonly<MeshOnlyFeatures>,
action: CommsAction & { action: "gateway_redeem_connection_code" },
fetchPgpPublicKeyByFingerprintImpl: (fingerprint: string) => Promise<string>,
): Promise<CommsResult> {
if (!store.redeemConnectionCode) {
return {
content: "Connection codes are not available on this store.",
isError: true,
};
}
const candidate: ConnectionCode = {
code: action.code,
expiresAt: action.expiresAt,
deviceId: action.device,
...(action.signature !== undefined && { signature: action.signature }),
};

let publicKeyArmored = action.publicKey;
if (
candidate.signature !== undefined &&
publicKeyArmored === undefined &&
action.fingerprint !== undefined
) {
try {
publicKeyArmored = await fetchPgpPublicKeyByFingerprintImpl(
action.fingerprint,
);
} catch (err) {
return {
content: `Failed to fetch PGP public key for fingerprint ${action.fingerprint}: ${err instanceof Error ? err.message : String(err)}`,
isError: true,
};
}
}

const redeemConnectionCode = store.redeemConnectionCode.bind(store);
return tryMeshAction("redeem connection code", async () => {
const result = await redeemConnectionCode(candidate, {
...(publicKeyArmored !== undefined && { publicKeyArmored }),
...(action.fingerprint !== undefined && {
expectedFingerprint: action.fingerprint,
}),
});
const fingerprintNote =
result.fingerprint !== undefined
? ` (signature verified, signing key fingerprint ${result.fingerprint})`
: " (no signature; freshness only)";
return `Trusted remote gateway device ${result.deviceId} via connection code${fingerprintNote}.`;
});
}
Loading
Loading