diff --git a/broker/access-credential.test.ts b/broker/access-credential.test.ts new file mode 100644 index 0000000..512e3c0 --- /dev/null +++ b/broker/access-credential.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import test from "node:test"; +import { ACCESS_CREDENTIAL_ENV, loadRemoteAccessCredential, writeRemoteSessionCredential } from "./access-credential.ts"; + +function withTempFile(content: unknown, run: (path: string) => void): void { + const root = mkdtempSync(join(tmpdir(), "agent-intercom-credential-")); + const path = join(root, "credential.json"); + writeFileSync(path, JSON.stringify(content), { mode: 0o600 }); + try { + run(path); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +test("loads a private one-use enrollment credential from the configured path", () => { + withTempFile({ enrollmentToken: "one-use-secret" }, (path) => { + assert.deepEqual(loadRemoteAccessCredential({ [ACCESS_CREDENTIAL_ENV]: path }), { + path, + access: { enrollmentToken: "one-use-secret" }, + enrollment: true, + }); + }); +}); + +test("atomically replaces enrollment material with assigned reconnect state", () => { + withTempFile({ enrollmentToken: "one-use-secret" }, (path) => { + writeRemoteSessionCredential(path, "assigned-session", { + origin: "remote", + remoteHostId: "ika-dev-v3", + parentSessionId: "local-root", + rootSessionId: "local-root", + generation: 1, + canDelegate: false, + depth: 1, + maxDepth: 1, + maxChildren: 0, + sessionCredential: "reconnect-secret", + }); + assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), { + version: 1, + sessionCredential: "reconnect-secret", + sessionId: "assigned-session", + generation: 1, + }); + assert.equal(statSync(path).mode & 0o777, 0o600); + assert.deepEqual(loadRemoteAccessCredential({ [ACCESS_CREDENTIAL_ENV]: path })?.access, { + sessionCredential: "reconnect-secret", + sessionId: "assigned-session", + generation: 1, + }); + }); +}); + +test("credential parsing fails closed", () => { + withTempFile({ sessionCredential: "secret", sessionId: "id", generation: 1 }, (path) => { + assert.throws(() => loadRemoteAccessCredential({ [ACCESS_CREDENTIAL_ENV]: path }), /Invalid Agent Intercom access credential/); + }); +}); diff --git a/broker/access-credential.ts b/broker/access-credential.ts new file mode 100644 index 0000000..02eec82 --- /dev/null +++ b/broker/access-credential.ts @@ -0,0 +1,76 @@ +import { readFileSync } from "fs"; +import { writeDurableJson } from "../durable-json.ts"; +import type { RemoteAccessMetadata, RemoteRegistrationAccess } from "../types.ts"; + +export const ACCESS_CREDENTIAL_ENV = "AGENT_INTERCOM_ACCESS_CREDENTIAL_PATH"; +export const ACCESS_CREDENTIAL_VERSION = 1; + +export interface EnrollmentCredentialFile { + version?: typeof ACCESS_CREDENTIAL_VERSION; + enrollmentToken: string; +} + +export interface SessionCredentialFile { + version: typeof ACCESS_CREDENTIAL_VERSION; + sessionCredential: string; + sessionId: string; + generation: number; +} + +export interface LoadedRemoteAccessCredential { + path: string; + access: RemoteRegistrationAccess; + enrollment: boolean; +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && !value.includes("\0"); +} + +export function loadRemoteAccessCredential(env: NodeJS.ProcessEnv = process.env): LoadedRemoteAccessCredential | undefined { + const path = env[ACCESS_CREDENTIAL_ENV]?.trim(); + if (!path) return undefined; + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`Invalid Agent Intercom access credential at ${path}`); + } + const credential = parsed as Record; + if (nonEmptyString(credential.enrollmentToken)) { + return { path, access: { enrollmentToken: credential.enrollmentToken }, enrollment: true }; + } + if ( + credential.version === ACCESS_CREDENTIAL_VERSION + && nonEmptyString(credential.sessionCredential) + && nonEmptyString(credential.sessionId) + && typeof credential.generation === "number" + && Number.isSafeInteger(credential.generation) + && credential.generation > 0 + ) { + return { + path, + access: { + sessionCredential: credential.sessionCredential, + sessionId: credential.sessionId, + generation: credential.generation, + }, + enrollment: false, + }; + } + throw new Error(`Invalid Agent Intercom access credential at ${path}`); +} + +export function writeRemoteSessionCredential( + path: string, + sessionId: string, + metadata: RemoteAccessMetadata, +): void { + if (!metadata.sessionCredential) { + throw new Error("Remote enrollment response omitted the session credential"); + } + writeDurableJson(path, { + version: ACCESS_CREDENTIAL_VERSION, + sessionCredential: metadata.sessionCredential, + sessionId, + generation: metadata.generation, + } satisfies SessionCredentialFile); +} diff --git a/broker/access-registry.test.ts b/broker/access-registry.test.ts new file mode 100644 index 0000000..2925d97 --- /dev/null +++ b/broker/access-registry.test.ts @@ -0,0 +1,266 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import test from "node:test"; +import { RemoteAccessError, RemoteAccessRegistry } from "./access-registry.ts"; + +function fixture(now = 1_800_000_000_000) { + const root = mkdtempSync(join(tmpdir(), "agent-intercom-access-")); + let clock = now; + const registry = new RemoteAccessRegistry(join(root, "broker-access.json"), () => clock); + return { + root, + registry, + advance(ms: number) { clock += ms; }, + close() { rmSync(root, { recursive: true, force: true }); }, + }; +} + +const template = { + name: "ika/manager", + parentSessionId: "local-root", + rootSessionId: "local-root", + remoteHostId: "ika-dev-v3", +}; + +test("version-one registry records migrate to non-delegating tree principals", () => { + const root = mkdtempSync(join(tmpdir(), "agent-intercom-access-migrate-")); + const path = join(root, "broker-access.json"); + try { + writeFileSync(path, JSON.stringify({ + version: 1, + principals: { + legacy: { + id: "legacy", + name: "legacy", + credentialHash: "0".repeat(64), + parentSessionId: "root", + rootSessionId: "root", + remoteHostId: "host", + generation: 1, + policy: "remote-parent", + state: "active", + expiresAt: 2_000_000_000_000, + createdAt: 1, + updatedAt: 1, + }, + }, + enrollments: {}, + })); + const principal = new RemoteAccessRegistry(path, () => 1_800_000_000_000).snapshot().principals.legacy; + assert.equal(principal.policy, "remote-tree"); + assert.equal(principal.canDelegate, false); + assert.equal(principal.depth, 1); + assert.equal(principal.maxDepth, 1); + assert.equal(principal.maxChildren, 0); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("admin credential persists privately and authenticates by hash", () => { + const f = fixture(); + try { + const path = join(f.root, "broker-admin.json"); + const token = f.registry.ensureAdminCredential(path); + assert.equal(f.registry.authenticateAdmin(token), true); + assert.equal(f.registry.authenticateAdmin(`${token}x`), false); + assert.equal(statSync(path).mode & 0o777, 0o600); + const state = readFileSync(join(f.root, "broker-access.json"), "utf8"); + assert.equal(state.includes(token), false); + assert.equal(f.registry.ensureAdminCredential(path), token); + } finally { + f.close(); + } +}); + +test("one-use enrollment assigns broker identity and stores only hashes", () => { + const f = fixture(); + try { + const issued = f.registry.issueEnrollment(template); + const consumed = f.registry.consumeEnrollment(issued.enrollmentToken); + assert.match(consumed.principal.id, /^[0-9a-f-]{36}$/); + assert.equal(consumed.principal.name, template.name); + assert.equal(consumed.principal.parentSessionId, template.parentSessionId); + assert.equal(consumed.principal.generation, 1); + assert.equal( + f.registry.authenticateSession(consumed.principal.id, 1, consumed.sessionCredential).id, + consumed.principal.id, + ); + const persisted = readFileSync(join(f.root, "broker-access.json"), "utf8"); + assert.equal(persisted.includes(issued.enrollmentToken), false); + assert.equal(persisted.includes(consumed.sessionCredential), false); + assert.throws( + () => f.registry.consumeEnrollment(issued.enrollmentToken), + (error: unknown) => error instanceof RemoteAccessError && error.code === "INVALID_ENROLLMENT", + ); + } finally { + f.close(); + } +}); + +test("expired enrollment is consumed fail-closed", () => { + const f = fixture(); + try { + const issued = f.registry.issueEnrollment(template, 1000); + f.advance(1001); + assert.throws( + () => f.registry.consumeEnrollment(issued.enrollmentToken), + (error: unknown) => error instanceof RemoteAccessError && error.code === "INVALID_ENROLLMENT", + ); + assert.equal(Object.keys(f.registry.snapshot().enrollments).length, 0); + } finally { + f.close(); + } +}); + +test("reconnect requires the exact assigned identity, credential, and generation", () => { + const f = fixture(); + try { + const issued = f.registry.issueEnrollment(template); + const consumed = f.registry.consumeEnrollment(issued.enrollmentToken); + assert.throws( + () => f.registry.authenticateSession("chosen-id", 1, consumed.sessionCredential), + (error: unknown) => error instanceof RemoteAccessError && error.code === "INVALID_CREDENTIAL", + ); + assert.throws( + () => f.registry.authenticateSession(consumed.principal.id, 1, "wrong"), + (error: unknown) => error instanceof RemoteAccessError && error.code === "INVALID_CREDENTIAL", + ); + assert.throws( + () => f.registry.authenticateSession(consumed.principal.id, 2, consumed.sessionCredential), + (error: unknown) => error instanceof RemoteAccessError && error.code === "STALE_GENERATION", + ); + } finally { + f.close(); + } +}); + +test("delegated child enrollment is broker-computed and cannot widen parent limits", () => { + const f = fixture(); + try { + const parent = f.registry.consumeEnrollment(f.registry.issueEnrollment({ + ...template, + canDelegate: true, + depth: 1, + maxDepth: 3, + maxChildren: 1, + }).enrollmentToken); + const childEnrollment = f.registry.issueChildEnrollment(parent.principal.id, 1, { + name: "ika/lead", + canDelegate: true, + maxDepth: 3, + maxChildren: 1, + }); + assert.throws( + () => f.registry.issueChildEnrollment(parent.principal.id, 1, { name: "ika/sibling" }), + (error: unknown) => error instanceof RemoteAccessError && error.code === "INVALID_ENROLLMENT", + ); + const child = f.registry.consumeEnrollment(childEnrollment.enrollmentToken); + assert.equal(child.principal.parentSessionId, parent.principal.id); + assert.equal(child.principal.rootSessionId, template.rootSessionId); + assert.equal(child.principal.remoteHostId, template.remoteHostId); + assert.equal(child.principal.depth, 2); + assert.equal(child.principal.maxDepth, 3); + assert.equal(child.principal.maxChildren, 1); + assert.equal(child.principal.canDelegate, true); + assert.throws( + () => f.registry.issueChildEnrollment(child.principal.id, 1, { name: "too-wide", maxDepth: 4 }), + /Invalid child maximum depth/, + ); + const grandchild = f.registry.consumeEnrollment(f.registry.issueChildEnrollment(child.principal.id, 1, { + name: "ika/worker", + }).enrollmentToken); + assert.equal(grandchild.principal.depth, 3); + assert.equal(grandchild.principal.canDelegate, false); + assert.equal(grandchild.principal.maxDepth, 3); + const metadata = f.registry.inspectSubtree(parent.principal.id); + assert.deepEqual(metadata.map((principal) => principal.id), [parent.principal.id, child.principal.id, grandchild.principal.id]); + assert.equal(metadata.some((principal) => "credentialHash" in principal), false); + } finally { + f.close(); + } +}); + +test("non-delegating principals cannot issue child enrollment", () => { + const f = fixture(); + try { + const parent = f.registry.consumeEnrollment(f.registry.issueEnrollment(template).enrollmentToken); + assert.throws( + () => f.registry.issueChildEnrollment(parent.principal.id, 1, { name: "child" }), + (error: unknown) => error instanceof RemoteAccessError && error.code === "INVALID_ENROLLMENT", + ); + } finally { + f.close(); + } +}); + +test("adoption rewrites ancestry, fences generations, and cancels pending delegated tokens", () => { + const f = fixture(); + try { + const parent = f.registry.consumeEnrollment(f.registry.issueEnrollment({ + ...template, + canDelegate: true, + maxDepth: 3, + maxChildren: 1, + }).enrollmentToken); + const child = f.registry.consumeEnrollment(f.registry.issueChildEnrollment(parent.principal.id, 1, { + name: "child", + canDelegate: true, + maxDepth: 3, + maxChildren: 1, + }).enrollmentToken); + f.registry.issueChildEnrollment(child.principal.id, 1, { name: "pending-grandchild" }); + assert.throws(() => f.registry.adoptSubtree(parent.principal.id, child.principal.id, "root"), /ownership cycle/); + const changed = f.registry.adoptSubtree(child.principal.id, "other-local-root", "other-local-root"); + assert.deepEqual(changed.map((principal) => principal.id), [child.principal.id]); + assert.equal(changed[0].parentSessionId, "other-local-root"); + assert.equal(changed[0].rootSessionId, "other-local-root"); + assert.equal(changed[0].generation, 2); + assert.equal(Object.keys(f.registry.snapshot().enrollments).length, 0); + } finally { + f.close(); + } +}); + +test("expiry reconciliation recursively generation-fences an expired subtree", () => { + const f = fixture(); + try { + const parent = f.registry.consumeEnrollment(f.registry.issueEnrollment({ + ...template, + expiresAt: 1_800_000_001_000, + canDelegate: true, + maxDepth: 2, + maxChildren: 1, + }).enrollmentToken); + const child = f.registry.consumeEnrollment(f.registry.issueChildEnrollment(parent.principal.id, 1, { name: "child" }).enrollmentToken); + f.advance(1001); + const expired = f.registry.expirePrincipals(); + assert.deepEqual(new Set(expired.map((principal) => principal.id)), new Set([parent.principal.id, child.principal.id])); + assert.equal(expired.every((principal) => principal.state === "revoked" && principal.generation === 2), true); + } finally { + f.close(); + } +}); + +test("recursive revoke increments generations and invalidates reconnect", () => { + const f = fixture(); + try { + const parent = f.registry.consumeEnrollment(f.registry.issueEnrollment(template).enrollmentToken); + const child = f.registry.consumeEnrollment(f.registry.issueEnrollment({ + ...template, + name: "ika/child", + parentSessionId: parent.principal.id, + }).enrollmentToken); + const changed = f.registry.revoke(parent.principal.id); + assert.deepEqual(new Set(changed.map((principal) => principal.id)), new Set([parent.principal.id, child.principal.id])); + assert.equal(changed.every((principal) => principal.state === "revoked" && principal.generation === 2), true); + assert.throws( + () => f.registry.authenticateSession(child.principal.id, 1, child.sessionCredential), + (error: unknown) => error instanceof RemoteAccessError && error.code === "REVOKED_CREDENTIAL", + ); + } finally { + f.close(); + } +}); diff --git a/broker/access-registry.ts b/broker/access-registry.ts new file mode 100644 index 0000000..6093acd --- /dev/null +++ b/broker/access-registry.ts @@ -0,0 +1,460 @@ +import { createHash, randomBytes, randomUUID, timingSafeEqual } from "crypto"; +import { existsSync, readFileSync } from "fs"; +import { writeDurableJson } from "../durable-json.ts"; +import { restrictIntercomRuntimeFile } from "./paths.ts"; + +export const REMOTE_ACCESS_STATE_VERSION = 2; +export const REMOTE_ACCESS_CREDENTIAL_VERSION = 1; +export const DEFAULT_ENROLLMENT_TTL_MS = 10 * 60 * 1000; +export const DEFAULT_PRINCIPAL_TTL_MS = 30 * 24 * 60 * 60 * 1000; + +export type RemotePrincipalState = "active" | "revoked"; +export type RemotePrincipalPolicy = "remote-tree"; + +export interface RemotePrincipalRecord { + id: string; + name: string; + credentialHash: string; + parentSessionId: string; + rootSessionId: string; + remoteHostId: string; + generation: number; + policy: RemotePrincipalPolicy; + canDelegate: boolean; + depth: number; + maxDepth: number; + maxChildren: number; + state: RemotePrincipalState; + expiresAt: number; + createdAt: number; + updatedAt: number; +} + +export interface EnrollmentTemplate { + name: string; + parentSessionId: string; + rootSessionId: string; + remoteHostId: string; + expiresAt?: number; + canDelegate?: boolean; + depth?: number; + maxDepth?: number; + maxChildren?: number; +} + +export interface ChildEnrollmentRequest { + name: string; + expiresAt?: number; + canDelegate?: boolean; + maxDepth?: number; + maxChildren?: number; +} + +export interface EnrollmentRecord { + tokenHash: string; + template: EnrollmentTemplate; + expiresAt: number; + createdAt: number; +} + +export interface RemoteAccessState { + version: typeof REMOTE_ACCESS_STATE_VERSION; + adminCredentialHash?: string; + principals: Record; + enrollments: Record; +} + +export interface IssuedEnrollment { + enrollmentToken: string; + expiresAt: number; +} + +export interface ConsumedEnrollment { + principal: RemotePrincipalRecord; + sessionCredential: string; +} + +export type RemotePrincipalMetadata = Omit; + +export interface PersistedAdminCredential { + version: typeof REMOTE_ACCESS_CREDENTIAL_VERSION; + adminToken: string; +} + +export class RemoteAccessError extends Error { + constructor( + readonly code: "INVALID_CREDENTIAL" | "EXPIRED_CREDENTIAL" | "REVOKED_CREDENTIAL" | "STALE_GENERATION" | "INVALID_ENROLLMENT", + message: string, + ) { + super(message); + this.name = "RemoteAccessError"; + } +} + +function emptyState(): RemoteAccessState { + return { version: REMOTE_ACCESS_STATE_VERSION, principals: {}, enrollments: {} }; +} + +function hashSecret(secret: string): string { + return createHash("sha256").update(secret, "utf8").digest("hex"); +} + +function secretsMatch(secret: string, expectedHash: string): boolean { + const actual = Buffer.from(hashSecret(secret), "hex"); + const expected = Buffer.from(expectedHash, "hex"); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} + +function newSecret(): string { + return randomBytes(32).toString("base64url"); +} + +function requireText(value: string, field: string, maxLength = 512): string { + const normalized = value.trim(); + if (!normalized || normalized.length > maxLength || /[\u0000-\u001f\u007f]/.test(normalized)) { + throw new Error(`Invalid ${field}`); + } + return normalized; +} + +function boundedInteger(value: number | undefined, fallback: number, field: string, minimum: number, maximum: number): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < minimum || resolved > maximum) throw new Error(`Invalid ${field}`); + return resolved; +} + +function parseState(raw: unknown): RemoteAccessState { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("expected object"); + const state = raw as Record; + if (state.version !== 1 && state.version !== REMOTE_ACCESS_STATE_VERSION) throw new Error("unsupported version"); + if (typeof state.principals !== "object" || state.principals === null || Array.isArray(state.principals)) throw new Error("invalid principals"); + if (typeof state.enrollments !== "object" || state.enrollments === null || Array.isArray(state.enrollments)) throw new Error("invalid enrollments"); + if (state.adminCredentialHash !== undefined && typeof state.adminCredentialHash !== "string") throw new Error("invalid admin credential hash"); + const principals: Record = {}; + for (const [id, value] of Object.entries(state.principals as Record)) { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`invalid principal ${id}`); + const principal = value as Record; + if ( + principal.id !== id + || typeof principal.name !== "string" + || typeof principal.credentialHash !== "string" + || typeof principal.parentSessionId !== "string" + || typeof principal.rootSessionId !== "string" + || typeof principal.remoteHostId !== "string" + || typeof principal.generation !== "number" + || (principal.state !== "active" && principal.state !== "revoked") + || typeof principal.expiresAt !== "number" + || typeof principal.createdAt !== "number" + || typeof principal.updatedAt !== "number" + ) throw new Error(`invalid principal ${id}`); + const depth = boundedInteger(principal.depth as number | undefined, 1, "principal depth", 1, 32); + const maxDepth = boundedInteger(principal.maxDepth as number | undefined, depth, "maximum delegation depth", depth, 32); + const maxChildren = boundedInteger(principal.maxChildren as number | undefined, 0, "maximum child count", 0, 128); + principals[id] = { + ...(principal as unknown as RemotePrincipalRecord), + policy: "remote-tree", + canDelegate: principal.canDelegate === true, + depth, + maxDepth, + maxChildren, + }; + } + const enrollments: Record = {}; + for (const [hash, value] of Object.entries(state.enrollments as Record)) { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`invalid enrollment ${hash}`); + const enrollment = value as EnrollmentRecord; + const template = enrollment.template; + if (!template || typeof template !== "object") throw new Error(`invalid enrollment ${hash}`); + const depth = boundedInteger(template.depth, 1, "enrollment depth", 1, 32); + enrollments[hash] = { + ...enrollment, + template: { + ...template, + canDelegate: template.canDelegate === true, + depth, + maxDepth: boundedInteger(template.maxDepth, depth, "enrollment maximum depth", depth, 32), + maxChildren: boundedInteger(template.maxChildren, 0, "enrollment maximum children", 0, 128), + }, + }; + } + return { + version: REMOTE_ACCESS_STATE_VERSION, + ...(typeof state.adminCredentialHash === "string" ? { adminCredentialHash: state.adminCredentialHash } : {}), + principals, + enrollments, + }; +} + +export class RemoteAccessRegistry { + private state: RemoteAccessState; + + constructor(private readonly statePath: string, private readonly now: () => number = Date.now) { + this.state = this.load(); + } + + snapshot(): RemoteAccessState { + return structuredClone(this.state); + } + + ensureAdminCredential(credentialPath: string): string { + if (existsSync(credentialPath)) { + const parsed = JSON.parse(readFileSync(credentialPath, "utf8")) as Partial; + if (parsed.version === REMOTE_ACCESS_CREDENTIAL_VERSION && typeof parsed.adminToken === "string" && parsed.adminToken.length >= 32) { + const hash = hashSecret(parsed.adminToken); + if (this.state.adminCredentialHash !== hash) { + this.state.adminCredentialHash = hash; + this.persist(); + } + restrictIntercomRuntimeFile(credentialPath); + return parsed.adminToken; + } + } + const adminToken = newSecret(); + writeDurableJson(credentialPath, { version: REMOTE_ACCESS_CREDENTIAL_VERSION, adminToken }); + this.state.adminCredentialHash = hashSecret(adminToken); + this.persist(); + return adminToken; + } + + authenticateAdmin(adminToken: string): boolean { + return typeof this.state.adminCredentialHash === "string" && secretsMatch(adminToken, this.state.adminCredentialHash); + } + + issueEnrollment(template: EnrollmentTemplate, ttlMs = DEFAULT_ENROLLMENT_TTL_MS): IssuedEnrollment { + if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > 24 * 60 * 60 * 1000) throw new Error("Invalid enrollment TTL"); + const now = this.now(); + const enrollmentToken = newSecret(); + const tokenHash = hashSecret(enrollmentToken); + const expiresAt = now + ttlMs; + const principalExpiresAt = template.expiresAt ?? now + DEFAULT_PRINCIPAL_TTL_MS; + if (!Number.isSafeInteger(principalExpiresAt) || principalExpiresAt <= now) throw new Error("Invalid principal expiry"); + const depth = boundedInteger(template.depth, 1, "principal depth", 1, 32); + const maxDepth = boundedInteger(template.maxDepth, depth, "maximum delegation depth", depth, 32); + const maxChildren = boundedInteger(template.maxChildren, 0, "maximum child count", 0, 128); + const canDelegate = template.canDelegate === true; + if (canDelegate && (maxDepth <= depth || maxChildren === 0)) throw new Error("Delegating principals require remaining depth and child capacity"); + const normalized: EnrollmentTemplate = { + name: requireText(template.name, "principal name", 256), + parentSessionId: requireText(template.parentSessionId, "parent session ID"), + rootSessionId: requireText(template.rootSessionId, "root session ID"), + remoteHostId: requireText(template.remoteHostId, "remote host ID", 256), + expiresAt: principalExpiresAt, + canDelegate, + depth, + maxDepth, + maxChildren, + }; + this.pruneExpiredEnrollments(now); + this.state.enrollments[tokenHash] = { tokenHash, template: normalized, expiresAt, createdAt: now }; + this.persist(); + return { enrollmentToken, expiresAt }; + } + + issueChildEnrollment( + parentSessionId: string, + parentGeneration: number, + request: ChildEnrollmentRequest, + ttlMs = DEFAULT_ENROLLMENT_TTL_MS, + ): IssuedEnrollment { + const parent = this.validatePrincipal(parentSessionId, parentGeneration); + if (!parent.canDelegate) throw new RemoteAccessError("INVALID_ENROLLMENT", "Parent principal cannot delegate children"); + const now = this.now(); + const activeChildren = Object.values(this.state.principals).filter((principal) => + principal.parentSessionId === parent.id && principal.state === "active" && principal.expiresAt > now + ).length; + const pendingChildren = Object.values(this.state.enrollments).filter((enrollment) => + enrollment.template.parentSessionId === parent.id && enrollment.expiresAt > now + ).length; + if (activeChildren + pendingChildren >= parent.maxChildren) throw new RemoteAccessError("INVALID_ENROLLMENT", "Parent child limit is exhausted"); + const depth = parent.depth + 1; + if (depth > parent.maxDepth) throw new RemoteAccessError("INVALID_ENROLLMENT", "Parent delegation depth is exhausted"); + const maxDepth = boundedInteger(request.maxDepth, depth, "child maximum depth", depth, parent.maxDepth); + const maxChildren = boundedInteger(request.maxChildren, 0, "child maximum count", 0, parent.maxChildren); + const canDelegate = request.canDelegate === true; + if (canDelegate && (maxDepth <= depth || maxChildren === 0)) throw new Error("Delegating child requires remaining depth and child capacity"); + const expiresAt = request.expiresAt ?? parent.expiresAt; + if (!Number.isSafeInteger(expiresAt) || expiresAt <= this.now() || expiresAt > parent.expiresAt) { + throw new Error("Child expiry must not exceed the parent expiry"); + } + return this.issueEnrollment({ + name: request.name, + parentSessionId: parent.id, + rootSessionId: parent.rootSessionId, + remoteHostId: parent.remoteHostId, + expiresAt, + canDelegate, + depth, + maxDepth, + maxChildren, + }, ttlMs); + } + + consumeEnrollment(enrollmentToken: string): ConsumedEnrollment { + const now = this.now(); + const tokenHash = hashSecret(enrollmentToken); + const enrollment = this.state.enrollments[tokenHash]; + if (!enrollment) throw new RemoteAccessError("INVALID_ENROLLMENT", "Enrollment credential is invalid or already consumed"); + delete this.state.enrollments[tokenHash]; + if (enrollment.expiresAt <= now) { + this.persist(); + throw new RemoteAccessError("INVALID_ENROLLMENT", "Enrollment credential has expired"); + } + const sessionCredential = newSecret(); + const id = randomUUID(); + const principal: RemotePrincipalRecord = { + id, + name: enrollment.template.name, + credentialHash: hashSecret(sessionCredential), + parentSessionId: enrollment.template.parentSessionId, + rootSessionId: enrollment.template.rootSessionId, + remoteHostId: enrollment.template.remoteHostId, + generation: 1, + policy: "remote-tree", + canDelegate: enrollment.template.canDelegate === true, + depth: enrollment.template.depth!, + maxDepth: enrollment.template.maxDepth!, + maxChildren: enrollment.template.maxChildren!, + state: "active", + expiresAt: enrollment.template.expiresAt!, + createdAt: now, + updatedAt: now, + }; + this.state.principals[id] = principal; + this.persist(); + return { principal: structuredClone(principal), sessionCredential }; + } + + authenticateSession(sessionId: string, generation: number, sessionCredential: string): RemotePrincipalRecord { + const principal = this.state.principals[sessionId]; + if (!principal || !secretsMatch(sessionCredential, principal.credentialHash)) { + throw new RemoteAccessError("INVALID_CREDENTIAL", "Session credential is invalid"); + } + return this.validatePrincipal(sessionId, generation); + } + + validatePrincipal(sessionId: string, generation: number): RemotePrincipalRecord { + const principal = this.state.principals[sessionId]; + if (!principal) throw new RemoteAccessError("INVALID_CREDENTIAL", "Remote principal does not exist"); + if (principal.state !== "active") throw new RemoteAccessError("REVOKED_CREDENTIAL", "Session credential is revoked"); + if (principal.expiresAt <= this.now()) throw new RemoteAccessError("EXPIRED_CREDENTIAL", "Session credential has expired"); + if (principal.generation !== generation) throw new RemoteAccessError("STALE_GENERATION", "Session credential generation is stale"); + return structuredClone(principal); + } + + inspectSubtree(principalId: string): RemotePrincipalMetadata[] { + const result: RemotePrincipalMetadata[] = []; + const queue = [principalId]; + const seen = new Set(); + while (queue.length) { + const id = queue.shift()!; + if (seen.has(id)) continue; + seen.add(id); + const principal = this.state.principals[id]; + if (!principal) continue; + const { credentialHash: _credentialHash, ...metadata } = principal; + result.push(structuredClone(metadata)); + for (const candidate of Object.values(this.state.principals)) { + if (candidate.parentSessionId === id) queue.push(candidate.id); + } + } + return result; + } + + adoptSubtree(principalId: string, newParentSessionId: string, newRootSessionId: string): RemotePrincipalRecord[] { + const principal = this.state.principals[principalId]; + if (!principal) throw new Error("Unknown adopted principal"); + if (principalId === newParentSessionId) throw new Error("Adoption would create an ownership cycle"); + let ancestor = this.state.principals[newParentSessionId]; + const seen = new Set(); + while (ancestor && !seen.has(ancestor.id)) { + if (ancestor.id === principalId) throw new Error("Adoption would create an ownership cycle"); + seen.add(ancestor.id); + ancestor = this.state.principals[ancestor.parentSessionId]; + } + const ids = this.subtreePrincipalIds(principalId); + const now = this.now(); + principal.parentSessionId = requireText(newParentSessionId, "new parent session ID"); + for (const id of ids) { + const changed = this.state.principals[id]; + changed.rootSessionId = requireText(newRootSessionId, "new root session ID"); + changed.generation += 1; + changed.updatedAt = now; + } + const changedIds = new Set(ids); + for (const [hash, enrollment] of Object.entries(this.state.enrollments)) { + if (changedIds.has(enrollment.template.parentSessionId)) delete this.state.enrollments[hash]; + } + this.persist(); + return ids.map((id) => structuredClone(this.state.principals[id])); + } + + expirePrincipals(now = this.now()): RemotePrincipalRecord[] { + const changed = new Map(); + const expiredRoots = Object.values(this.state.principals) + .filter((principal) => principal.state === "active" && principal.expiresAt <= now) + .sort((left, right) => left.depth - right.depth); + for (const principal of expiredRoots) { + if (changed.has(principal.id)) continue; + for (const revoked of this.revoke(principal.id)) changed.set(revoked.id, revoked); + } + return [...changed.values()]; + } + + revoke(principalId: string): RemotePrincipalRecord[] { + const now = this.now(); + const queue = [principalId]; + const seen = new Set(); + const changed: RemotePrincipalRecord[] = []; + while (queue.length) { + const id = queue.shift()!; + if (seen.has(id)) continue; + seen.add(id); + const principal = this.state.principals[id]; + if (!principal) continue; + principal.state = "revoked"; + principal.generation += 1; + principal.updatedAt = now; + changed.push(structuredClone(principal)); + for (const candidate of Object.values(this.state.principals)) { + if (candidate.parentSessionId === id) queue.push(candidate.id); + } + } + if (changed.length) this.persist(); + return changed; + } + + private subtreePrincipalIds(principalId: string): string[] { + const result: string[] = []; + const queue = [principalId]; + const seen = new Set(); + while (queue.length) { + const id = queue.shift()!; + if (seen.has(id) || !this.state.principals[id]) continue; + seen.add(id); + result.push(id); + for (const candidate of Object.values(this.state.principals)) { + if (candidate.parentSessionId === id) queue.push(candidate.id); + } + } + return result; + } + + private pruneExpiredEnrollments(now = this.now()): void { + for (const [hash, enrollment] of Object.entries(this.state.enrollments)) { + if (enrollment.expiresAt <= now) delete this.state.enrollments[hash]; + } + } + + private load(): RemoteAccessState { + if (!existsSync(this.statePath)) return emptyState(); + try { + return parseState(JSON.parse(readFileSync(this.statePath, "utf8"))); + } catch (error) { + throw new Error(`Invalid remote access registry at ${this.statePath}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + private persist(): void { + writeDurableJson(this.statePath, this.state); + } +} diff --git a/broker/audit.test.ts b/broker/audit.test.ts new file mode 100644 index 0000000..c5706a5 --- /dev/null +++ b/broker/audit.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, statSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import test from "node:test"; +import { BrokerAuditLog } from "./audit.ts"; + +test("broker audit is append-only, private, structured, and contains no credential fields", () => { + const root = mkdtempSync(join(tmpdir(), "agent-intercom-audit-")); + const path = join(root, "broker-audit.jsonl"); + try { + const audit = new BrokerAuditLog(path, () => 1234); + audit.record({ + event: "remote_connect", + outcome: "allowed", + actorId: "remote-1", + remoteHostId: "ika", + generation: 2, + }); + audit.record({ + event: "remote_delivery_denied", + outcome: "denied", + actorId: "remote-1", + targetId: "hidden", + reason: "POLICY_DENIED", + }); + const lines = readFileSync(path, "utf8").trim().split("\n").map((line) => JSON.parse(line)); + assert.equal(lines.length, 2); + assert.deepEqual(lines[0], { + version: 1, + timestamp: 1234, + event: "remote_connect", + outcome: "allowed", + actorId: "remote-1", + remoteHostId: "ika", + generation: 2, + }); + assert.equal(lines[1].reason, "POLICY_DENIED"); + assert.equal(readFileSync(path, "utf8").includes("credential"), false); + assert.equal(statSync(path).mode & 0o777, 0o600); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/broker/audit.ts b/broker/audit.ts new file mode 100644 index 0000000..2e6c753 --- /dev/null +++ b/broker/audit.ts @@ -0,0 +1,60 @@ +import { closeSync, fsyncSync, openSync, writeSync } from "fs"; +import { INTERCOM_RUNTIME_FILE_MODE, restrictIntercomRuntimeFile } from "./paths.ts"; + +export const BROKER_AUDIT_VERSION = 1; + +export type BrokerAuditEvent = + | "enrollment_issued" + | "enrollment_consumed" + | "remote_connect" + | "remote_reconnect" + | "remote_disconnect" + | "remote_registration_denied" + | "remote_delivery_denied" + | "credential_reuse_denied" + | "remote_visibility_filtered" + | "tree_inspected" + | "principal_revoked" + | "principal_expired" + | "principal_adopted" + | "generation_fenced"; + +export interface BrokerAuditEntry { + event: BrokerAuditEvent; + outcome: "allowed" | "denied" | "observed"; + actorId?: string; + targetId?: string; + remoteHostId?: string; + generation?: number; + reason?: string; + visibleCount?: number; + hiddenCount?: number; +} + +export class BrokerAuditLog { + constructor(private readonly path: string, private readonly now: () => number = Date.now) {} + + record(entry: BrokerAuditEntry): void { + const line = `${JSON.stringify({ + version: BROKER_AUDIT_VERSION, + timestamp: this.now(), + ...entry, + })}\n`; + const descriptor = openSync(this.path, "a", INTERCOM_RUNTIME_FILE_MODE); + try { + writeSync(descriptor, line, undefined, "utf8"); + fsyncSync(descriptor); + } finally { + closeSync(descriptor); + } + restrictIntercomRuntimeFile(this.path); + } + + tryRecord(entry: BrokerAuditEntry): void { + try { + this.record(entry); + } catch (error) { + console.error("Failed to append Agent Intercom broker audit event:", error); + } + } +} diff --git a/broker/authorization.test.ts b/broker/authorization.test.ts new file mode 100644 index 0000000..880dd5b --- /dev/null +++ b/broker/authorization.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { authorizeSessionAction, visibleSessions } from "./authorization.ts"; +import type { SessionInfo } from "../types.ts"; + +function local(id: string): SessionInfo { + return { id, name: id, cwd: "/tmp", model: "test", pid: 1, startedAt: 1, lastActivity: 1, origin: "local" }; +} + +function remote(id: string, parentSessionId: string, rootSessionId = "root"): SessionInfo { + return { + id, + name: id, + cwd: "/tmp", + model: "test", + pid: 2, + startedAt: 1, + lastActivity: 1, + origin: "remote", + remoteHostId: "ika", + parentSessionId, + rootSessionId, + generation: 1, + }; +} + +const sessions = [ + local("root"), + local("unrelated"), + remote("manager", "root"), + remote("child-a", "manager"), + remote("child-b", "manager"), +]; + +test("phase one discovery and communication use the same ancestor-chain policy", () => { + assert.equal(authorizeSessionAction(sessions, "root", "send", "manager").allowed, true); + assert.equal(authorizeSessionAction(sessions, "manager", "ask", "root").allowed, true); + assert.equal(authorizeSessionAction(sessions, "manager", "send", "child-a").allowed, true); + assert.equal(authorizeSessionAction(sessions, "child-a", "reply", "manager").allowed, true); + assert.equal(authorizeSessionAction(sessions, "child-a", "send", "root").allowed, true); + assert.equal(authorizeSessionAction(sessions, "child-a", "discover", "child-b").allowed, false); + assert.equal(authorizeSessionAction(sessions, "unrelated", "discover", "manager").allowed, false); +}); + +test("visibility hides unauthorized sessions rather than revealing denial details", () => { + assert.deepEqual(visibleSessions(sessions, "child-a").map((session) => session.id).sort(), ["child-a", "manager", "root"]); + assert.deepEqual(visibleSessions(sessions, "root").map((session) => session.id).sort(), ["child-a", "child-b", "manager", "root", "unrelated"]); + assert.deepEqual(visibleSessions(sessions, "unrelated").map((session) => session.id).sort(), ["root", "unrelated"]); +}); diff --git a/broker/authorization.ts b/broker/authorization.ts new file mode 100644 index 0000000..225b986 --- /dev/null +++ b/broker/authorization.ts @@ -0,0 +1,53 @@ +import { authorize, type AuthorizationDecision, type PolicyAction, type PolicyPrincipal, type PolicyState } from "@dataforxyz/agent-intercom-core"; +import type { SessionInfo } from "../types.ts"; + +export function policyPrincipalForSession(session: SessionInfo): PolicyPrincipal { + if (session.origin === "remote") { + if (!session.parentSessionId || !session.rootSessionId || !session.generation) { + throw new Error(`Remote session ${session.id} is missing broker-owned policy metadata`); + } + return { + id: session.id, + kind: "remote", + state: "active", + generation: session.generation, + policy: "remote-tree", + parentSessionId: session.parentSessionId, + rootSessionId: session.rootSessionId, + }; + } + return { + id: session.id, + kind: "local", + state: "active", + generation: 1, + policy: "local-public", + rootSessionId: session.id, + }; +} + +export function policyStateForSessions(sessions: Iterable): PolicyState { + const principals: Record = {}; + for (const session of sessions) principals[session.id] = policyPrincipalForSession(session); + return { principals }; +} + +export function authorizeSessionAction( + sessions: Iterable, + actorId: string, + action: PolicyAction, + targetId: string, +): AuthorizationDecision { + const state = policyStateForSessions(sessions); + const actor = state.principals[actorId]; + const target = state.principals[targetId]; + return authorize(state, actorId, action, targetId, { + actorGeneration: actor?.generation, + targetGeneration: target?.generation, + }); +} + +export function visibleSessions(sessions: Iterable, actorId: string): SessionInfo[] { + const values = Array.from(sessions); + return values.filter((target) => authorizeSessionAction(values, actorId, "discover", target.id).allowed); +} diff --git a/broker/broker.ts b/broker/broker.ts index 00fdfe7..5e58f6a 100644 --- a/broker/broker.ts +++ b/broker/broker.ts @@ -2,13 +2,18 @@ import net from "net"; import { existsSync, readFileSync, renameSync, writeFileSync, unlinkSync } from "fs"; import { join } from "path"; import { randomUUID } from "crypto"; +import { authorize, POLICY_SEMANTICS_HASH, POLICY_SEMANTICS_VERSION, type PolicyAction, type PolicyState } from "@dataforxyz/agent-intercom-core"; import { writeMessage, createMessageReader } from "./framing.ts"; import { ensureIntercomRuntimeDir, + getBrokerAccessStateFilePath, + getBrokerAdminCredentialFilePath, getBrokerAskStateFilePath, + getBrokerAuditFilePath, getBrokerListenTarget, getBrokerPortFilePath, getIntercomDirPath, + getRemoteGatewaySocketPath, INTERCOM_PROTOCOL_NAME, INTERCOM_PROTOCOL_VERSION, INTERCOM_RUNTIME_FILE_MODE, @@ -18,6 +23,9 @@ import { import { getAskTimeoutMs } from "../config.ts"; import { writeDurableJson } from "../durable-json.ts"; import { acquireBrokerOwnership, hasBrokerOwnership, releaseBrokerOwnership } from "./ownership.ts"; +import { RemoteAccessRegistry, type RemotePrincipalMetadata, type RemotePrincipalRecord } from "./access-registry.ts"; +import { authorizeSessionAction, visibleSessions } from "./authorization.ts"; +import { BrokerAuditLog } from "./audit.ts"; import type { AskCancellationReason, BrokerErrorCode, @@ -27,20 +35,28 @@ import type { Attachment, SessionInfo, SessionRegistration, + RemotePrincipalSummary, } from "../types.ts"; const INTERCOM_DIR = getIntercomDirPath(); const LISTEN_TARGET = getBrokerListenTarget(); +const REMOTE_LISTEN_TARGET = getRemoteGatewaySocketPath(); const PID_PATH = join(INTERCOM_DIR, "broker.pid"); const OWNER_PATH = join(INTERCOM_DIR, "broker.owner"); const PORT_PATH = getBrokerPortFilePath(INTERCOM_DIR); const ASK_STATE_PATH = getBrokerAskStateFilePath(INTERCOM_DIR); +const ACCESS_STATE_PATH = getBrokerAccessStateFilePath(INTERCOM_DIR); +const ADMIN_CREDENTIAL_PATH = getBrokerAdminCredentialFilePath(INTERCOM_DIR); +const AUDIT_PATH = getBrokerAuditFilePath(INTERCOM_DIR); const BROKER_STATE_ID = randomUUID(); const MAX_SESSIONS = 128; const MAX_UNREGISTERED_CONNECTIONS = 32; const REGISTRATION_TIMEOUT_MS = 1000; const RATE_LIMIT_CAPACITY = 240; const RATE_LIMIT_REFILL_PER_SECOND = 120; +const REMOTE_RATE_LIMIT_CAPACITY = 60; +const REMOTE_RATE_LIMIT_REFILL_PER_SECOND = 30; +const REMOTE_EXPIRY_SWEEP_MS = Math.max(50, Number.parseInt(process.env.PI_INTERCOM_REMOTE_EXPIRY_SWEEP_MS ?? "1000", 10) || 1000); const PRESENCE_HEARTBEAT_MS = 1000; const DELIVERY_ACK_TIMEOUT_MS = 8000; const RECENT_DELIVERY_TTL_MS = 10 * 60 * 1000; @@ -64,9 +80,13 @@ interface ConnectedSession { lastPresenceBroadcastAt: number; } +type ConnectionOrigin = "local" | "remote"; + interface ConnectionState { socket: net.Socket; + origin: ConnectionOrigin; tokens: number; + refillPerSecond: number; lastRefillAt: number; } @@ -98,11 +118,19 @@ interface PendingDelivery { to: string; senderSocket: net.Socket; recipientSocket: net.Socket; + action: PolicyAction; + fromGeneration: number; + toGeneration: number; timeout: NodeJS.Timeout; } interface RecentDelivery { fingerprint: string; + from: string; + to: string; + action: PolicyAction; + fromGeneration: number; + toGeneration: number; retryable: boolean; response: | { type: "delivered"; messageId: string; deliveryId: string } @@ -227,25 +255,45 @@ class IntercomBroker { private connections = new Set(); private unregisteredConnections = new Set(); private server: net.Server; + private remoteServer: net.Server | null = null; private shutdownTimer: NodeJS.Timeout | null = null; + private expiryTimer: NodeJS.Timeout | null = null; private readonly askTimeoutMs = getAskTimeoutMs(); + private readonly accessRegistry: RemoteAccessRegistry; + private readonly audit: BrokerAuditLog; constructor() { ensureIntercomRuntimeDir(INTERCOM_DIR); acquireBrokerOwnership(OWNER_PATH); + this.accessRegistry = new RemoteAccessRegistry(ACCESS_STATE_PATH); + this.audit = new BrokerAuditLog(AUDIT_PATH); + this.accessRegistry.ensureAdminCredential(ADMIN_CREDENTIAL_PATH); this.loadAskEdges(); if (typeof LISTEN_TARGET === "string" && process.platform !== "win32") { - try { - unlinkSync(LISTEN_TARGET); - } catch { - // A clean startup has no stale socket to remove. + for (const socketPath of [LISTEN_TARGET, REMOTE_LISTEN_TARGET]) { + try { + unlinkSync(socketPath); + } catch { + // A clean startup has no stale socket to remove. + } } } - this.server = net.createServer(this.handleConnection.bind(this)); + this.server = net.createServer((socket) => this.handleConnection(socket, "local")); + if (process.platform !== "win32" && typeof LISTEN_TARGET === "string") { + this.remoteServer = net.createServer((socket) => this.handleConnection(socket, "remote")); + } } start(): void { - const onListening = () => { + let localListening = false; + let remoteListening = this.remoteServer === null; + const announceWhenReady = () => { + if (!localListening || !remoteListening) return; + writeFileSync(PID_PATH, String(process.pid), { mode: INTERCOM_RUNTIME_FILE_MODE }); + restrictIntercomRuntimeFile(PID_PATH); + console.log(`Intercom broker started (pid: ${process.pid}, remote-access-v1)`); + }; + const onLocalListening = () => { if (typeof LISTEN_TARGET === "string") { restrictIntercomRuntimeFile(LISTEN_TARGET); } else { @@ -262,21 +310,29 @@ class IntercomBroker { writeFileSync(PORT_PATH, `${JSON.stringify(endpoint)}\n`, { mode: INTERCOM_RUNTIME_FILE_MODE }); restrictIntercomRuntimeFile(PORT_PATH); } - writeFileSync(PID_PATH, String(process.pid), { mode: INTERCOM_RUNTIME_FILE_MODE }); - restrictIntercomRuntimeFile(PID_PATH); - console.log(`Intercom broker started (pid: ${process.pid})`); + localListening = true; + announceWhenReady(); }; if (typeof LISTEN_TARGET === "string") { - this.server.listen(LISTEN_TARGET, onListening); + this.server.listen(LISTEN_TARGET, onLocalListening); } else { - this.server.listen({ host: LISTEN_TARGET.host, port: LISTEN_TARGET.port }, onListening); + this.server.listen({ host: LISTEN_TARGET.host, port: LISTEN_TARGET.port }, onLocalListening); } + if (this.remoteServer) { + this.remoteServer.listen(REMOTE_LISTEN_TARGET, () => { + restrictIntercomRuntimeFile(REMOTE_LISTEN_TARGET); + remoteListening = true; + announceWhenReady(); + }); + } + this.expiryTimer = setInterval(() => this.reconcileExpiredPrincipals(), REMOTE_EXPIRY_SWEEP_MS); + this.expiryTimer.unref?.(); process.on("SIGTERM", () => this.shutdown()); process.on("SIGINT", () => this.shutdown()); } - private handleConnection(socket: net.Socket): void { + private handleConnection(socket: net.Socket, origin: ConnectionOrigin): void { this.connections.add(socket); let sessionId: string | null = null; let registrationTimeout: NodeJS.Timeout | null = null; @@ -304,7 +360,9 @@ class IntercomBroker { armRegistrationTimeout(); const connection: ConnectionState = { socket, - tokens: RATE_LIMIT_CAPACITY, + origin, + tokens: origin === "remote" ? REMOTE_RATE_LIMIT_CAPACITY : RATE_LIMIT_CAPACITY, + refillPerSecond: origin === "remote" ? REMOTE_RATE_LIMIT_REFILL_PER_SECOND : RATE_LIMIT_REFILL_PER_SECOND, lastRefillAt: Date.now(), }; @@ -316,7 +374,7 @@ class IntercomBroker { return; } try { - this.handleMessage(socket, msg, sessionId, (id) => { + this.handleMessage(socket, origin, msg, sessionId, (id) => { sessionId = id; if (id) { clearRegistrationTimeout(); @@ -345,10 +403,20 @@ class IntercomBroker { if (sessionId) { const existing = this.sessions.get(sessionId); if (existing?.socket === socket) { + if (existing.info.origin === "remote") { + this.audit.tryRecord({ + event: "remote_disconnect", + outcome: "observed", + actorId: sessionId, + remoteHostId: existing.info.remoteHostId, + generation: existing.info.generation, + reason: "SOCKET_CLOSED", + }); + } + this.broadcastVisible({ type: "session_left", sessionId }, existing.info, sessionId); this.sessions.delete(sessionId); this.clearPendingDeliveriesForSession(sessionId, socket); this.deferAskEdgesForSession(sessionId); - this.broadcast({ type: "session_left", sessionId }, sessionId); this.scheduleShutdownCheck(); } } @@ -377,8 +445,8 @@ class IntercomBroker { const elapsedMs = now - connection.lastRefillAt; if (elapsedMs > 0) { connection.tokens = Math.min( - RATE_LIMIT_CAPACITY, - connection.tokens + elapsedMs * RATE_LIMIT_REFILL_PER_SECOND / 1000, + connection.origin === "remote" ? REMOTE_RATE_LIMIT_CAPACITY : RATE_LIMIT_CAPACITY, + connection.tokens + elapsedMs * connection.refillPerSecond / 1000, ); connection.lastRefillAt = now; } @@ -417,6 +485,7 @@ class IntercomBroker { private handleMessage( socket: net.Socket, + origin: ConnectionOrigin, msg: unknown, currentId: string | null, setId: (id: string | null) => void, @@ -441,10 +510,23 @@ class IntercomBroker { requestId: clientMessage.requestId, protocol: INTERCOM_PROTOCOL_NAME, version: INTERCOM_PROTOCOL_VERSION, + endpoint: origin, + remoteAccess: this.remoteAccessContract(), }); return; } + if (clientMessage.type === "access_control") { + if (currentId !== null) { + this.sendError(socket, "ACCESS_DENIED", "Remote access control requires a short-lived control connection"); + socket.end(); + return; + } + if (origin === "local") this.handleAccessControl(socket, clientMessage); + else this.handleRemoteAccessControl(socket, clientMessage); + return; + } + if (requiresEndpointAuth && clientMessage.type === "register" && !hasEndpointAuth) { throw new Error("Invalid intercom TCP endpoint credentials"); } @@ -452,6 +534,11 @@ class IntercomBroker { if (currentId === null && clientMessage.type !== "register") { throw new Error(`Received ${clientMessage.type} before register`); } + if (currentId && !this.isCurrentPrincipal(currentId)) { + this.sendError(socket, "ACCESS_DENIED", "Remote session authorization is no longer valid"); + socket.destroy(); + return; + } switch (clientMessage.type) { case "register": { @@ -476,27 +563,102 @@ class IntercomBroker { throw new Error("Received duplicate register message"); } - let id: string = randomUUID(); - if (clientMessage.sessionId !== undefined) { - if (!isSessionId(clientMessage.sessionId)) { - throw new Error("Invalid register sessionId"); + let id: string; + let remotePrincipal: RemotePrincipalRecord | undefined; + let issuedSessionCredential: string | undefined; + let enrollmentConsumed = false; + if (origin === "remote") { + if (this.sessions.size >= MAX_SESSIONS) { + this.sendError(socket, "TOO_MANY_SESSIONS", "Too many registered intercom sessions"); + socket.destroy(); + break; + } + const access = clientMessage.access; + if (typeof access !== "object" || access === null || Array.isArray(access)) { + this.audit.tryRecord({ event: "remote_registration_denied", outcome: "denied", reason: "MISSING_CREDENTIAL" }); + this.sendError(socket, "ACCESS_DENIED", "Remote registration requires an access credential"); + socket.end(); + break; + } + const fields = access as Record; + try { + if (typeof fields.enrollmentToken === "string") { + const consumed = this.accessRegistry.consumeEnrollment(fields.enrollmentToken); + remotePrincipal = consumed.principal; + issuedSessionCredential = consumed.sessionCredential; + enrollmentConsumed = true; + } else if ( + typeof fields.sessionCredential === "string" + && typeof fields.sessionId === "string" + && typeof fields.generation === "number" + && Number.isSafeInteger(fields.generation) + ) { + remotePrincipal = this.accessRegistry.authenticateSession(fields.sessionId, fields.generation, fields.sessionCredential); + } else { + throw new Error("Invalid remote access credential shape"); + } + } catch { + this.audit.tryRecord({ event: "remote_registration_denied", outcome: "denied", reason: "INVALID_CREDENTIAL" }); + this.sendError(socket, "ACCESS_DENIED", "Remote registration credential was rejected"); + socket.end(); + break; + } + id = remotePrincipal.id; + if (this.sessions.has(id)) { + this.audit.tryRecord({ + event: "credential_reuse_denied", + outcome: "denied", + actorId: id, + remoteHostId: remotePrincipal.remoteHostId, + generation: remotePrincipal.generation, + reason: "ALREADY_ACTIVE", + }); + this.sendError(socket, "ACCESS_DENIED", "Remote session credential is already active"); + socket.end(); + break; + } + } else { + id = randomUUID(); + if (clientMessage.sessionId !== undefined) { + if (!isSessionId(clientMessage.sessionId)) { + throw new Error("Invalid register sessionId"); + } + id = clientMessage.sessionId; + } + const previous = this.sessions.get(id); + if (!previous && this.sessions.size >= MAX_SESSIONS) { + this.sendError(socket, "TOO_MANY_SESSIONS", "Too many registered intercom sessions"); + socket.destroy(); + break; + } + if (previous) { + this.clearPendingDeliveriesForSession(id, previous.socket); + this.deferAskEdgesForSession(id); + previous.socket.end(); } - id = clientMessage.sessionId; - } - const previous = this.sessions.get(id); - if (!previous && this.sessions.size >= MAX_SESSIONS) { - this.sendError(socket, "TOO_MANY_SESSIONS", "Too many registered intercom sessions"); - socket.destroy(); - break; - } - if (previous) { - this.clearPendingDeliveriesForSession(id, previous.socket); - this.deferAskEdgesForSession(id); - previous.socket.end(); } setId(id); const session = clientMessage.session; - const info: SessionInfo = { + const info: SessionInfo = remotePrincipal ? { + id, + name: remotePrincipal.name, + cwd: session.cwd, + model: session.model, + pid: session.pid, + startedAt: session.startedAt, + lastActivity: session.lastActivity, + ...(session.status !== undefined ? { status: session.status } : {}), + trustedLocal: false, + origin: "remote", + remoteHostId: remotePrincipal.remoteHostId, + parentSessionId: remotePrincipal.parentSessionId, + rootSessionId: remotePrincipal.rootSessionId, + generation: remotePrincipal.generation, + canDelegate: remotePrincipal.canDelegate, + depth: remotePrincipal.depth, + maxDepth: remotePrincipal.maxDepth, + maxChildren: remotePrincipal.maxChildren, + } : { id, ...(session.name !== undefined ? { name: session.name } : {}), cwd: session.cwd, @@ -506,9 +668,28 @@ class IntercomBroker { lastActivity: session.lastActivity, ...(session.status !== undefined ? { status: session.status } : {}), trustedLocal: typeof LISTEN_TARGET === "string" && process.platform !== "win32", + origin: "local", }; + if (remotePrincipal) { + this.audit.record({ + event: enrollmentConsumed ? "enrollment_consumed" : "remote_reconnect", + outcome: "allowed", + actorId: id, + targetId: remotePrincipal.parentSessionId, + remoteHostId: remotePrincipal.remoteHostId, + generation: remotePrincipal.generation, + }); + this.audit.record({ + event: "remote_connect", + outcome: "allowed", + actorId: id, + targetId: remotePrincipal.parentSessionId, + remoteHostId: remotePrincipal.remoteHostId, + generation: remotePrincipal.generation, + }); + } this.sessions.set(id, { socket, info, lastPresenceBroadcastAt: Date.now() }); - + if (this.shutdownTimer) { clearTimeout(this.shutdownTimer); this.shutdownTimer = null; @@ -519,8 +700,23 @@ class IntercomBroker { sessionId: id, protocol: INTERCOM_PROTOCOL_NAME, version: INTERCOM_PROTOCOL_VERSION, + ...(remotePrincipal ? { + remoteAccess: this.remoteAccessContract(), + access: { + origin: "remote", + remoteHostId: remotePrincipal.remoteHostId, + parentSessionId: remotePrincipal.parentSessionId, + rootSessionId: remotePrincipal.rootSessionId, + generation: remotePrincipal.generation, + canDelegate: remotePrincipal.canDelegate, + depth: remotePrincipal.depth, + maxDepth: remotePrincipal.maxDepth, + maxChildren: remotePrincipal.maxChildren, + ...(issuedSessionCredential ? { sessionCredential: issuedSessionCredential } : {}), + }, + } : {}), }); - this.broadcast({ type: "session_joined", session: info }, id); + this.broadcastVisible({ type: "session_joined", session: info }, info, id); break; } @@ -533,6 +729,17 @@ class IntercomBroker { } const existing = this.sessions.get(currentId); if (existing?.socket === socket) { + if (existing.info.origin === "remote") { + this.audit.tryRecord({ + event: "remote_disconnect", + outcome: "observed", + actorId: currentId, + remoteHostId: existing.info.remoteHostId, + generation: existing.info.generation, + reason: "UNREGISTERED", + }); + } + this.broadcastVisible({ type: "session_left", sessionId: currentId }, existing.info, currentId); this.sessions.delete(currentId); this.clearPendingDeliveriesForSession(currentId, socket); if (clientMessage.preserveAsks) { @@ -540,7 +747,6 @@ class IntercomBroker { } else { this.clearAskEdgesForSession(currentId, "session_disconnected"); } - this.broadcast({ type: "session_left", sessionId: currentId }, currentId); this.scheduleShutdownCheck(); } setId(null); @@ -552,7 +758,20 @@ class IntercomBroker { throw new Error("Invalid list message"); } - const sessions = Array.from(this.sessions.values()).map(s => s.info); + const allSessions = Array.from(this.sessions.values(), (session) => session.info); + const sessions = visibleSessions(allSessions, currentId!); + const actor = this.sessions.get(currentId!); + if (actor?.info.origin === "remote" && sessions.length < allSessions.length) { + this.audit.tryRecord({ + event: "remote_visibility_filtered", + outcome: "observed", + actorId: currentId!, + remoteHostId: actor.info.remoteHostId, + generation: actor.info.generation, + visibleCount: sessions.length, + hiddenCount: allSessions.length - sessions.length, + }); + } writeMessage(socket, { type: "sessions", requestId: clientMessage.requestId, sessions }); break; } @@ -581,6 +800,7 @@ class IntercomBroker { break; } + const action: PolicyAction = message.replyTo ? "reply" : message.expectsReply ? "ask" : "send"; this.pruneRecentDeliveries(); const deliveryKey = this.deliveryKey(currentId, message.id); const fingerprint = JSON.stringify({ @@ -595,7 +815,16 @@ class IntercomBroker { this.sendDeliveryFailure(socket, message.id, false, "DUPLICATE_MESSAGE_ID", "Message ID was already used with a different payload"); break; } - if (recent.retryable) { + const actor = this.sessions.get(currentId); + const target = this.sessions.get(recent.to); + const authorizationStillValid = Boolean( + actor + && target + && (actor.info.generation ?? 1) === recent.fromGeneration + && (target.info.generation ?? 1) === recent.toGeneration + && this.isAuthorized(currentId, recent.action, recent.to) + ); + if (recent.retryable || !authorizationStillValid) { this.recentDeliveries.delete(deliveryKey); } else { if (recent.response.type === "delivered") { @@ -614,10 +843,21 @@ class IntercomBroker { const existing = this.pendingDeliveries.get(existingDeliveryId); if (!existing || existing.fingerprint !== fingerprint) { this.sendDeliveryFailure(socket, message.id, false, "DUPLICATE_MESSAGE_ID", "Message ID is already pending with a different payload"); - } else { + break; + } + const actor = this.sessions.get(existing.from); + const target = this.sessions.get(existing.to); + if ( + actor + && target + && (actor.info.generation ?? 1) === existing.fromGeneration + && (target.info.generation ?? 1) === existing.toGeneration + && this.isAuthorized(existing.from, existing.action, existing.to) + ) { writeMessage(socket, { type: "delivery_accepted", messageId: message.id, deliveryId: existing.id }); + break; } - break; + this.failPendingDelivery(existing.id, "SESSION_NOT_FOUND", "Delivery authorization changed while pending"); } if ( @@ -628,7 +868,20 @@ class IntercomBroker { break; } - const targets = this.findSessions(clientMessage.to); + const candidates = this.findSessions(clientMessage.to); + const targets = candidates.filter((target) => this.isAuthorized(currentId, action, target.info.id)); + if (candidates.length > 0 && targets.length === 0) { + const actor = this.sessions.get(currentId); + this.audit.tryRecord({ + event: "remote_delivery_denied", + outcome: "denied", + actorId: currentId, + targetId: candidates.length === 1 ? candidates[0].info.id : undefined, + remoteHostId: actor?.info.remoteHostId ?? candidates.find((candidate) => candidate.info.remoteHostId)?.info.remoteHostId, + generation: actor?.info.generation, + reason: "POLICY_DENIED", + }); + } if (targets.length === 1) { const fromSession = this.sessions.get(currentId); if (!fromSession || fromSession.socket !== socket) { @@ -686,6 +939,9 @@ class IntercomBroker { to: target.info.id, senderSocket: socket, recipientSocket: target.socket, + action, + fromGeneration: fromSession.info.generation ?? 1, + toGeneration: target.info.generation ?? 1, timeout, }; this.pendingDeliveries.set(deliveryId, pending); @@ -753,7 +1009,7 @@ class IntercomBroker { } const edge = this.askEdges.get(this.askKey(currentId, clientMessage.messageId)); const applied = Boolean(edge?.from === currentId); - if (applied && edge.state === "blocking") { + if (edge?.from === currentId && edge.state === "blocking") { edge.state = "deferred"; this.persistAskEdges(); this.notifyAskDeferred(edge); @@ -796,7 +1052,7 @@ class IntercomBroker { if (typeof clientMessage.name !== "string" || clientMessage.name.length > MAX_SESSION_NAME_LENGTH) { throw new Error("Invalid presence name"); } - if (session.info.name !== clientMessage.name) { + if (session.info.origin !== "remote" && session.info.name !== clientMessage.name) { session.info.name = clientMessage.name; changed = true; } @@ -823,7 +1079,7 @@ class IntercomBroker { session.info.lastActivity = now; if (changed || now - session.lastPresenceBroadcastAt >= PRESENCE_HEARTBEAT_MS) { session.lastPresenceBroadcastAt = now; - this.broadcast({ type: "presence_update", session: session.info }, currentId); + this.broadcastVisible({ type: "presence_update", session: session.info }, session.info, currentId); } } break; @@ -834,6 +1090,400 @@ class IntercomBroker { } } + private remoteAccessContract() { + return { + feature: "remote-access-v1" as const, + policySemanticsVersion: POLICY_SEMANTICS_VERSION, + policySemanticsHash: POLICY_SEMANTICS_HASH, + }; + } + + private handleAccessControl(socket: net.Socket, message: Record): void { + if ( + typeof message.requestId !== "string" + || message.requestId.length > MAX_MESSAGE_ID_LENGTH + || typeof message.adminToken !== "string" + || !this.accessRegistry.authenticateAdmin(message.adminToken) + ) { + this.sendError(socket, "ACCESS_DENIED", "Remote access control credential or request was rejected"); + socket.end(); + return; + } + if (message.action === "inspect_tree") { + if (typeof message.principalId !== "string" || !isSessionId(message.principalId)) { + this.sendError(socket, "INVALID_REQUEST", "Invalid remote principal ID"); + socket.end(); + return; + } + const principals = this.accessRegistry.inspectSubtree(message.principalId).map((principal) => this.principalSummary(principal)); + this.audit.record({ + event: "tree_inspected", + outcome: "allowed", + actorId: "local-admin", + targetId: message.principalId, + visibleCount: principals.length, + }); + writeMessage(socket, { type: "access_control_result", requestId: message.requestId, action: "inspect_tree", principals }); + socket.end(); + return; + } + if (message.action === "adopt_subtree") { + if ( + typeof message.principalId !== "string" + || !isSessionId(message.principalId) + || typeof message.newParentSessionId !== "string" + || !isSessionId(message.newParentSessionId) + ) { + this.sendError(socket, "INVALID_REQUEST", "Invalid adoption request"); + socket.end(); + return; + } + const localParent = this.sessions.get(message.newParentSessionId); + const remoteParent = this.accessRegistry.snapshot().principals[message.newParentSessionId]; + if ((!localParent || localParent.info.origin === "remote") && (!remoteParent || remoteParent.state !== "active")) { + this.sendError(socket, "ACCESS_DENIED", "Adoption parent must be an active local or remote principal"); + socket.end(); + return; + } + const newRootSessionId = localParent?.info.origin === "local" + ? localParent.info.id + : remoteParent!.rootSessionId; + const priorSessions = Array.from(this.sessions.values(), (session) => session.info); + let changed: RemotePrincipalRecord[]; + try { + changed = this.accessRegistry.adoptSubtree(message.principalId, message.newParentSessionId, newRootSessionId); + } catch { + this.sendError(socket, "ACCESS_DENIED", "Adoption would violate the ownership tree"); + socket.end(); + return; + } + this.disconnectTransitionedPrincipals(changed, priorSessions, "principal_adopted"); + writeMessage(socket, { + type: "access_control_result", + requestId: message.requestId, + action: "adopt_subtree", + principals: changed.map((principal) => this.principalSummary(principal)), + }); + socket.end(); + return; + } + if (message.action === "revoke_subtree") { + if (typeof message.principalId !== "string" || !isSessionId(message.principalId)) { + this.sendError(socket, "INVALID_REQUEST", "Invalid remote principal ID"); + socket.end(); + return; + } + const priorSessions = Array.from(this.sessions.values(), (session) => session.info); + const changed = this.accessRegistry.revoke(message.principalId); + this.disconnectTransitionedPrincipals(changed, priorSessions); + writeMessage(socket, { + type: "access_control_result", + requestId: message.requestId, + action: "revoke_subtree", + changedPrincipalIds: changed.map((principal) => principal.id), + }); + socket.end(); + return; + } + if ( + message.action !== "issue_enrollment" + || typeof message.enrollment !== "object" + || message.enrollment === null + || Array.isArray(message.enrollment) + ) { + this.sendError(socket, "INVALID_REQUEST", "Unknown remote access control action"); + socket.end(); + return; + } + const enrollment = message.enrollment as Record; + if ( + typeof enrollment.name !== "string" + || typeof enrollment.parentSessionId !== "string" + || typeof enrollment.rootSessionId !== "string" + || typeof enrollment.remoteHostId !== "string" + || (enrollment.ttlMs !== undefined && (typeof enrollment.ttlMs !== "number" || !Number.isSafeInteger(enrollment.ttlMs))) + || (enrollment.expiresAt !== undefined && (typeof enrollment.expiresAt !== "number" || !Number.isSafeInteger(enrollment.expiresAt))) + || (enrollment.canDelegate !== undefined && typeof enrollment.canDelegate !== "boolean") + || (enrollment.maxDepth !== undefined && (typeof enrollment.maxDepth !== "number" || !Number.isSafeInteger(enrollment.maxDepth))) + || (enrollment.maxChildren !== undefined && (typeof enrollment.maxChildren !== "number" || !Number.isSafeInteger(enrollment.maxChildren))) + ) { + this.sendError(socket, "INVALID_REQUEST", "Invalid remote enrollment request"); + socket.end(); + return; + } + const parent = this.sessions.get(enrollment.parentSessionId); + if (!parent || parent.info.origin === "remote" || enrollment.rootSessionId !== parent.info.id) { + this.sendError(socket, "ACCESS_DENIED", "Enrollment parent must be an active local root session"); + socket.end(); + return; + } + const issued = this.accessRegistry.issueEnrollment({ + name: enrollment.name, + parentSessionId: parent.info.id, + rootSessionId: parent.info.id, + remoteHostId: enrollment.remoteHostId, + ...(enrollment.expiresAt !== undefined ? { expiresAt: enrollment.expiresAt as number } : {}), + ...(enrollment.canDelegate !== undefined ? { canDelegate: enrollment.canDelegate as boolean } : {}), + ...(enrollment.maxDepth !== undefined ? { maxDepth: enrollment.maxDepth as number } : {}), + ...(enrollment.maxChildren !== undefined ? { maxChildren: enrollment.maxChildren as number } : {}), + }, enrollment.ttlMs as number | undefined); + this.audit.record({ + event: "enrollment_issued", + outcome: "allowed", + actorId: parent.info.id, + targetId: enrollment.name, + remoteHostId: enrollment.remoteHostId, + reason: `expires:${issued.expiresAt}`, + }); + writeMessage(socket, { + type: "access_control_result", + requestId: message.requestId, + action: "issue_enrollment", + enrollmentToken: issued.enrollmentToken, + expiresAt: issued.expiresAt, + }); + socket.end(); + } + + private handleRemoteAccessControl(socket: net.Socket, message: Record): void { + if ( + typeof message.requestId !== "string" + || message.requestId.length > MAX_MESSAGE_ID_LENGTH + || typeof message.access !== "object" + || message.access === null + || Array.isArray(message.access) + ) { + this.sendError(socket, "ACCESS_DENIED", "Remote control request was rejected"); + socket.end(); + return; + } + const access = message.access as Record; + if ( + typeof access.sessionCredential !== "string" + || typeof access.sessionId !== "string" + || typeof access.generation !== "number" + || !Number.isSafeInteger(access.generation) + ) { + this.sendError(socket, "ACCESS_DENIED", "Remote control credential was rejected"); + socket.end(); + return; + } + let parent: RemotePrincipalRecord; + try { + parent = this.accessRegistry.authenticateSession(access.sessionId, access.generation, access.sessionCredential); + } catch { + this.audit.tryRecord({ event: "remote_registration_denied", outcome: "denied", reason: "INVALID_CONTROL_CREDENTIAL" }); + this.sendError(socket, "ACCESS_DENIED", "Remote control credential was rejected"); + socket.end(); + return; + } + const policyState = this.registryPolicyState(); + if (message.action === "inspect_tree") { + const targetId = typeof message.principalId === "string" ? message.principalId : parent.id; + const inspection = authorize(policyState, parent.id, "inspect_tree", targetId, { actorGeneration: parent.generation }); + if (!inspection.allowed) { + this.sendError(socket, "ACCESS_DENIED", "Remote tree inspection policy denied the request"); + socket.end(); + return; + } + const principals = this.accessRegistry.inspectSubtree(targetId) + .filter((candidate) => authorize(policyState, parent.id, "inspect_tree", candidate.id, { actorGeneration: parent.generation }).allowed) + .map((candidate) => this.principalSummary(candidate)); + this.audit.record({ + event: "tree_inspected", + outcome: "allowed", + actorId: parent.id, + targetId, + remoteHostId: parent.remoteHostId, + generation: parent.generation, + visibleCount: principals.length, + }); + writeMessage(socket, { type: "access_control_result", requestId: message.requestId, action: "inspect_tree", principals }); + socket.end(); + return; + } + if ( + message.action !== "issue_child_enrollment" + || typeof message.enrollment !== "object" + || message.enrollment === null + || Array.isArray(message.enrollment) + ) { + this.sendError(socket, "ACCESS_DENIED", "Remote control action was rejected"); + socket.end(); + return; + } + const enrollment = message.enrollment as Record; + if ( + typeof enrollment.name !== "string" + || (enrollment.ttlMs !== undefined && (typeof enrollment.ttlMs !== "number" || !Number.isSafeInteger(enrollment.ttlMs))) + || (enrollment.expiresAt !== undefined && (typeof enrollment.expiresAt !== "number" || !Number.isSafeInteger(enrollment.expiresAt))) + || (enrollment.canDelegate !== undefined && typeof enrollment.canDelegate !== "boolean") + || (enrollment.maxDepth !== undefined && (typeof enrollment.maxDepth !== "number" || !Number.isSafeInteger(enrollment.maxDepth))) + || (enrollment.maxChildren !== undefined && (typeof enrollment.maxChildren !== "number" || !Number.isSafeInteger(enrollment.maxChildren))) + ) { + this.sendError(socket, "ACCESS_DENIED", "Remote delegation request was rejected"); + socket.end(); + return; + } + const delegation = authorize(policyState, parent.id, "delegate_child", parent.id, { + actorGeneration: parent.generation, + targetGeneration: parent.generation, + }); + if (!delegation.allowed) { + this.sendError(socket, "ACCESS_DENIED", "Remote delegation policy denied the request"); + socket.end(); + return; + } + let issued; + try { + issued = this.accessRegistry.issueChildEnrollment(parent.id, parent.generation, { + name: enrollment.name, + ...(enrollment.expiresAt !== undefined ? { expiresAt: enrollment.expiresAt as number } : {}), + ...(enrollment.canDelegate !== undefined ? { canDelegate: enrollment.canDelegate as boolean } : {}), + ...(enrollment.maxDepth !== undefined ? { maxDepth: enrollment.maxDepth as number } : {}), + ...(enrollment.maxChildren !== undefined ? { maxChildren: enrollment.maxChildren as number } : {}), + }, enrollment.ttlMs as number | undefined); + } catch { + this.sendError(socket, "ACCESS_DENIED", "Remote delegation limits denied the request"); + socket.end(); + return; + } + this.audit.record({ + event: "enrollment_issued", + outcome: "allowed", + actorId: parent.id, + targetId: enrollment.name, + remoteHostId: parent.remoteHostId, + generation: parent.generation, + reason: `delegated-expires:${issued.expiresAt}`, + }); + writeMessage(socket, { + type: "access_control_result", + requestId: message.requestId, + action: "issue_child_enrollment", + enrollmentToken: issued.enrollmentToken, + expiresAt: issued.expiresAt, + parentSessionId: parent.id, + }); + socket.end(); + } + + private registryPolicyState(): PolicyState { + const records = this.accessRegistry.snapshot().principals; + const principals: PolicyState["principals"] = {}; + for (const record of Object.values(records)) { + principals[record.id] = { + id: record.id, + kind: "remote", + state: record.state, + generation: record.generation, + policy: "remote-tree", + parentSessionId: record.parentSessionId, + rootSessionId: record.rootSessionId, + }; + if (!principals[record.rootSessionId]) { + principals[record.rootSessionId] = { + id: record.rootSessionId, + kind: "local", + state: "active", + generation: 1, + policy: "local-public", + rootSessionId: record.rootSessionId, + }; + } + } + return { principals }; + } + + private principalSummary(principal: RemotePrincipalMetadata): RemotePrincipalSummary { + return { ...principal, connected: this.sessions.has(principal.id) }; + } + + private reconcileExpiredPrincipals(): void { + const priorSessions = Array.from(this.sessions.values(), (session) => session.info); + const changed = this.accessRegistry.expirePrincipals(); + if (changed.length > 0) this.disconnectTransitionedPrincipals(changed, priorSessions, "principal_expired"); + } + + private disconnectTransitionedPrincipals( + changed: RemotePrincipalRecord[], + priorSessions: SessionInfo[], + auditEvent: "principal_revoked" | "principal_expired" | "principal_adopted" = "principal_revoked", + ): void { + const changedIds = new Set(changed.map((principal) => principal.id)); + for (const principal of changed) { + const live = this.sessions.get(principal.id); + if (!live) { + this.audit.record({ + event: auditEvent, + outcome: "allowed", + actorId: principal.id, + remoteHostId: principal.remoteHostId, + generation: principal.generation, + reason: "OFFLINE", + }); + continue; + } + const subject = priorSessions.find((session) => session.id === principal.id) ?? live.info; + for (const [recipientId, recipient] of this.sessions) { + if ( + recipientId !== principal.id + && !changedIds.has(recipientId) + && authorizeSessionAction(priorSessions, recipientId, "discover", principal.id).allowed + ) { + writeMessage(recipient.socket, { type: "session_left", sessionId: principal.id }); + } + } + this.clearPendingDeliveriesForSession(principal.id, live.socket); + this.clearAskEdgesForSession(principal.id, "authorization_revoked"); + this.sessions.delete(principal.id); + for (const [key, recent] of this.recentDeliveries) { + if (recent.from === principal.id || recent.to === principal.id) this.recentDeliveries.delete(key); + } + this.audit.record({ + event: auditEvent, + outcome: "allowed", + actorId: principal.id, + targetId: subject.parentSessionId, + remoteHostId: principal.remoteHostId, + generation: principal.generation, + reason: "DISCONNECTED", + }); + live.socket.destroy(); + } + if (changed.length > 0) this.scheduleShutdownCheck(); + } + + private isCurrentPrincipal(sessionId: string): boolean { + const session = this.sessions.get(sessionId); + if (!session) return false; + if (session.info.origin !== "remote") return true; + try { + this.accessRegistry.validatePrincipal(sessionId, session.info.generation ?? 0); + return true; + } catch { + return false; + } + } + + private isAuthorized(actorId: string, action: PolicyAction, targetId: string): boolean { + if (!this.isCurrentPrincipal(actorId) || !this.isCurrentPrincipal(targetId)) return false; + return authorizeSessionAction( + Array.from(this.sessions.values(), (session) => session.info), + actorId, + action, + targetId, + ).allowed; + } + + private broadcastVisible(message: BrokerMessage, subject: SessionInfo, exclude?: string): void { + for (const [id, session] of this.sessions) { + if (id !== exclude && this.isAuthorized(id, "discover", subject.id)) { + writeMessage(session.socket, message); + } + } + } + private askKey(fromSessionId: string, messageId: string): string { return `${fromSessionId}\u0000${messageId}`; } @@ -1056,6 +1706,18 @@ class IntercomBroker { if (!pending || pending.to !== sessionId || pending.recipientSocket !== socket) { return; } + const sender = this.sessions.get(pending.from); + const recipient = this.sessions.get(pending.to); + if ( + !sender + || !recipient + || (sender.info.generation ?? 1) !== pending.fromGeneration + || (recipient.info.generation ?? 1) !== pending.toGeneration + || !this.isAuthorized(pending.from, pending.action, pending.to) + ) { + this.failPendingDelivery(deliveryId, "SESSION_NOT_FOUND", "Delivery authorization changed before acknowledgement"); + return; + } clearTimeout(pending.timeout); this.pendingDeliveries.delete(deliveryId); @@ -1066,12 +1728,16 @@ class IntercomBroker { const response = { type: "delivered" as const, messageId: pending.message.id, deliveryId }; this.recentDeliveries.set(pending.key, { fingerprint: pending.fingerprint, + from: pending.from, + to: pending.to, + action: pending.action, + fromGeneration: pending.fromGeneration, + toGeneration: pending.toGeneration, retryable: false, response, expiresAt: Date.now() + RECENT_DELIVERY_TTL_MS, }); - const sender = this.sessions.get(pending.from); - if (sender?.socket === pending.senderSocket) { + if (sender.socket === pending.senderSocket) { writeMessage(sender.socket, response); } } @@ -1097,6 +1763,11 @@ class IntercomBroker { }; this.recentDeliveries.set(pending.key, { fingerprint: pending.fingerprint, + from: pending.from, + to: pending.to, + action: pending.action, + fromGeneration: pending.fromGeneration, + toGeneration: pending.toGeneration, retryable: true, response, expiresAt: Date.now() + RECENT_DELIVERY_TTL_MS, @@ -1142,16 +1813,12 @@ class IntercomBroker { .map(([, session]) => session); } - private broadcast(msg: BrokerMessage, exclude?: string): void { - for (const [id, session] of this.sessions) { - if (id !== exclude) { - writeMessage(session.socket, msg); - } - } - } - private shutdown(): void { console.log("Broker shutting down"); + if (this.expiryTimer) { + clearInterval(this.expiryTimer); + this.expiryTimer = null; + } for (const session of this.sessions.values()) { session.socket.end(); @@ -1168,10 +1835,12 @@ class IntercomBroker { this.askEdges.clear(); const ownsBroker = hasBrokerOwnership(OWNER_PATH); if (ownsBroker && typeof LISTEN_TARGET === "string" && process.platform !== "win32") { - try { - unlinkSync(LISTEN_TARGET); - } catch { - // The socket may already be gone if shutdown started after a disconnect. + for (const socketPath of [LISTEN_TARGET, REMOTE_LISTEN_TARGET]) { + try { + unlinkSync(socketPath); + } catch { + // The socket may already be gone if shutdown started after a disconnect. + } } } if (ownsBroker) { @@ -1188,6 +1857,7 @@ class IntercomBroker { releaseBrokerOwnership(OWNER_PATH); } this.server.close(); + this.remoteServer?.close(); process.exit(0); } } diff --git a/broker/client.ts b/broker/client.ts index 925fc42..c96d794 100644 --- a/broker/client.ts +++ b/broker/client.ts @@ -1,8 +1,10 @@ import { EventEmitter } from "events"; import net from "net"; import { randomUUID } from "crypto"; +import { POLICY_SEMANTICS_HASH, POLICY_SEMANTICS_VERSION } from "@dataforxyz/agent-intercom-core"; import { writeMessage, createMessageReader } from "./framing.ts"; import { PersistentOutboundOutbox } from "../outbound-outbox.ts"; +import { loadRemoteAccessCredential, writeRemoteSessionCredential, type LoadedRemoteAccessCredential } from "./access-credential.ts"; import { getBrokerConnectTarget, INTERCOM_PROTOCOL_NAME, @@ -129,7 +131,37 @@ function isSessionInfo(value: unknown): value is SessionInfo { return false; } - return session.trustedLocal === undefined || typeof session.trustedLocal === "boolean"; + if (session.trustedLocal !== undefined && typeof session.trustedLocal !== "boolean") return false; + if (session.origin !== undefined && session.origin !== "local" && session.origin !== "remote") return false; + if (session.remoteHostId !== undefined && typeof session.remoteHostId !== "string") return false; + if (session.parentSessionId !== undefined && typeof session.parentSessionId !== "string") return false; + if (session.rootSessionId !== undefined && typeof session.rootSessionId !== "string") return false; + if (session.generation !== undefined && (typeof session.generation !== "number" || !Number.isSafeInteger(session.generation))) return false; + if (session.canDelegate !== undefined && typeof session.canDelegate !== "boolean") return false; + for (const field of ["depth", "maxDepth", "maxChildren"] as const) { + if (session[field] !== undefined && (typeof session[field] !== "number" || !Number.isSafeInteger(session[field]))) return false; + } + return true; +} + +function isRemoteAccessMetadata(value: unknown): value is import("../types.ts").RemoteAccessMetadata { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const access = value as Record; + return access.origin === "remote" + && typeof access.remoteHostId === "string" + && typeof access.parentSessionId === "string" + && typeof access.rootSessionId === "string" + && typeof access.generation === "number" + && Number.isSafeInteger(access.generation) + && access.generation > 0 + && typeof access.canDelegate === "boolean" + && typeof access.depth === "number" + && Number.isSafeInteger(access.depth) + && typeof access.maxDepth === "number" + && Number.isSafeInteger(access.maxDepth) + && typeof access.maxChildren === "number" + && Number.isSafeInteger(access.maxChildren) + && (access.sessionCredential === undefined || typeof access.sessionCredential === "string"); } export class IntercomClient extends EventEmitter { @@ -144,6 +176,7 @@ export class IntercomClient extends EventEmitter { private pendingLists = new Map void; reject: (e: Error) => void }>(); private pendingAskControls = new Map void; timeout: NodeJS.Timeout }>(); private outbox: PersistentOutboundOutbox | null = null; + private remoteAccessCredential: LoadedRemoteAccessCredential | undefined; private disconnecting = false; private disconnectError: Error | null = null; @@ -203,6 +236,7 @@ export class IntercomClient extends EventEmitter { let target: BrokerConnectTarget; try { target = getBrokerConnectTarget(); + this.remoteAccessCredential = loadRemoteAccessCredential(); socket = connectToBrokerTarget(target); } catch (error) { reject(toError(error)); @@ -311,7 +345,8 @@ export class IntercomClient extends EventEmitter { protocol: INTERCOM_PROTOCOL_NAME, version: INTERCOM_PROTOCOL_VERSION, session, - ...(sessionId ? { sessionId } : {}), + ...(!this.remoteAccessCredential && sessionId ? { sessionId } : {}), + ...(this.remoteAccessCredential ? { access: this.remoteAccessCredential.access } : {}), ...(typeof target === "string" ? {} : { stateId: target.stateId }), }); } catch (error) { @@ -351,6 +386,32 @@ export class IntercomClient extends EventEmitter { throw new Error("Received duplicate registered message"); } + if (this.remoteAccessCredential) { + const contract = brokerMessage.remoteAccess; + const contractFields = typeof contract === "object" && contract !== null + ? contract as Record + : undefined; + if ( + !contractFields + || contractFields.feature !== "remote-access-v1" + || contractFields.policySemanticsVersion !== POLICY_SEMANTICS_VERSION + || contractFields.policySemanticsHash !== POLICY_SEMANTICS_HASH + ) { + throw new Error("Remote Intercom policy contract is absent or incompatible"); + } + if (!isRemoteAccessMetadata(brokerMessage.access)) { + throw new Error("Remote Intercom registration omitted broker-owned provenance"); + } + if (this.remoteAccessCredential.enrollment) { + writeRemoteSessionCredential(this.remoteAccessCredential.path, brokerMessage.sessionId, brokerMessage.access); + } else { + const reconnect = this.remoteAccessCredential.access; + if (!("sessionId" in reconnect) || reconnect.sessionId !== brokerMessage.sessionId || reconnect.generation !== brokerMessage.access.generation) { + throw new Error("Remote Intercom reconnect identity or generation changed unexpectedly"); + } + } + } + this._sessionId = brokerMessage.sessionId; this.outbox = new PersistentOutboundOutbox(brokerMessage.sessionId); this.replayOutbox(); diff --git a/broker/paths.test.ts b/broker/paths.test.ts index 3af68a5..20502de 100644 --- a/broker/paths.test.ts +++ b/broker/paths.test.ts @@ -7,11 +7,15 @@ import { ensureIntercomRuntimeDir, getAgentDirPath, getBrokerConnectTarget, + getBrokerAccessStateFilePath, + getBrokerAdminCredentialFilePath, getBrokerAskStateFilePath, + getBrokerAuditFilePath, getBrokerListenTarget, getBrokerPortFilePath, getBrokerSocketPath, getIntercomDirPath, + getRemoteGatewaySocketPath, INTERCOM_DIR_MODE, INTERCOM_RUNTIME_FILE_MODE, INTERCOM_TCP_HOST, @@ -37,6 +41,9 @@ test("getAgentDirPath resolves relative PI_CODING_AGENT_DIR values from the call test("getIntercomDirPath points at the intercom runtime directory under the agent dir", () => { assert.equal(getIntercomDirPath("/tmp/pi-agent"), join("/tmp/pi-agent", "intercom")); assert.equal(getBrokerAskStateFilePath("/tmp/pi-agent/intercom"), join("/tmp/pi-agent/intercom", "broker-asks.json")); + assert.equal(getBrokerAccessStateFilePath("/tmp/pi-agent/intercom"), join("/tmp/pi-agent/intercom", "broker-access.json")); + assert.equal(getBrokerAdminCredentialFilePath("/tmp/pi-agent/intercom"), join("/tmp/pi-agent/intercom", "broker-admin.json")); + assert.equal(getBrokerAuditFilePath("/tmp/pi-agent/intercom"), join("/tmp/pi-agent/intercom", "broker-audit.jsonl")); }); test("getBrokerSocketPath uses named pipe on Windows", () => { @@ -45,9 +52,12 @@ test("getBrokerSocketPath uses named pipe on Windows", () => { assert.doesNotMatch(pipePath, /broker\.sock$/); }); -test("getBrokerSocketPath uses broker.sock under PI_CODING_AGENT_DIR on non-Windows", () => { +test("local and remote broker sockets are physically distinct", () => { const socketPath = getBrokerSocketPath("linux", "/tmp/pi-agent"); + const remotePath = getRemoteGatewaySocketPath("linux", "/tmp/pi-agent"); assert.equal(socketPath, join("/tmp/pi-agent", "intercom", "broker.sock")); + assert.equal(remotePath, join("/tmp/pi-agent", "intercom", "remote-gateway.sock")); + assert.notEqual(socketPath, remotePath); }); test("Windows TCP transport is opt-in", () => { diff --git a/broker/paths.ts b/broker/paths.ts index ad3f063..95285e1 100644 --- a/broker/paths.ts +++ b/broker/paths.ts @@ -66,6 +66,28 @@ export function getBrokerAskStateFilePath(intercomDir: string = getIntercomDirPa return join(intercomDir, "broker-asks.json"); } +export function getBrokerAccessStateFilePath(intercomDir: string = getIntercomDirPath()): string { + return join(intercomDir, "broker-access.json"); +} + +export function getBrokerAdminCredentialFilePath(intercomDir: string = getIntercomDirPath()): string { + return join(intercomDir, "broker-admin.json"); +} + +export function getBrokerAuditFilePath(intercomDir: string = getIntercomDirPath()): string { + return join(intercomDir, "broker-audit.jsonl"); +} + +export function getRemoteGatewaySocketPath( + platform: NodeJS.Platform = process.platform, + agentDir: string = getAgentDirPath(), +): string { + if (platform === "win32") { + return `\\\\.\\pipe\\pi-intercom-remote-${sanitizePipeSegment(agentDir)}`; + } + return join(getIntercomDirPath(agentDir), "remote-gateway.sock"); +} + export function getBrokerSocketPath( platform: NodeJS.Platform = process.platform, agentDir: string = getAgentDirPath(), diff --git a/broker/remote-access.integration.test.ts b/broker/remote-access.integration.test.ts new file mode 100644 index 0000000..9f430b7 --- /dev/null +++ b/broker/remote-access.integration.test.ts @@ -0,0 +1,516 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import net from "node:net"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { createMessageReader, writeMessage } from "./framing.ts"; + +const repoDir = resolve(import.meta.dirname, ".."); + +class RawPeer { + readonly messages: unknown[] = []; + private waiters: Array<{ predicate: (message: any) => boolean; resolve: (message: any) => void; reject: (error: Error) => void; timeout: NodeJS.Timeout }> = []; + + constructor(readonly socket: net.Socket) { + socket.on("data", createMessageReader((message) => { + this.messages.push(message); + for (const waiter of [...this.waiters]) { + if (waiter.predicate(message)) { + clearTimeout(waiter.timeout); + this.waiters.splice(this.waiters.indexOf(waiter), 1); + waiter.resolve(message); + } + } + }, (error) => { + for (const waiter of this.waiters.splice(0)) { + clearTimeout(waiter.timeout); + waiter.reject(error); + } + })); + } + + send(message: unknown): void { + writeMessage(this.socket, message); + } + + waitFor(predicate: (message: any) => boolean, timeoutMs = 5000): Promise { + const existing = this.messages.find(predicate); + if (existing) return Promise.resolve(existing); + return new Promise((resolve, reject) => { + const waiter = { + predicate, + resolve, + reject, + timeout: setTimeout(() => { + this.waiters.splice(this.waiters.indexOf(waiter), 1); + reject(new Error(`Timed out waiting for broker message; received ${JSON.stringify(this.messages)}`)); + }, timeoutMs), + }; + this.waiters.push(waiter); + }); + } + + close(): void { + this.socket.destroy(); + } +} + +async function connect(path: string): Promise { + const socket = net.connect(path); + await once(socket, "connect"); + return new RawPeer(socket); +} + +async function waitForBrokerReady(broker: ReturnType): Promise { + const stdout = broker.stdout; + if (!stdout) throw new Error("broker stdout is unavailable"); + await new Promise((resolveReady, reject) => { + const timeout = setTimeout(() => reject(new Error("broker startup timeout")), 10_000); + stdout.on("data", function onData(chunk: Buffer) { + if (chunk.toString().includes("Intercom broker started")) { + clearTimeout(timeout); + stdout.off("data", onData); + resolveReady(); + } + }); + broker.once("exit", (code) => reject(new Error(`broker exited early: ${code}`))); + }); +} + +function registration(name: string, sessionId: string, access?: unknown) { + return { + type: "register", + protocol: "pi-intercom", + version: 3, + sessionId, + ...(access ? { access } : {}), + session: { + name, + cwd: repoDir, + model: "test-model", + pid: process.pid, + startedAt: Date.now(), + lastActivity: Date.now(), + }, + }; +} + +test("authenticated remote gateway assigns identity and enforces ownership-tree visibility", { concurrency: false }, async () => { + const agentDir = mkdtempSync(join(tmpdir(), "agent-intercom-remote-gateway-")); + const intercomDir = join(agentDir, "intercom"); + const localPath = join(intercomDir, "broker.sock"); + const remotePath = join(intercomDir, "remote-gateway.sock"); + const broker = spawn(process.execPath, ["--import", "tsx", join(repoDir, "broker", "broker.ts")], { + cwd: repoDir, + env: { ...process.env, PI_CODING_AGENT_DIR: agentDir, HOME: agentDir, USERPROFILE: agentDir, PI_INTERCOM_REMOTE_EXPIRY_SWEEP_MS: "50" }, + stdio: ["ignore", "pipe", "pipe"], + }); + const peers: RawPeer[] = []; + try { + await waitForBrokerReady(broker); + + const health = await connect(remotePath); + peers.push(health); + health.send({ type: "health", requestId: "health-1" }); + assert.deepEqual(await health.waitFor((message) => message.type === "health_ok"), { + type: "health_ok", + requestId: "health-1", + protocol: "pi-intercom", + version: 3, + endpoint: "remote", + remoteAccess: { + feature: "remote-access-v1", + policySemanticsVersion: 2, + policySemanticsHash: "f3b00e503631bc91123aedfbcf1df72cc9913e1893c09728b2c598f3dcdfdfe0", + }, + }); + + const unauthorized = await connect(remotePath); + peers.push(unauthorized); + unauthorized.send(registration("forged", "forged-id")); + assert.equal((await unauthorized.waitFor((message) => message.type === "error")).code, "ACCESS_DENIED"); + + const root = await connect(localPath); + peers.push(root); + root.send(registration("local-root", "local-root")); + await root.waitFor((message) => message.type === "registered"); + + const unrelated = await connect(localPath); + peers.push(unrelated); + unrelated.send(registration("unrelated", "unrelated")); + await unrelated.waitFor((message) => message.type === "registered"); + + const adminToken = JSON.parse(readFileSync(join(intercomDir, "broker-admin.json"), "utf8")).adminToken; + const control = await connect(localPath); + peers.push(control); + control.send({ + type: "access_control", + requestId: "enroll-1", + adminToken, + action: "issue_enrollment", + enrollment: { + name: "ika/manager", + parentSessionId: "local-root", + rootSessionId: "local-root", + remoteHostId: "ika-dev-v3", + canDelegate: true, + maxDepth: 3, + maxChildren: 2, + }, + }); + const enrollment = await control.waitFor((message) => message.type === "access_control_result"); + assert.equal(typeof enrollment.enrollmentToken, "string"); + + const remote = await connect(remotePath); + peers.push(remote); + remote.send(registration("attacker-selected-name", "attacker-selected-id", { enrollmentToken: enrollment.enrollmentToken })); + const registered = await remote.waitFor((message) => message.type === "registered"); + assert.notEqual(registered.sessionId, "attacker-selected-id"); + assert.equal(registered.access.remoteHostId, "ika-dev-v3"); + assert.equal(registered.access.parentSessionId, "local-root"); + assert.equal(registered.access.sessionCredential.length > 20, true); + assert.equal(registered.access.canDelegate, true); + assert.equal(registered.access.depth, 1); + assert.equal(registered.access.maxDepth, 3); + assert.equal(registered.access.maxChildren, 2); + + const issueChild = async (parent: any, name: string, options: Record = {}) => { + const controlPeer = await connect(remotePath); + peers.push(controlPeer); + controlPeer.send({ + type: "access_control", + requestId: `delegate-${name}`, + action: "issue_child_enrollment", + access: { + sessionCredential: parent.access.sessionCredential, + sessionId: parent.sessionId, + generation: parent.access.generation, + }, + enrollment: { name, ...options }, + }); + return await controlPeer.waitFor((message) => + (message.type === "access_control_result" && message.requestId === `delegate-${name}`) || message.type === "error" + ); + }; + const connectChild = async (enrollmentToken: string, selectedName: string) => { + const childPeer = await connect(remotePath); + peers.push(childPeer); + childPeer.send(registration(selectedName, `selected-${selectedName}`, { enrollmentToken })); + return { peer: childPeer, registered: await childPeer.waitFor((message) => message.type === "registered") }; + }; + + const leadEnrollment = await issueChild(registered, "ika/lead", { canDelegate: true, maxDepth: 3, maxChildren: 1 }); + assert.equal(leadEnrollment.action, "issue_child_enrollment"); + const lead = await connectChild(leadEnrollment.enrollmentToken, "forged-lead"); + assert.equal(lead.registered.access.parentSessionId, registered.sessionId); + assert.equal(lead.registered.access.rootSessionId, "local-root"); + assert.equal(lead.registered.access.depth, 2); + + const workerEnrollment = await issueChild(lead.registered, "ika/worker"); + const worker = await connectChild(workerEnrollment.enrollmentToken, "forged-worker"); + assert.equal(worker.registered.access.parentSessionId, lead.registered.sessionId); + assert.equal(worker.registered.access.depth, 3); + assert.equal(worker.registered.access.canDelegate, false); + + const siblingEnrollment = await issueChild(registered, "ika/sibling"); + const sibling = await connectChild(siblingEnrollment.enrollmentToken, "forged-sibling"); + const exhausted = await issueChild(registered, "ika/too-many"); + assert.equal(exhausted.type, "error"); + assert.equal(exhausted.code, "ACCESS_DENIED"); + + worker.peer.send({ type: "list", requestId: "worker-tree" }); + const workerTree = await worker.peer.waitFor((message) => message.type === "sessions" && message.requestId === "worker-tree"); + assert.deepEqual(workerTree.sessions.map((session: any) => session.id).sort(), ["local-root", registered.sessionId, lead.registered.sessionId, worker.registered.sessionId].sort()); + assert.equal(workerTree.sessions.some((session: any) => session.id === sibling.registered.sessionId), false); + + remote.send({ type: "list", requestId: "manager-tree" }); + const managerTree = await remote.waitFor((message) => message.type === "sessions" && message.requestId === "manager-tree"); + assert.deepEqual(managerTree.sessions.map((session: any) => session.id).sort(), ["local-root", registered.sessionId, lead.registered.sessionId, worker.registered.sessionId, sibling.registered.sessionId].sort()); + + const inspectAs = async (principal: any, targetId?: string) => { + const inspectPeer = await connect(remotePath); + peers.push(inspectPeer); + inspectPeer.send({ + type: "access_control", + requestId: `inspect-${principal.sessionId}-${targetId ?? "self"}`, + action: "inspect_tree", + access: { + sessionCredential: principal.access.sessionCredential, + sessionId: principal.sessionId, + generation: principal.access.generation, + }, + ...(targetId ? { principalId: targetId } : {}), + }); + return await inspectPeer.waitFor((message) => message.type === "access_control_result" || message.type === "error"); + }; + const managerInspection = await inspectAs(registered); + assert.deepEqual(new Set(managerInspection.principals.map((principal: any) => principal.id)), new Set([registered.sessionId, lead.registered.sessionId, worker.registered.sessionId, sibling.registered.sessionId])); + assert.equal(managerInspection.principals.every((principal: any) => principal.connected === true && principal.credentialHash === undefined), true); + const forbiddenInspection = await inspectAs(worker.registered, registered.sessionId); + assert.equal(forbiddenInspection.code, "ACCESS_DENIED"); + const workerInspection = await inspectAs(worker.registered); + assert.deepEqual(workerInspection.principals.map((principal: any) => principal.id), [worker.registered.sessionId]); + + const adminInspectionPeer = await connect(localPath); + peers.push(adminInspectionPeer); + adminInspectionPeer.send({ type: "access_control", requestId: "admin-inspect", adminToken, action: "inspect_tree", principalId: registered.sessionId }); + const adminInspection = await adminInspectionPeer.waitFor((message) => message.type === "access_control_result" && message.action === "inspect_tree"); + assert.deepEqual(new Set(adminInspection.principals.map((principal: any) => principal.id)), new Set([registered.sessionId, lead.registered.sessionId, worker.registered.sessionId, sibling.registered.sessionId])); + + const adoptControl = await connect(localPath); + peers.push(adoptControl); + adoptControl.send({ + type: "access_control", + requestId: "adopt-sibling", + adminToken, + action: "adopt_subtree", + principalId: sibling.registered.sessionId, + newParentSessionId: "local-root", + }); + const adopted = await adoptControl.waitFor((message) => message.type === "access_control_result" && message.action === "adopt_subtree"); + assert.equal(adopted.principals.length, 1); + assert.equal(adopted.principals[0].id, sibling.registered.sessionId); + assert.equal(adopted.principals[0].parentSessionId, "local-root"); + assert.equal(adopted.principals[0].generation, 2); + assert.equal(adopted.principals[0].connected, false); + + const staleAdoptedReconnect = await connect(remotePath); + peers.push(staleAdoptedReconnect); + staleAdoptedReconnect.send(registration("stale-adopted", "stale-adopted", { + sessionCredential: sibling.registered.access.sessionCredential, + sessionId: sibling.registered.sessionId, + generation: 1, + })); + assert.equal((await staleAdoptedReconnect.waitFor((message) => message.type === "error")).code, "ACCESS_DENIED"); + const adoptedSibling = await connect(remotePath); + peers.push(adoptedSibling); + adoptedSibling.send(registration("adopted", "adopted", { + sessionCredential: sibling.registered.access.sessionCredential, + sessionId: sibling.registered.sessionId, + generation: 2, + })); + await adoptedSibling.waitFor((message) => message.type === "registered"); + remote.send({ type: "list", requestId: "manager-after-adoption" }); + const afterAdoption = await remote.waitFor((message) => message.type === "sessions" && message.requestId === "manager-after-adoption"); + assert.equal(afterAdoption.sessions.some((session: any) => session.id === sibling.registered.sessionId), false); + + const reusedEnrollment = await connect(remotePath); + peers.push(reusedEnrollment); + reusedEnrollment.send(registration("other", "other", { enrollmentToken: enrollment.enrollmentToken })); + assert.equal((await reusedEnrollment.waitFor((message) => message.type === "error")).code, "ACCESS_DENIED"); + + root.send({ type: "list", requestId: "root-list" }); + const rootSessions = await root.waitFor((message) => message.type === "sessions" && message.requestId === "root-list"); + assert.deepEqual(rootSessions.sessions.map((session: any) => session.id).sort(), [registered.sessionId, lead.registered.sessionId, worker.registered.sessionId, sibling.registered.sessionId, "local-root", "unrelated"].sort()); + assert.equal(rootSessions.sessions.find((session: any) => session.id === registered.sessionId).name, "ika/manager"); + + unrelated.send({ type: "list", requestId: "unrelated-list" }); + const unrelatedSessions = await unrelated.waitFor((message) => message.type === "sessions" && message.requestId === "unrelated-list"); + assert.deepEqual(unrelatedSessions.sessions.map((session: any) => session.id).sort(), ["local-root", "unrelated"]); + + remote.send({ type: "list", requestId: "remote-list" }); + const remoteSessions = await remote.waitFor((message) => message.type === "sessions" && message.requestId === "remote-list"); + assert.deepEqual(remoteSessions.sessions.map((session: any) => session.id).sort(), [registered.sessionId, lead.registered.sessionId, worker.registered.sessionId, "local-root"].sort()); + + remote.send({ + type: "send", + to: "unrelated", + message: { id: "denied-message", timestamp: Date.now(), content: { text: "should be hidden" } }, + }); + const denied = await remote.waitFor((message) => message.type === "delivery_failed" && message.messageId === "denied-message"); + assert.equal(denied.code, "SESSION_NOT_FOUND"); + + const duplicate = await connect(remotePath); + peers.push(duplicate); + duplicate.send(registration("replacement", "replacement", { + sessionCredential: registered.access.sessionCredential, + sessionId: registered.sessionId, + generation: registered.access.generation, + })); + assert.equal((await duplicate.waitFor((message) => message.type === "error")).code, "ACCESS_DENIED"); + + remote.close(); + await root.waitFor((message) => message.type === "session_left" && message.sessionId === registered.sessionId); + const reconnect = await connect(remotePath); + peers.push(reconnect); + reconnect.send(registration("changed-name", "changed-id", { + sessionCredential: registered.access.sessionCredential, + sessionId: registered.sessionId, + generation: registered.access.generation, + })); + const reconnected = await reconnect.waitFor((message) => message.type === "registered"); + assert.equal(reconnected.sessionId, registered.sessionId); + assert.equal(reconnected.access.sessionCredential, undefined); + + root.send({ + type: "send", + to: registered.sessionId, + message: { id: "delivered-before-revoke", timestamp: 100, content: { text: "must not replay after revoke" } }, + }); + const deliveredBeforeRevoke = await reconnect.waitFor((message) => message.type === "message" && message.message.id === "delivered-before-revoke"); + reconnect.send({ type: "message_received", deliveryId: deliveredBeforeRevoke.deliveryId }); + await root.waitFor((message) => message.type === "delivered" && message.messageId === "delivered-before-revoke"); + + root.send({ + type: "send", + to: registered.sessionId, + message: { id: "revoked-pending", timestamp: Date.now(), expectsReply: true, content: { text: "pending during revoke" } }, + }); + await root.waitFor((message) => message.type === "delivery_accepted" && message.messageId === "revoked-pending"); + await reconnect.waitFor((message) => message.type === "message" && message.message.id === "revoked-pending"); + + const revokeControl = await connect(localPath); + peers.push(revokeControl); + revokeControl.send({ + type: "access_control", + requestId: "revoke-1", + adminToken, + action: "revoke_subtree", + principalId: registered.sessionId, + }); + const revoked = await revokeControl.waitFor((message) => message.type === "access_control_result" && message.action === "revoke_subtree"); + assert.deepEqual(new Set(revoked.changedPrincipalIds), new Set([registered.sessionId, lead.registered.sessionId, worker.registered.sessionId])); + const revokedDelivery = await root.waitFor((message) => message.type === "delivery_failed" && message.messageId === "revoked-pending"); + assert.equal(revokedDelivery.code, "RECIPIENT_DISCONNECTED"); + + root.send({ + type: "send", + to: registered.sessionId, + message: { id: "delivered-before-revoke", timestamp: 100, content: { text: "must not replay after revoke" } }, + }); + const fencedReplay = await root.waitFor((message) => message.type === "delivery_failed" && message.messageId === "delivered-before-revoke"); + assert.equal(fencedReplay.code, "SESSION_NOT_FOUND"); + + const revokedReconnect = await connect(remotePath); + peers.push(revokedReconnect); + revokedReconnect.send(registration("revoked", "revoked", { + sessionCredential: registered.access.sessionCredential, + sessionId: registered.sessionId, + generation: registered.access.generation, + })); + assert.equal((await revokedReconnect.waitFor((message) => message.type === "error")).code, "ACCESS_DENIED"); + + const clientEnrollmentControl = await connect(localPath); + peers.push(clientEnrollmentControl); + clientEnrollmentControl.send({ + type: "access_control", + requestId: "enroll-client", + adminToken, + action: "issue_enrollment", + enrollment: { + name: "ika/client", + parentSessionId: "local-root", + rootSessionId: "local-root", + remoteHostId: "ika-dev-v3", + }, + }); + const clientEnrollment = await clientEnrollmentControl.waitFor((message) => message.type === "access_control_result" && message.requestId === "enroll-client"); + const remoteClientDir = join(agentDir, "remote-client"); + const remoteClientIntercom = join(remoteClientDir, "intercom"); + const credentialPath = join(remoteClientDir, "credential.json"); + mkdirSync(remoteClientIntercom, { recursive: true, mode: 0o700 }); + symlinkSync(remotePath, join(remoteClientIntercom, "broker.sock")); + writeFileSync(credentialPath, JSON.stringify({ version: 1, enrollmentToken: clientEnrollment.enrollmentToken }), { mode: 0o600 }); + const previousAgentDir = process.env.PI_CODING_AGENT_DIR; + const previousCredentialPath = process.env.AGENT_INTERCOM_ACCESS_CREDENTIAL_PATH; + process.env.PI_CODING_AGENT_DIR = remoteClientDir; + process.env.AGENT_INTERCOM_ACCESS_CREDENTIAL_PATH = credentialPath; + try { + const { IntercomClient } = await import("./client.ts"); + const firstClient = new IntercomClient(); + await firstClient.connect({ + name: "client-selected-name", + cwd: repoDir, + model: "test-client", + pid: process.pid, + startedAt: Date.now(), + lastActivity: Date.now(), + }, "client-selected-id"); + const assignedClientId = firstClient.sessionId; + assert.ok(assignedClientId && assignedClientId !== "client-selected-id"); + const persistedCredential = JSON.parse(readFileSync(credentialPath, "utf8")); + assert.equal(persistedCredential.sessionId, assignedClientId); + assert.equal(typeof persistedCredential.sessionCredential, "string"); + assert.equal(persistedCredential.enrollmentToken, undefined); + const clientVisible = await firstClient.listSessions(); + assert.deepEqual(clientVisible.map((session) => session.id).sort(), [assignedClientId, "local-root"].sort()); + assert.equal(clientVisible.find((session) => session.id === assignedClientId)?.name, "ika/client"); + await firstClient.disconnect(); + + const secondClient = new IntercomClient(); + await secondClient.connect({ + name: "changed-client-name", + cwd: repoDir, + model: "test-client", + pid: process.pid, + startedAt: Date.now(), + lastActivity: Date.now(), + }, "another-selected-id"); + assert.equal(secondClient.sessionId, assignedClientId); + await secondClient.disconnect(); + } finally { + if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousAgentDir; + if (previousCredentialPath === undefined) delete process.env.AGENT_INTERCOM_ACCESS_CREDENTIAL_PATH; + else process.env.AGENT_INTERCOM_ACCESS_CREDENTIAL_PATH = previousCredentialPath; + } + + const expiringControl = await connect(localPath); + peers.push(expiringControl); + expiringControl.send({ + type: "access_control", + requestId: "enroll-expiring", + adminToken, + action: "issue_enrollment", + enrollment: { + name: "ika/expiring", + parentSessionId: "local-root", + rootSessionId: "local-root", + remoteHostId: "ika-dev-v3", + expiresAt: Date.now() + 800, + }, + }); + const expiringEnrollment = await expiringControl.waitFor((message) => message.type === "access_control_result" && message.requestId === "enroll-expiring"); + const expiring = await connect(remotePath); + peers.push(expiring); + expiring.send(registration("expiring", "expiring", { enrollmentToken: expiringEnrollment.enrollmentToken })); + const expiringRegistered = await expiring.waitFor((message) => message.type === "registered"); + await new Promise((resolve, reject) => { + if (expiring.socket.destroyed) return resolve(); + const timeout = setTimeout(() => reject(new Error("expired principal remained connected")), 3000); + expiring.socket.once("close", () => { clearTimeout(timeout); resolve(); }); + }); + const expiredReconnect = await connect(remotePath); + peers.push(expiredReconnect); + expiredReconnect.send(registration("expired", "expired", { + sessionCredential: expiringRegistered.access.sessionCredential, + sessionId: expiringRegistered.sessionId, + generation: expiringRegistered.access.generation, + })); + assert.equal((await expiredReconnect.waitFor((message) => message.type === "error")).code, "ACCESS_DENIED"); + + const auditText = readFileSync(join(intercomDir, "broker-audit.jsonl"), "utf8"); + const auditEvents = auditText.trim().split("\n").map((line) => JSON.parse(line).event); + assert.ok(auditEvents.includes("enrollment_issued")); + assert.ok(auditEvents.includes("enrollment_consumed")); + assert.ok(auditEvents.includes("remote_connect")); + assert.ok(auditEvents.includes("remote_reconnect")); + assert.ok(auditEvents.includes("remote_delivery_denied")); + assert.ok(auditEvents.includes("credential_reuse_denied")); + assert.ok(auditEvents.includes("principal_revoked")); + assert.ok(auditEvents.includes("tree_inspected")); + assert.ok(auditEvents.includes("principal_expired")); + assert.ok(auditEvents.includes("principal_adopted")); + assert.equal(auditText.includes(enrollment.enrollmentToken), false); + assert.equal(auditText.includes(registered.access.sessionCredential), false); + } finally { + for (const peer of peers) peer.close(); + broker.kill("SIGTERM"); + await Promise.race([once(broker, "exit"), new Promise((resolveTimeout) => setTimeout(resolveTimeout, 3000))]); + if (broker.exitCode === null) broker.kill("SIGKILL"); + rmSync(agentDir, { recursive: true, force: true }); + } +}); diff --git a/broker/spawn.test.ts b/broker/spawn.test.ts index 2b5fa33..4b36d6f 100644 --- a/broker/spawn.test.ts +++ b/broker/spawn.test.ts @@ -162,10 +162,16 @@ test("getBrokerSpawnOptions passes an absolute PI_CODING_AGENT_DIR to the broker assert.equal(options.env.PI_CODING_AGENT_DIR, path.resolve("relative-agent")); }); -test("isBrokerHealthOkMessage requires the intercom protocol marker", () => { - assert.equal(isBrokerHealthOkMessage({ type: "health_ok", requestId: "req-1", protocol: "pi-intercom", version: 3 }, "req-1"), true); - assert.equal(isBrokerHealthOkMessage({ type: "health_ok", requestId: "req-1" }, "req-1"), false); - assert.equal(isBrokerHealthOkMessage({ type: "health_ok", requestId: "req-2", protocol: "pi-intercom", version: 3 }, "req-1"), false); +test("isBrokerHealthOkMessage pins the remote policy semantic contract", () => { + const compatible = { + type: "health_ok", requestId: "req-1", protocol: "pi-intercom", version: 3, endpoint: "local", + remoteAccess: { feature: "remote-access-v1", policySemanticsVersion: 2, policySemanticsHash: "f3b00e503631bc91123aedfbcf1df72cc9913e1893c09728b2c598f3dcdfdfe0" }, + }; + assert.equal(isBrokerHealthOkMessage(compatible, "req-1"), true); + assert.equal(isBrokerHealthOkMessage({ ...compatible, endpoint: "remote" }, "req-1"), false); + assert.equal(isBrokerHealthOkMessage({ ...compatible, remoteAccess: undefined }, "req-1"), false); + assert.equal(isBrokerHealthOkMessage({ ...compatible, remoteAccess: { ...compatible.remoteAccess, policySemanticsHash: "wrong" } }, "req-1"), false); + assert.equal(isBrokerHealthOkMessage({ ...compatible, requestId: "req-2" }, "req-1"), false); assert.equal(isBrokerHealthOkMessage("ok", "req-1"), false); }); diff --git a/broker/spawn.ts b/broker/spawn.ts index 1173c62..e75df07 100644 --- a/broker/spawn.ts +++ b/broker/spawn.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "url"; import { createRequire } from "module"; import net from "net"; import { randomUUID } from "crypto"; +import { POLICY_SEMANTICS_HASH, POLICY_SEMANTICS_VERSION } from "@dataforxyz/agent-intercom-core"; import { createMessageReader, writeMessage } from "./framing.ts"; import { ensureIntercomRuntimeDir, @@ -117,10 +118,19 @@ export function isBrokerHealthOkMessage(message: unknown, requestId: string): bo return false; } const response = message as Record; - return response.type === "health_ok" - && response.requestId === requestId - && response.protocol === INTERCOM_PROTOCOL_NAME - && response.version === INTERCOM_PROTOCOL_VERSION; + if ( + response.type !== "health_ok" + || response.requestId !== requestId + || response.protocol !== INTERCOM_PROTOCOL_NAME + || response.version !== INTERCOM_PROTOCOL_VERSION + || response.endpoint !== "local" + ) return false; + const remoteAccess = response.remoteAccess; + if (typeof remoteAccess !== "object" || remoteAccess === null || Array.isArray(remoteAccess)) return false; + const contract = remoteAccess as Record; + return contract.feature === "remote-access-v1" + && contract.policySemanticsVersion === POLICY_SEMANTICS_VERSION + && contract.policySemanticsHash === POLICY_SEMANTICS_HASH; } function writeWindowsHiddenLauncher( diff --git a/dist/broker.mjs b/dist/broker.mjs index 03fb0d4..980e07d 100644 --- a/dist/broker.mjs +++ b/dist/broker.mjs @@ -1,8 +1,214 @@ // broker/broker.ts import net from "net"; -import { existsSync, readFileSync as readFileSync3, renameSync as renameSync2, writeFileSync as writeFileSync3, unlinkSync as unlinkSync2 } from "fs"; +import { existsSync as existsSync2, readFileSync as readFileSync4, renameSync as renameSync2, writeFileSync as writeFileSync3, unlinkSync as unlinkSync2 } from "fs"; import { join as join2 } from "path"; -import { randomUUID as randomUUID2 } from "crypto"; +import { randomUUID as randomUUID3 } from "crypto"; + +// node_modules/@dataforxyz/agent-intercom-core/dist/policy.js +var POLICY_SEMANTICS_VERSION = 2; +function activePrincipal(state, id) { + return state.principals[id]; +} +function isDirectParentPair(left, right) { + return left.parentSessionId === right.id || right.parentSessionId === left.id; +} +function isAncestor(state, ancestorId, descendantId) { + if (ancestorId === descendantId) + return false; + const visited = /* @__PURE__ */ new Set(); + let current = state.principals[descendantId]; + while (current?.parentSessionId && !visited.has(current.id)) { + if (current.parentSessionId === ancestorId) + return true; + visited.add(current.id); + current = state.principals[current.parentSessionId]; + } + return false; +} +function authorize(state, actorId, action, targetId, context = {}) { + const actor = activePrincipal(state, actorId); + const target = activePrincipal(state, targetId); + if (!actor || !target) + return { allowed: false, code: "UNKNOWN_PRINCIPAL" }; + if (actor.state !== "active" || target.state !== "active") + return { allowed: false, code: "REVOKED_PRINCIPAL" }; + if (context.actorGeneration !== void 0 && context.actorGeneration !== actor.generation || context.targetGeneration !== void 0 && context.targetGeneration !== target.generation) { + return { allowed: false, code: "STALE_GENERATION" }; + } + if (actor.id === target.id) + return { allowed: true, reason: "self" }; + if (actor.kind === "local" && target.kind === "local") + return { allowed: true, reason: "local-public" }; + if (action === "discover" || action === "send" || action === "ask" || action === "reply") { + if (isDirectParentPair(actor, target)) + return { allowed: true, reason: "direct-parent" }; + if (isAncestor(state, actor.id, target.id) || isAncestor(state, target.id, actor.id)) { + return { allowed: true, reason: "ancestor-chain" }; + } + } + if (action === "inspect_tree" || action === "revoke" || action === "adopt") { + if (isAncestor(state, actor.id, target.id)) + return { allowed: true, reason: "ancestor-control" }; + } + return { allowed: false, code: "POLICY_DENIED" }; +} + +// node_modules/@dataforxyz/agent-intercom-core/dist/policy-vectors.js +var localRoot = { + id: "local-root", + kind: "local", + state: "active", + generation: 1, + policy: "local-public", + rootSessionId: "local-root" +}; +var localPeer = { + id: "local-peer", + kind: "local", + state: "active", + generation: 1, + policy: "local-public", + rootSessionId: "local-peer" +}; +var remoteManager = { + id: "remote-manager", + kind: "remote", + state: "active", + generation: 1, + policy: "remote-tree", + parentSessionId: "local-root", + rootSessionId: "local-root" +}; +var remoteChild = { + id: "remote-child", + kind: "remote", + state: "active", + generation: 1, + policy: "remote-tree", + parentSessionId: "remote-manager", + rootSessionId: "local-root" +}; +var remoteSibling = { + id: "remote-sibling", + kind: "remote", + state: "active", + generation: 1, + policy: "remote-tree", + parentSessionId: "remote-manager", + rootSessionId: "local-root" +}; +var POLICY_VECTORS = [ + { + name: "local sessions remain public", + principals: [localRoot, localPeer], + actorId: "local-root", + action: "send", + targetId: "local-peer", + expectedAllowed: true, + expectedReasonOrCode: "local-public" + }, + { + name: "remote manager can reach direct local parent", + principals: [localRoot, remoteManager], + actorId: "remote-manager", + action: "send", + targetId: "local-root", + expectedAllowed: true, + expectedReasonOrCode: "direct-parent" + }, + { + name: "local parent can reach direct remote child", + principals: [localRoot, remoteManager], + actorId: "local-root", + action: "ask", + targetId: "remote-manager", + expectedAllowed: true, + expectedReasonOrCode: "direct-parent" + }, + { + name: "remote child can reach its local root through the ancestor chain", + principals: [localRoot, remoteManager, remoteChild], + actorId: "remote-child", + action: "send", + targetId: "local-root", + expectedAllowed: true, + expectedReasonOrCode: "ancestor-chain" + }, + { + name: "remote siblings cannot communicate in phase one", + principals: [localRoot, remoteManager, remoteChild, remoteSibling], + actorId: "remote-child", + action: "discover", + targetId: "remote-sibling", + expectedAllowed: false, + expectedReasonOrCode: "POLICY_DENIED" + }, + { + name: "unrelated local session cannot discover remote principal", + principals: [localRoot, localPeer, remoteManager], + actorId: "local-peer", + action: "discover", + targetId: "remote-manager", + expectedAllowed: false, + expectedReasonOrCode: "POLICY_DENIED" + }, + { + name: "remote principal cannot reach unrelated local session", + principals: [localRoot, localPeer, remoteManager], + actorId: "remote-manager", + action: "send", + targetId: "local-peer", + expectedAllowed: false, + expectedReasonOrCode: "POLICY_DENIED" + }, + { + name: "remote manager may inspect its descendant subtree", + principals: [localRoot, remoteManager, remoteChild], + actorId: "remote-manager", + action: "inspect_tree", + targetId: "remote-child", + expectedAllowed: true, + expectedReasonOrCode: "ancestor-control" + }, + { + name: "remote child cannot revoke its ancestor", + principals: [localRoot, remoteManager, remoteChild], + actorId: "remote-child", + action: "revoke", + targetId: "remote-manager", + expectedAllowed: false, + expectedReasonOrCode: "POLICY_DENIED" + }, + { + name: "remote principal may request attenuated delegation under itself", + principals: [localRoot, remoteManager], + actorId: "remote-manager", + action: "delegate_child", + targetId: "remote-manager", + expectedAllowed: true, + expectedReasonOrCode: "self" + }, + { + name: "revoked principal cannot communicate", + principals: [localRoot, { ...remoteManager, state: "revoked" }], + actorId: "remote-manager", + action: "send", + targetId: "local-root", + expectedAllowed: false, + expectedReasonOrCode: "REVOKED_PRINCIPAL" + }, + { + name: "stale actor generation cannot send", + principals: [localRoot, { ...remoteManager, generation: 2 }], + actorId: "remote-manager", + action: "send", + targetId: "local-root", + context: { actorGeneration: 1 }, + expectedAllowed: false, + expectedReasonOrCode: "STALE_GENERATION" + } +]; +var POLICY_SEMANTICS_HASH = "f3b00e503631bc91123aedfbcf1df72cc9913e1893c09728b2c598f3dcdfdfe0"; // broker/framing.ts var MAX_FRAME_BYTES = 1024 * 1024; @@ -107,6 +313,21 @@ function getBrokerPortFilePath(intercomDir = getIntercomDirPath()) { function getBrokerAskStateFilePath(intercomDir = getIntercomDirPath()) { return join(intercomDir, "broker-asks.json"); } +function getBrokerAccessStateFilePath(intercomDir = getIntercomDirPath()) { + return join(intercomDir, "broker-access.json"); +} +function getBrokerAdminCredentialFilePath(intercomDir = getIntercomDirPath()) { + return join(intercomDir, "broker-admin.json"); +} +function getBrokerAuditFilePath(intercomDir = getIntercomDirPath()) { + return join(intercomDir, "broker-audit.jsonl"); +} +function getRemoteGatewaySocketPath(platform = process.platform, agentDir = getAgentDirPath()) { + if (platform === "win32") { + return `\\\\.\\pipe\\pi-intercom-remote-${sanitizePipeSegment(agentDir)}`; + } + return join(getIntercomDirPath(agentDir), "remote-gateway.sock"); +} function getBrokerSocketPath(platform = process.platform, agentDir = getAgentDirPath()) { if (platform === "win32") { return `\\\\.\\pipe\\pi-intercom-${sanitizePipeSegment(agentDir)}`; @@ -233,19 +454,449 @@ function hasBrokerOwnership(path, pid = process.pid) { return ownerPid(path) === pid; } +// broker/access-registry.ts +import { createHash, randomBytes, randomUUID as randomUUID2, timingSafeEqual } from "crypto"; +import { existsSync, readFileSync as readFileSync3 } from "fs"; +var REMOTE_ACCESS_STATE_VERSION = 2; +var REMOTE_ACCESS_CREDENTIAL_VERSION = 1; +var DEFAULT_ENROLLMENT_TTL_MS = 10 * 60 * 1e3; +var DEFAULT_PRINCIPAL_TTL_MS = 30 * 24 * 60 * 60 * 1e3; +var RemoteAccessError = class extends Error { + constructor(code, message) { + super(message); + this.code = code; + this.name = "RemoteAccessError"; + } + code; +}; +function emptyState() { + return { version: REMOTE_ACCESS_STATE_VERSION, principals: {}, enrollments: {} }; +} +function hashSecret(secret) { + return createHash("sha256").update(secret, "utf8").digest("hex"); +} +function secretsMatch(secret, expectedHash) { + const actual = Buffer.from(hashSecret(secret), "hex"); + const expected = Buffer.from(expectedHash, "hex"); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} +function newSecret() { + return randomBytes(32).toString("base64url"); +} +function requireText(value, field, maxLength = 512) { + const normalized = value.trim(); + if (!normalized || normalized.length > maxLength || /[\u0000-\u001f\u007f]/.test(normalized)) { + throw new Error(`Invalid ${field}`); + } + return normalized; +} +function boundedInteger(value, fallback, field, minimum, maximum) { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < minimum || resolved > maximum) throw new Error(`Invalid ${field}`); + return resolved; +} +function parseState(raw) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("expected object"); + const state = raw; + if (state.version !== 1 && state.version !== REMOTE_ACCESS_STATE_VERSION) throw new Error("unsupported version"); + if (typeof state.principals !== "object" || state.principals === null || Array.isArray(state.principals)) throw new Error("invalid principals"); + if (typeof state.enrollments !== "object" || state.enrollments === null || Array.isArray(state.enrollments)) throw new Error("invalid enrollments"); + if (state.adminCredentialHash !== void 0 && typeof state.adminCredentialHash !== "string") throw new Error("invalid admin credential hash"); + const principals = {}; + for (const [id, value] of Object.entries(state.principals)) { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`invalid principal ${id}`); + const principal = value; + if (principal.id !== id || typeof principal.name !== "string" || typeof principal.credentialHash !== "string" || typeof principal.parentSessionId !== "string" || typeof principal.rootSessionId !== "string" || typeof principal.remoteHostId !== "string" || typeof principal.generation !== "number" || principal.state !== "active" && principal.state !== "revoked" || typeof principal.expiresAt !== "number" || typeof principal.createdAt !== "number" || typeof principal.updatedAt !== "number") throw new Error(`invalid principal ${id}`); + const depth = boundedInteger(principal.depth, 1, "principal depth", 1, 32); + const maxDepth = boundedInteger(principal.maxDepth, depth, "maximum delegation depth", depth, 32); + const maxChildren = boundedInteger(principal.maxChildren, 0, "maximum child count", 0, 128); + principals[id] = { + ...principal, + policy: "remote-tree", + canDelegate: principal.canDelegate === true, + depth, + maxDepth, + maxChildren + }; + } + const enrollments = {}; + for (const [hash, value] of Object.entries(state.enrollments)) { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`invalid enrollment ${hash}`); + const enrollment = value; + const template = enrollment.template; + if (!template || typeof template !== "object") throw new Error(`invalid enrollment ${hash}`); + const depth = boundedInteger(template.depth, 1, "enrollment depth", 1, 32); + enrollments[hash] = { + ...enrollment, + template: { + ...template, + canDelegate: template.canDelegate === true, + depth, + maxDepth: boundedInteger(template.maxDepth, depth, "enrollment maximum depth", depth, 32), + maxChildren: boundedInteger(template.maxChildren, 0, "enrollment maximum children", 0, 128) + } + }; + } + return { + version: REMOTE_ACCESS_STATE_VERSION, + ...typeof state.adminCredentialHash === "string" ? { adminCredentialHash: state.adminCredentialHash } : {}, + principals, + enrollments + }; +} +var RemoteAccessRegistry = class { + constructor(statePath, now = Date.now) { + this.statePath = statePath; + this.now = now; + this.state = this.load(); + } + statePath; + now; + state; + snapshot() { + return structuredClone(this.state); + } + ensureAdminCredential(credentialPath) { + if (existsSync(credentialPath)) { + const parsed = JSON.parse(readFileSync3(credentialPath, "utf8")); + if (parsed.version === REMOTE_ACCESS_CREDENTIAL_VERSION && typeof parsed.adminToken === "string" && parsed.adminToken.length >= 32) { + const hash = hashSecret(parsed.adminToken); + if (this.state.adminCredentialHash !== hash) { + this.state.adminCredentialHash = hash; + this.persist(); + } + restrictIntercomRuntimeFile(credentialPath); + return parsed.adminToken; + } + } + const adminToken = newSecret(); + writeDurableJson(credentialPath, { version: REMOTE_ACCESS_CREDENTIAL_VERSION, adminToken }); + this.state.adminCredentialHash = hashSecret(adminToken); + this.persist(); + return adminToken; + } + authenticateAdmin(adminToken) { + return typeof this.state.adminCredentialHash === "string" && secretsMatch(adminToken, this.state.adminCredentialHash); + } + issueEnrollment(template, ttlMs = DEFAULT_ENROLLMENT_TTL_MS) { + if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > 24 * 60 * 60 * 1e3) throw new Error("Invalid enrollment TTL"); + const now = this.now(); + const enrollmentToken = newSecret(); + const tokenHash = hashSecret(enrollmentToken); + const expiresAt = now + ttlMs; + const principalExpiresAt = template.expiresAt ?? now + DEFAULT_PRINCIPAL_TTL_MS; + if (!Number.isSafeInteger(principalExpiresAt) || principalExpiresAt <= now) throw new Error("Invalid principal expiry"); + const depth = boundedInteger(template.depth, 1, "principal depth", 1, 32); + const maxDepth = boundedInteger(template.maxDepth, depth, "maximum delegation depth", depth, 32); + const maxChildren = boundedInteger(template.maxChildren, 0, "maximum child count", 0, 128); + const canDelegate = template.canDelegate === true; + if (canDelegate && (maxDepth <= depth || maxChildren === 0)) throw new Error("Delegating principals require remaining depth and child capacity"); + const normalized = { + name: requireText(template.name, "principal name", 256), + parentSessionId: requireText(template.parentSessionId, "parent session ID"), + rootSessionId: requireText(template.rootSessionId, "root session ID"), + remoteHostId: requireText(template.remoteHostId, "remote host ID", 256), + expiresAt: principalExpiresAt, + canDelegate, + depth, + maxDepth, + maxChildren + }; + this.pruneExpiredEnrollments(now); + this.state.enrollments[tokenHash] = { tokenHash, template: normalized, expiresAt, createdAt: now }; + this.persist(); + return { enrollmentToken, expiresAt }; + } + issueChildEnrollment(parentSessionId, parentGeneration, request, ttlMs = DEFAULT_ENROLLMENT_TTL_MS) { + const parent = this.validatePrincipal(parentSessionId, parentGeneration); + if (!parent.canDelegate) throw new RemoteAccessError("INVALID_ENROLLMENT", "Parent principal cannot delegate children"); + const now = this.now(); + const activeChildren = Object.values(this.state.principals).filter( + (principal) => principal.parentSessionId === parent.id && principal.state === "active" && principal.expiresAt > now + ).length; + const pendingChildren = Object.values(this.state.enrollments).filter( + (enrollment) => enrollment.template.parentSessionId === parent.id && enrollment.expiresAt > now + ).length; + if (activeChildren + pendingChildren >= parent.maxChildren) throw new RemoteAccessError("INVALID_ENROLLMENT", "Parent child limit is exhausted"); + const depth = parent.depth + 1; + if (depth > parent.maxDepth) throw new RemoteAccessError("INVALID_ENROLLMENT", "Parent delegation depth is exhausted"); + const maxDepth = boundedInteger(request.maxDepth, depth, "child maximum depth", depth, parent.maxDepth); + const maxChildren = boundedInteger(request.maxChildren, 0, "child maximum count", 0, parent.maxChildren); + const canDelegate = request.canDelegate === true; + if (canDelegate && (maxDepth <= depth || maxChildren === 0)) throw new Error("Delegating child requires remaining depth and child capacity"); + const expiresAt = request.expiresAt ?? parent.expiresAt; + if (!Number.isSafeInteger(expiresAt) || expiresAt <= this.now() || expiresAt > parent.expiresAt) { + throw new Error("Child expiry must not exceed the parent expiry"); + } + return this.issueEnrollment({ + name: request.name, + parentSessionId: parent.id, + rootSessionId: parent.rootSessionId, + remoteHostId: parent.remoteHostId, + expiresAt, + canDelegate, + depth, + maxDepth, + maxChildren + }, ttlMs); + } + consumeEnrollment(enrollmentToken) { + const now = this.now(); + const tokenHash = hashSecret(enrollmentToken); + const enrollment = this.state.enrollments[tokenHash]; + if (!enrollment) throw new RemoteAccessError("INVALID_ENROLLMENT", "Enrollment credential is invalid or already consumed"); + delete this.state.enrollments[tokenHash]; + if (enrollment.expiresAt <= now) { + this.persist(); + throw new RemoteAccessError("INVALID_ENROLLMENT", "Enrollment credential has expired"); + } + const sessionCredential = newSecret(); + const id = randomUUID2(); + const principal = { + id, + name: enrollment.template.name, + credentialHash: hashSecret(sessionCredential), + parentSessionId: enrollment.template.parentSessionId, + rootSessionId: enrollment.template.rootSessionId, + remoteHostId: enrollment.template.remoteHostId, + generation: 1, + policy: "remote-tree", + canDelegate: enrollment.template.canDelegate === true, + depth: enrollment.template.depth, + maxDepth: enrollment.template.maxDepth, + maxChildren: enrollment.template.maxChildren, + state: "active", + expiresAt: enrollment.template.expiresAt, + createdAt: now, + updatedAt: now + }; + this.state.principals[id] = principal; + this.persist(); + return { principal: structuredClone(principal), sessionCredential }; + } + authenticateSession(sessionId, generation, sessionCredential) { + const principal = this.state.principals[sessionId]; + if (!principal || !secretsMatch(sessionCredential, principal.credentialHash)) { + throw new RemoteAccessError("INVALID_CREDENTIAL", "Session credential is invalid"); + } + return this.validatePrincipal(sessionId, generation); + } + validatePrincipal(sessionId, generation) { + const principal = this.state.principals[sessionId]; + if (!principal) throw new RemoteAccessError("INVALID_CREDENTIAL", "Remote principal does not exist"); + if (principal.state !== "active") throw new RemoteAccessError("REVOKED_CREDENTIAL", "Session credential is revoked"); + if (principal.expiresAt <= this.now()) throw new RemoteAccessError("EXPIRED_CREDENTIAL", "Session credential has expired"); + if (principal.generation !== generation) throw new RemoteAccessError("STALE_GENERATION", "Session credential generation is stale"); + return structuredClone(principal); + } + inspectSubtree(principalId) { + const result = []; + const queue = [principalId]; + const seen = /* @__PURE__ */ new Set(); + while (queue.length) { + const id = queue.shift(); + if (seen.has(id)) continue; + seen.add(id); + const principal = this.state.principals[id]; + if (!principal) continue; + const { credentialHash: _credentialHash, ...metadata } = principal; + result.push(structuredClone(metadata)); + for (const candidate of Object.values(this.state.principals)) { + if (candidate.parentSessionId === id) queue.push(candidate.id); + } + } + return result; + } + adoptSubtree(principalId, newParentSessionId, newRootSessionId) { + const principal = this.state.principals[principalId]; + if (!principal) throw new Error("Unknown adopted principal"); + if (principalId === newParentSessionId) throw new Error("Adoption would create an ownership cycle"); + let ancestor = this.state.principals[newParentSessionId]; + const seen = /* @__PURE__ */ new Set(); + while (ancestor && !seen.has(ancestor.id)) { + if (ancestor.id === principalId) throw new Error("Adoption would create an ownership cycle"); + seen.add(ancestor.id); + ancestor = this.state.principals[ancestor.parentSessionId]; + } + const ids = this.subtreePrincipalIds(principalId); + const now = this.now(); + principal.parentSessionId = requireText(newParentSessionId, "new parent session ID"); + for (const id of ids) { + const changed = this.state.principals[id]; + changed.rootSessionId = requireText(newRootSessionId, "new root session ID"); + changed.generation += 1; + changed.updatedAt = now; + } + const changedIds = new Set(ids); + for (const [hash, enrollment] of Object.entries(this.state.enrollments)) { + if (changedIds.has(enrollment.template.parentSessionId)) delete this.state.enrollments[hash]; + } + this.persist(); + return ids.map((id) => structuredClone(this.state.principals[id])); + } + expirePrincipals(now = this.now()) { + const changed = /* @__PURE__ */ new Map(); + const expiredRoots = Object.values(this.state.principals).filter((principal) => principal.state === "active" && principal.expiresAt <= now).sort((left, right) => left.depth - right.depth); + for (const principal of expiredRoots) { + if (changed.has(principal.id)) continue; + for (const revoked of this.revoke(principal.id)) changed.set(revoked.id, revoked); + } + return [...changed.values()]; + } + revoke(principalId) { + const now = this.now(); + const queue = [principalId]; + const seen = /* @__PURE__ */ new Set(); + const changed = []; + while (queue.length) { + const id = queue.shift(); + if (seen.has(id)) continue; + seen.add(id); + const principal = this.state.principals[id]; + if (!principal) continue; + principal.state = "revoked"; + principal.generation += 1; + principal.updatedAt = now; + changed.push(structuredClone(principal)); + for (const candidate of Object.values(this.state.principals)) { + if (candidate.parentSessionId === id) queue.push(candidate.id); + } + } + if (changed.length) this.persist(); + return changed; + } + subtreePrincipalIds(principalId) { + const result = []; + const queue = [principalId]; + const seen = /* @__PURE__ */ new Set(); + while (queue.length) { + const id = queue.shift(); + if (seen.has(id) || !this.state.principals[id]) continue; + seen.add(id); + result.push(id); + for (const candidate of Object.values(this.state.principals)) { + if (candidate.parentSessionId === id) queue.push(candidate.id); + } + } + return result; + } + pruneExpiredEnrollments(now = this.now()) { + for (const [hash, enrollment] of Object.entries(this.state.enrollments)) { + if (enrollment.expiresAt <= now) delete this.state.enrollments[hash]; + } + } + load() { + if (!existsSync(this.statePath)) return emptyState(); + try { + return parseState(JSON.parse(readFileSync3(this.statePath, "utf8"))); + } catch (error) { + throw new Error(`Invalid remote access registry at ${this.statePath}: ${error instanceof Error ? error.message : String(error)}`); + } + } + persist() { + writeDurableJson(this.statePath, this.state); + } +}; + +// broker/authorization.ts +function policyPrincipalForSession(session) { + if (session.origin === "remote") { + if (!session.parentSessionId || !session.rootSessionId || !session.generation) { + throw new Error(`Remote session ${session.id} is missing broker-owned policy metadata`); + } + return { + id: session.id, + kind: "remote", + state: "active", + generation: session.generation, + policy: "remote-tree", + parentSessionId: session.parentSessionId, + rootSessionId: session.rootSessionId + }; + } + return { + id: session.id, + kind: "local", + state: "active", + generation: 1, + policy: "local-public", + rootSessionId: session.id + }; +} +function policyStateForSessions(sessions) { + const principals = {}; + for (const session of sessions) principals[session.id] = policyPrincipalForSession(session); + return { principals }; +} +function authorizeSessionAction(sessions, actorId, action, targetId) { + const state = policyStateForSessions(sessions); + const actor = state.principals[actorId]; + const target = state.principals[targetId]; + return authorize(state, actorId, action, targetId, { + actorGeneration: actor?.generation, + targetGeneration: target?.generation + }); +} +function visibleSessions(sessions, actorId) { + const values = Array.from(sessions); + return values.filter((target) => authorizeSessionAction(values, actorId, "discover", target.id).allowed); +} + +// broker/audit.ts +import { closeSync as closeSync3, fsyncSync as fsyncSync2, openSync as openSync3, writeSync } from "fs"; +var BROKER_AUDIT_VERSION = 1; +var BrokerAuditLog = class { + constructor(path, now = Date.now) { + this.path = path; + this.now = now; + } + path; + now; + record(entry) { + const line = `${JSON.stringify({ + version: BROKER_AUDIT_VERSION, + timestamp: this.now(), + ...entry + })} +`; + const descriptor = openSync3(this.path, "a", INTERCOM_RUNTIME_FILE_MODE); + try { + writeSync(descriptor, line, void 0, "utf8"); + fsyncSync2(descriptor); + } finally { + closeSync3(descriptor); + } + restrictIntercomRuntimeFile(this.path); + } + tryRecord(entry) { + try { + this.record(entry); + } catch (error) { + console.error("Failed to append Agent Intercom broker audit event:", error); + } + } +}; + // broker/broker.ts var INTERCOM_DIR = getIntercomDirPath(); var LISTEN_TARGET = getBrokerListenTarget(); +var REMOTE_LISTEN_TARGET = getRemoteGatewaySocketPath(); var PID_PATH = join2(INTERCOM_DIR, "broker.pid"); var OWNER_PATH = join2(INTERCOM_DIR, "broker.owner"); var PORT_PATH = getBrokerPortFilePath(INTERCOM_DIR); var ASK_STATE_PATH = getBrokerAskStateFilePath(INTERCOM_DIR); -var BROKER_STATE_ID = randomUUID2(); +var ACCESS_STATE_PATH = getBrokerAccessStateFilePath(INTERCOM_DIR); +var ADMIN_CREDENTIAL_PATH = getBrokerAdminCredentialFilePath(INTERCOM_DIR); +var AUDIT_PATH = getBrokerAuditFilePath(INTERCOM_DIR); +var BROKER_STATE_ID = randomUUID3(); var MAX_SESSIONS = 128; var MAX_UNREGISTERED_CONNECTIONS = 32; var REGISTRATION_TIMEOUT_MS = 1e3; var RATE_LIMIT_CAPACITY = 240; var RATE_LIMIT_REFILL_PER_SECOND = 120; +var REMOTE_RATE_LIMIT_CAPACITY = 60; +var REMOTE_RATE_LIMIT_REFILL_PER_SECOND = 30; +var REMOTE_EXPIRY_SWEEP_MS = Math.max(50, Number.parseInt(process.env.PI_INTERCOM_REMOTE_EXPIRY_SWEEP_MS ?? "1000", 10) || 1e3); var PRESENCE_HEARTBEAT_MS = 1e3; var DELIVERY_ACK_TIMEOUT_MS = 8e3; var RECENT_DELIVERY_TTL_MS = 10 * 60 * 1e3; @@ -323,22 +974,42 @@ var IntercomBroker = class { connections = /* @__PURE__ */ new Set(); unregisteredConnections = /* @__PURE__ */ new Set(); server; + remoteServer = null; shutdownTimer = null; + expiryTimer = null; askTimeoutMs = getAskTimeoutMs(); + accessRegistry; + audit; constructor() { ensureIntercomRuntimeDir(INTERCOM_DIR); acquireBrokerOwnership(OWNER_PATH); + this.accessRegistry = new RemoteAccessRegistry(ACCESS_STATE_PATH); + this.audit = new BrokerAuditLog(AUDIT_PATH); + this.accessRegistry.ensureAdminCredential(ADMIN_CREDENTIAL_PATH); this.loadAskEdges(); if (typeof LISTEN_TARGET === "string" && process.platform !== "win32") { - try { - unlinkSync2(LISTEN_TARGET); - } catch { + for (const socketPath of [LISTEN_TARGET, REMOTE_LISTEN_TARGET]) { + try { + unlinkSync2(socketPath); + } catch { + } } } - this.server = net.createServer(this.handleConnection.bind(this)); + this.server = net.createServer((socket) => this.handleConnection(socket, "local")); + if (process.platform !== "win32" && typeof LISTEN_TARGET === "string") { + this.remoteServer = net.createServer((socket) => this.handleConnection(socket, "remote")); + } } start() { - const onListening = () => { + let localListening = false; + let remoteListening = this.remoteServer === null; + const announceWhenReady = () => { + if (!localListening || !remoteListening) return; + writeFileSync3(PID_PATH, String(process.pid), { mode: INTERCOM_RUNTIME_FILE_MODE }); + restrictIntercomRuntimeFile(PID_PATH); + console.log(`Intercom broker started (pid: ${process.pid}, remote-access-v1)`); + }; + const onLocalListening = () => { if (typeof LISTEN_TARGET === "string") { restrictIntercomRuntimeFile(LISTEN_TARGET); } else { @@ -356,19 +1027,27 @@ var IntercomBroker = class { `, { mode: INTERCOM_RUNTIME_FILE_MODE }); restrictIntercomRuntimeFile(PORT_PATH); } - writeFileSync3(PID_PATH, String(process.pid), { mode: INTERCOM_RUNTIME_FILE_MODE }); - restrictIntercomRuntimeFile(PID_PATH); - console.log(`Intercom broker started (pid: ${process.pid})`); + localListening = true; + announceWhenReady(); }; if (typeof LISTEN_TARGET === "string") { - this.server.listen(LISTEN_TARGET, onListening); + this.server.listen(LISTEN_TARGET, onLocalListening); } else { - this.server.listen({ host: LISTEN_TARGET.host, port: LISTEN_TARGET.port }, onListening); + this.server.listen({ host: LISTEN_TARGET.host, port: LISTEN_TARGET.port }, onLocalListening); + } + if (this.remoteServer) { + this.remoteServer.listen(REMOTE_LISTEN_TARGET, () => { + restrictIntercomRuntimeFile(REMOTE_LISTEN_TARGET); + remoteListening = true; + announceWhenReady(); + }); } + this.expiryTimer = setInterval(() => this.reconcileExpiredPrincipals(), REMOTE_EXPIRY_SWEEP_MS); + this.expiryTimer.unref?.(); process.on("SIGTERM", () => this.shutdown()); process.on("SIGINT", () => this.shutdown()); } - handleConnection(socket) { + handleConnection(socket, origin) { this.connections.add(socket); let sessionId = null; let registrationTimeout = null; @@ -396,7 +1075,9 @@ var IntercomBroker = class { armRegistrationTimeout(); const connection = { socket, - tokens: RATE_LIMIT_CAPACITY, + origin, + tokens: origin === "remote" ? REMOTE_RATE_LIMIT_CAPACITY : RATE_LIMIT_CAPACITY, + refillPerSecond: origin === "remote" ? REMOTE_RATE_LIMIT_REFILL_PER_SECOND : RATE_LIMIT_REFILL_PER_SECOND, lastRefillAt: Date.now() }; const reader = createMessageReader((msg) => { @@ -407,7 +1088,7 @@ var IntercomBroker = class { return; } try { - this.handleMessage(socket, msg, sessionId, (id) => { + this.handleMessage(socket, origin, msg, sessionId, (id) => { sessionId = id; if (id) { clearRegistrationTimeout(); @@ -434,10 +1115,20 @@ var IntercomBroker = class { if (sessionId) { const existing = this.sessions.get(sessionId); if (existing?.socket === socket) { + if (existing.info.origin === "remote") { + this.audit.tryRecord({ + event: "remote_disconnect", + outcome: "observed", + actorId: sessionId, + remoteHostId: existing.info.remoteHostId, + generation: existing.info.generation, + reason: "SOCKET_CLOSED" + }); + } + this.broadcastVisible({ type: "session_left", sessionId }, existing.info, sessionId); this.sessions.delete(sessionId); this.clearPendingDeliveriesForSession(sessionId, socket); this.deferAskEdgesForSession(sessionId); - this.broadcast({ type: "session_left", sessionId }, sessionId); this.scheduleShutdownCheck(); } } @@ -463,8 +1154,8 @@ var IntercomBroker = class { const elapsedMs = now - connection.lastRefillAt; if (elapsedMs > 0) { connection.tokens = Math.min( - RATE_LIMIT_CAPACITY, - connection.tokens + elapsedMs * RATE_LIMIT_REFILL_PER_SECOND / 1e3 + connection.origin === "remote" ? REMOTE_RATE_LIMIT_CAPACITY : RATE_LIMIT_CAPACITY, + connection.tokens + elapsedMs * connection.refillPerSecond / 1e3 ); connection.lastRefillAt = now; } @@ -490,7 +1181,7 @@ var IntercomBroker = class { } }, 5e3); } - handleMessage(socket, msg, currentId, setId) { + handleMessage(socket, origin, msg, currentId, setId) { if (typeof msg !== "object" || msg === null || !("type" in msg) || typeof msg.type !== "string") { throw new Error("Invalid client message"); } @@ -508,16 +1199,33 @@ var IntercomBroker = class { type: "health_ok", requestId: clientMessage.requestId, protocol: INTERCOM_PROTOCOL_NAME, - version: INTERCOM_PROTOCOL_VERSION + version: INTERCOM_PROTOCOL_VERSION, + endpoint: origin, + remoteAccess: this.remoteAccessContract() }); return; } + if (clientMessage.type === "access_control") { + if (currentId !== null) { + this.sendError(socket, "ACCESS_DENIED", "Remote access control requires a short-lived control connection"); + socket.end(); + return; + } + if (origin === "local") this.handleAccessControl(socket, clientMessage); + else this.handleRemoteAccessControl(socket, clientMessage); + return; + } if (requiresEndpointAuth && clientMessage.type === "register" && !hasEndpointAuth) { throw new Error("Invalid intercom TCP endpoint credentials"); } if (currentId === null && clientMessage.type !== "register") { throw new Error(`Received ${clientMessage.type} before register`); } + if (currentId && !this.isCurrentPrincipal(currentId)) { + this.sendError(socket, "ACCESS_DENIED", "Remote session authorization is no longer valid"); + socket.destroy(); + return; + } switch (clientMessage.type) { case "register": { if (!isSessionRegistration(clientMessage.session)) { @@ -535,27 +1243,97 @@ var IntercomBroker = class { if (currentId) { throw new Error("Received duplicate register message"); } - let id = randomUUID2(); - if (clientMessage.sessionId !== void 0) { - if (!isSessionId(clientMessage.sessionId)) { - throw new Error("Invalid register sessionId"); + let id; + let remotePrincipal; + let issuedSessionCredential; + let enrollmentConsumed = false; + if (origin === "remote") { + if (this.sessions.size >= MAX_SESSIONS) { + this.sendError(socket, "TOO_MANY_SESSIONS", "Too many registered intercom sessions"); + socket.destroy(); + break; + } + const access = clientMessage.access; + if (typeof access !== "object" || access === null || Array.isArray(access)) { + this.audit.tryRecord({ event: "remote_registration_denied", outcome: "denied", reason: "MISSING_CREDENTIAL" }); + this.sendError(socket, "ACCESS_DENIED", "Remote registration requires an access credential"); + socket.end(); + break; + } + const fields = access; + try { + if (typeof fields.enrollmentToken === "string") { + const consumed = this.accessRegistry.consumeEnrollment(fields.enrollmentToken); + remotePrincipal = consumed.principal; + issuedSessionCredential = consumed.sessionCredential; + enrollmentConsumed = true; + } else if (typeof fields.sessionCredential === "string" && typeof fields.sessionId === "string" && typeof fields.generation === "number" && Number.isSafeInteger(fields.generation)) { + remotePrincipal = this.accessRegistry.authenticateSession(fields.sessionId, fields.generation, fields.sessionCredential); + } else { + throw new Error("Invalid remote access credential shape"); + } + } catch { + this.audit.tryRecord({ event: "remote_registration_denied", outcome: "denied", reason: "INVALID_CREDENTIAL" }); + this.sendError(socket, "ACCESS_DENIED", "Remote registration credential was rejected"); + socket.end(); + break; + } + id = remotePrincipal.id; + if (this.sessions.has(id)) { + this.audit.tryRecord({ + event: "credential_reuse_denied", + outcome: "denied", + actorId: id, + remoteHostId: remotePrincipal.remoteHostId, + generation: remotePrincipal.generation, + reason: "ALREADY_ACTIVE" + }); + this.sendError(socket, "ACCESS_DENIED", "Remote session credential is already active"); + socket.end(); + break; + } + } else { + id = randomUUID3(); + if (clientMessage.sessionId !== void 0) { + if (!isSessionId(clientMessage.sessionId)) { + throw new Error("Invalid register sessionId"); + } + id = clientMessage.sessionId; + } + const previous = this.sessions.get(id); + if (!previous && this.sessions.size >= MAX_SESSIONS) { + this.sendError(socket, "TOO_MANY_SESSIONS", "Too many registered intercom sessions"); + socket.destroy(); + break; + } + if (previous) { + this.clearPendingDeliveriesForSession(id, previous.socket); + this.deferAskEdgesForSession(id); + previous.socket.end(); } - id = clientMessage.sessionId; - } - const previous = this.sessions.get(id); - if (!previous && this.sessions.size >= MAX_SESSIONS) { - this.sendError(socket, "TOO_MANY_SESSIONS", "Too many registered intercom sessions"); - socket.destroy(); - break; - } - if (previous) { - this.clearPendingDeliveriesForSession(id, previous.socket); - this.deferAskEdgesForSession(id); - previous.socket.end(); } setId(id); const session = clientMessage.session; - const info = { + const info = remotePrincipal ? { + id, + name: remotePrincipal.name, + cwd: session.cwd, + model: session.model, + pid: session.pid, + startedAt: session.startedAt, + lastActivity: session.lastActivity, + ...session.status !== void 0 ? { status: session.status } : {}, + trustedLocal: false, + origin: "remote", + remoteHostId: remotePrincipal.remoteHostId, + parentSessionId: remotePrincipal.parentSessionId, + rootSessionId: remotePrincipal.rootSessionId, + generation: remotePrincipal.generation, + canDelegate: remotePrincipal.canDelegate, + depth: remotePrincipal.depth, + maxDepth: remotePrincipal.maxDepth, + maxChildren: remotePrincipal.maxChildren + } : { id, ...session.name !== void 0 ? { name: session.name } : {}, cwd: session.cwd, @@ -564,8 +1342,27 @@ var IntercomBroker = class { startedAt: session.startedAt, lastActivity: session.lastActivity, ...session.status !== void 0 ? { status: session.status } : {}, - trustedLocal: typeof LISTEN_TARGET === "string" && process.platform !== "win32" + trustedLocal: typeof LISTEN_TARGET === "string" && process.platform !== "win32", + origin: "local" }; + if (remotePrincipal) { + this.audit.record({ + event: enrollmentConsumed ? "enrollment_consumed" : "remote_reconnect", + outcome: "allowed", + actorId: id, + targetId: remotePrincipal.parentSessionId, + remoteHostId: remotePrincipal.remoteHostId, + generation: remotePrincipal.generation + }); + this.audit.record({ + event: "remote_connect", + outcome: "allowed", + actorId: id, + targetId: remotePrincipal.parentSessionId, + remoteHostId: remotePrincipal.remoteHostId, + generation: remotePrincipal.generation + }); + } this.sessions.set(id, { socket, info, lastPresenceBroadcastAt: Date.now() }); if (this.shutdownTimer) { clearTimeout(this.shutdownTimer); @@ -575,9 +1372,24 @@ var IntercomBroker = class { type: "registered", sessionId: id, protocol: INTERCOM_PROTOCOL_NAME, - version: INTERCOM_PROTOCOL_VERSION + version: INTERCOM_PROTOCOL_VERSION, + ...remotePrincipal ? { + remoteAccess: this.remoteAccessContract(), + access: { + origin: "remote", + remoteHostId: remotePrincipal.remoteHostId, + parentSessionId: remotePrincipal.parentSessionId, + rootSessionId: remotePrincipal.rootSessionId, + generation: remotePrincipal.generation, + canDelegate: remotePrincipal.canDelegate, + depth: remotePrincipal.depth, + maxDepth: remotePrincipal.maxDepth, + maxChildren: remotePrincipal.maxChildren, + ...issuedSessionCredential ? { sessionCredential: issuedSessionCredential } : {} + } + } : {} }); - this.broadcast({ type: "session_joined", session: info }, id); + this.broadcastVisible({ type: "session_joined", session: info }, info, id); break; } case "unregister": { @@ -589,6 +1401,17 @@ var IntercomBroker = class { } const existing = this.sessions.get(currentId); if (existing?.socket === socket) { + if (existing.info.origin === "remote") { + this.audit.tryRecord({ + event: "remote_disconnect", + outcome: "observed", + actorId: currentId, + remoteHostId: existing.info.remoteHostId, + generation: existing.info.generation, + reason: "UNREGISTERED" + }); + } + this.broadcastVisible({ type: "session_left", sessionId: currentId }, existing.info, currentId); this.sessions.delete(currentId); this.clearPendingDeliveriesForSession(currentId, socket); if (clientMessage.preserveAsks) { @@ -596,7 +1419,6 @@ var IntercomBroker = class { } else { this.clearAskEdgesForSession(currentId, "session_disconnected"); } - this.broadcast({ type: "session_left", sessionId: currentId }, currentId); this.scheduleShutdownCheck(); } setId(null); @@ -606,7 +1428,20 @@ var IntercomBroker = class { if (typeof clientMessage.requestId !== "string") { throw new Error("Invalid list message"); } - const sessions = Array.from(this.sessions.values()).map((s) => s.info); + const allSessions = Array.from(this.sessions.values(), (session) => session.info); + const sessions = visibleSessions(allSessions, currentId); + const actor = this.sessions.get(currentId); + if (actor?.info.origin === "remote" && sessions.length < allSessions.length) { + this.audit.tryRecord({ + event: "remote_visibility_filtered", + outcome: "observed", + actorId: currentId, + remoteHostId: actor.info.remoteHostId, + generation: actor.info.generation, + visibleCount: sessions.length, + hiddenCount: allSessions.length - sessions.length + }); + } writeMessage(socket, { type: "sessions", requestId: clientMessage.requestId, sessions }); break; } @@ -620,6 +1455,7 @@ var IntercomBroker = class { this.sendDeliveryFailure(socket, messageId, false, "INVALID_MESSAGE", "Invalid message format"); break; } + const action = message.replyTo ? "reply" : message.expectsReply ? "ask" : "send"; this.pruneRecentDeliveries(); const deliveryKey = this.deliveryKey(currentId, message.id); const fingerprint = JSON.stringify({ @@ -634,7 +1470,12 @@ var IntercomBroker = class { this.sendDeliveryFailure(socket, message.id, false, "DUPLICATE_MESSAGE_ID", "Message ID was already used with a different payload"); break; } - if (recent.retryable) { + const actor = this.sessions.get(currentId); + const target = this.sessions.get(recent.to); + const authorizationStillValid = Boolean( + actor && target && (actor.info.generation ?? 1) === recent.fromGeneration && (target.info.generation ?? 1) === recent.toGeneration && this.isAuthorized(currentId, recent.action, recent.to) + ); + if (recent.retryable || !authorizationStillValid) { this.recentDeliveries.delete(deliveryKey); } else { if (recent.response.type === "delivered") { @@ -653,16 +1494,34 @@ var IntercomBroker = class { const existing = this.pendingDeliveries.get(existingDeliveryId); if (!existing || existing.fingerprint !== fingerprint) { this.sendDeliveryFailure(socket, message.id, false, "DUPLICATE_MESSAGE_ID", "Message ID is already pending with a different payload"); - } else { + break; + } + const actor = this.sessions.get(existing.from); + const target = this.sessions.get(existing.to); + if (actor && target && (actor.info.generation ?? 1) === existing.fromGeneration && (target.info.generation ?? 1) === existing.toGeneration && this.isAuthorized(existing.from, existing.action, existing.to)) { writeMessage(socket, { type: "delivery_accepted", messageId: message.id, deliveryId: existing.id }); + break; } - break; + this.failPendingDelivery(existing.id, "SESSION_NOT_FOUND", "Delivery authorization changed while pending"); } if (this.pendingDeliveries.size >= MAX_PENDING_DELIVERIES || this.countPendingDeliveriesFrom(currentId) >= MAX_PENDING_DELIVERIES_PER_SESSION) { this.sendDeliveryFailure(socket, message.id, false, "TOO_MANY_PENDING_DELIVERIES", "Too many messages are waiting for receiver acknowledgement"); break; } - const targets = this.findSessions(clientMessage.to); + const candidates = this.findSessions(clientMessage.to); + const targets = candidates.filter((target) => this.isAuthorized(currentId, action, target.info.id)); + if (candidates.length > 0 && targets.length === 0) { + const actor = this.sessions.get(currentId); + this.audit.tryRecord({ + event: "remote_delivery_denied", + outcome: "denied", + actorId: currentId, + targetId: candidates.length === 1 ? candidates[0].info.id : void 0, + remoteHostId: actor?.info.remoteHostId ?? candidates.find((candidate) => candidate.info.remoteHostId)?.info.remoteHostId, + generation: actor?.info.generation, + reason: "POLICY_DENIED" + }); + } if (targets.length === 1) { const fromSession = this.sessions.get(currentId); if (!fromSession || fromSession.socket !== socket) { @@ -700,7 +1559,7 @@ var IntercomBroker = class { } this.addAskEdge(message.id, currentId, target.info.id); } - const deliveryId = randomUUID2(); + const deliveryId = randomUUID3(); const timeout = setTimeout(() => { this.failPendingDelivery(deliveryId, "DELIVERY_TIMEOUT", "Recipient did not acknowledge the message in time"); }, DELIVERY_ACK_TIMEOUT_MS); @@ -714,6 +1573,9 @@ var IntercomBroker = class { to: target.info.id, senderSocket: socket, recipientSocket: target.socket, + action, + fromGeneration: fromSession.info.generation ?? 1, + toGeneration: target.info.generation ?? 1, timeout }; this.pendingDeliveries.set(deliveryId, pending); @@ -766,7 +1628,7 @@ var IntercomBroker = class { } const edge = this.askEdges.get(this.askKey(currentId, clientMessage.messageId)); const applied = Boolean(edge?.from === currentId); - if (applied && edge.state === "blocking") { + if (edge?.from === currentId && edge.state === "blocking") { edge.state = "deferred"; this.persistAskEdges(); this.notifyAskDeferred(edge); @@ -802,7 +1664,7 @@ var IntercomBroker = class { if (typeof clientMessage.name !== "string" || clientMessage.name.length > MAX_SESSION_NAME_LENGTH) { throw new Error("Invalid presence name"); } - if (session.info.name !== clientMessage.name) { + if (session.info.origin !== "remote" && session.info.name !== clientMessage.name) { session.info.name = clientMessage.name; changed = true; } @@ -829,7 +1691,7 @@ var IntercomBroker = class { session.info.lastActivity = now; if (changed || now - session.lastPresenceBroadcastAt >= PRESENCE_HEARTBEAT_MS) { session.lastPresenceBroadcastAt = now; - this.broadcast({ type: "presence_update", session: session.info }, currentId); + this.broadcastVisible({ type: "presence_update", session: session.info }, session.info, currentId); } } break; @@ -838,6 +1700,330 @@ var IntercomBroker = class { throw new Error(`Unknown client message type: ${clientMessage.type}`); } } + remoteAccessContract() { + return { + feature: "remote-access-v1", + policySemanticsVersion: POLICY_SEMANTICS_VERSION, + policySemanticsHash: POLICY_SEMANTICS_HASH + }; + } + handleAccessControl(socket, message) { + if (typeof message.requestId !== "string" || message.requestId.length > MAX_MESSAGE_ID_LENGTH || typeof message.adminToken !== "string" || !this.accessRegistry.authenticateAdmin(message.adminToken)) { + this.sendError(socket, "ACCESS_DENIED", "Remote access control credential or request was rejected"); + socket.end(); + return; + } + if (message.action === "inspect_tree") { + if (typeof message.principalId !== "string" || !isSessionId(message.principalId)) { + this.sendError(socket, "INVALID_REQUEST", "Invalid remote principal ID"); + socket.end(); + return; + } + const principals = this.accessRegistry.inspectSubtree(message.principalId).map((principal) => this.principalSummary(principal)); + this.audit.record({ + event: "tree_inspected", + outcome: "allowed", + actorId: "local-admin", + targetId: message.principalId, + visibleCount: principals.length + }); + writeMessage(socket, { type: "access_control_result", requestId: message.requestId, action: "inspect_tree", principals }); + socket.end(); + return; + } + if (message.action === "adopt_subtree") { + if (typeof message.principalId !== "string" || !isSessionId(message.principalId) || typeof message.newParentSessionId !== "string" || !isSessionId(message.newParentSessionId)) { + this.sendError(socket, "INVALID_REQUEST", "Invalid adoption request"); + socket.end(); + return; + } + const localParent = this.sessions.get(message.newParentSessionId); + const remoteParent = this.accessRegistry.snapshot().principals[message.newParentSessionId]; + if ((!localParent || localParent.info.origin === "remote") && (!remoteParent || remoteParent.state !== "active")) { + this.sendError(socket, "ACCESS_DENIED", "Adoption parent must be an active local or remote principal"); + socket.end(); + return; + } + const newRootSessionId = localParent?.info.origin === "local" ? localParent.info.id : remoteParent.rootSessionId; + const priorSessions = Array.from(this.sessions.values(), (session) => session.info); + let changed; + try { + changed = this.accessRegistry.adoptSubtree(message.principalId, message.newParentSessionId, newRootSessionId); + } catch { + this.sendError(socket, "ACCESS_DENIED", "Adoption would violate the ownership tree"); + socket.end(); + return; + } + this.disconnectTransitionedPrincipals(changed, priorSessions, "principal_adopted"); + writeMessage(socket, { + type: "access_control_result", + requestId: message.requestId, + action: "adopt_subtree", + principals: changed.map((principal) => this.principalSummary(principal)) + }); + socket.end(); + return; + } + if (message.action === "revoke_subtree") { + if (typeof message.principalId !== "string" || !isSessionId(message.principalId)) { + this.sendError(socket, "INVALID_REQUEST", "Invalid remote principal ID"); + socket.end(); + return; + } + const priorSessions = Array.from(this.sessions.values(), (session) => session.info); + const changed = this.accessRegistry.revoke(message.principalId); + this.disconnectTransitionedPrincipals(changed, priorSessions); + writeMessage(socket, { + type: "access_control_result", + requestId: message.requestId, + action: "revoke_subtree", + changedPrincipalIds: changed.map((principal) => principal.id) + }); + socket.end(); + return; + } + if (message.action !== "issue_enrollment" || typeof message.enrollment !== "object" || message.enrollment === null || Array.isArray(message.enrollment)) { + this.sendError(socket, "INVALID_REQUEST", "Unknown remote access control action"); + socket.end(); + return; + } + const enrollment = message.enrollment; + if (typeof enrollment.name !== "string" || typeof enrollment.parentSessionId !== "string" || typeof enrollment.rootSessionId !== "string" || typeof enrollment.remoteHostId !== "string" || enrollment.ttlMs !== void 0 && (typeof enrollment.ttlMs !== "number" || !Number.isSafeInteger(enrollment.ttlMs)) || enrollment.expiresAt !== void 0 && (typeof enrollment.expiresAt !== "number" || !Number.isSafeInteger(enrollment.expiresAt)) || enrollment.canDelegate !== void 0 && typeof enrollment.canDelegate !== "boolean" || enrollment.maxDepth !== void 0 && (typeof enrollment.maxDepth !== "number" || !Number.isSafeInteger(enrollment.maxDepth)) || enrollment.maxChildren !== void 0 && (typeof enrollment.maxChildren !== "number" || !Number.isSafeInteger(enrollment.maxChildren))) { + this.sendError(socket, "INVALID_REQUEST", "Invalid remote enrollment request"); + socket.end(); + return; + } + const parent = this.sessions.get(enrollment.parentSessionId); + if (!parent || parent.info.origin === "remote" || enrollment.rootSessionId !== parent.info.id) { + this.sendError(socket, "ACCESS_DENIED", "Enrollment parent must be an active local root session"); + socket.end(); + return; + } + const issued = this.accessRegistry.issueEnrollment({ + name: enrollment.name, + parentSessionId: parent.info.id, + rootSessionId: parent.info.id, + remoteHostId: enrollment.remoteHostId, + ...enrollment.expiresAt !== void 0 ? { expiresAt: enrollment.expiresAt } : {}, + ...enrollment.canDelegate !== void 0 ? { canDelegate: enrollment.canDelegate } : {}, + ...enrollment.maxDepth !== void 0 ? { maxDepth: enrollment.maxDepth } : {}, + ...enrollment.maxChildren !== void 0 ? { maxChildren: enrollment.maxChildren } : {} + }, enrollment.ttlMs); + this.audit.record({ + event: "enrollment_issued", + outcome: "allowed", + actorId: parent.info.id, + targetId: enrollment.name, + remoteHostId: enrollment.remoteHostId, + reason: `expires:${issued.expiresAt}` + }); + writeMessage(socket, { + type: "access_control_result", + requestId: message.requestId, + action: "issue_enrollment", + enrollmentToken: issued.enrollmentToken, + expiresAt: issued.expiresAt + }); + socket.end(); + } + handleRemoteAccessControl(socket, message) { + if (typeof message.requestId !== "string" || message.requestId.length > MAX_MESSAGE_ID_LENGTH || typeof message.access !== "object" || message.access === null || Array.isArray(message.access)) { + this.sendError(socket, "ACCESS_DENIED", "Remote control request was rejected"); + socket.end(); + return; + } + const access = message.access; + if (typeof access.sessionCredential !== "string" || typeof access.sessionId !== "string" || typeof access.generation !== "number" || !Number.isSafeInteger(access.generation)) { + this.sendError(socket, "ACCESS_DENIED", "Remote control credential was rejected"); + socket.end(); + return; + } + let parent; + try { + parent = this.accessRegistry.authenticateSession(access.sessionId, access.generation, access.sessionCredential); + } catch { + this.audit.tryRecord({ event: "remote_registration_denied", outcome: "denied", reason: "INVALID_CONTROL_CREDENTIAL" }); + this.sendError(socket, "ACCESS_DENIED", "Remote control credential was rejected"); + socket.end(); + return; + } + const policyState = this.registryPolicyState(); + if (message.action === "inspect_tree") { + const targetId = typeof message.principalId === "string" ? message.principalId : parent.id; + const inspection = authorize(policyState, parent.id, "inspect_tree", targetId, { actorGeneration: parent.generation }); + if (!inspection.allowed) { + this.sendError(socket, "ACCESS_DENIED", "Remote tree inspection policy denied the request"); + socket.end(); + return; + } + const principals = this.accessRegistry.inspectSubtree(targetId).filter((candidate) => authorize(policyState, parent.id, "inspect_tree", candidate.id, { actorGeneration: parent.generation }).allowed).map((candidate) => this.principalSummary(candidate)); + this.audit.record({ + event: "tree_inspected", + outcome: "allowed", + actorId: parent.id, + targetId, + remoteHostId: parent.remoteHostId, + generation: parent.generation, + visibleCount: principals.length + }); + writeMessage(socket, { type: "access_control_result", requestId: message.requestId, action: "inspect_tree", principals }); + socket.end(); + return; + } + if (message.action !== "issue_child_enrollment" || typeof message.enrollment !== "object" || message.enrollment === null || Array.isArray(message.enrollment)) { + this.sendError(socket, "ACCESS_DENIED", "Remote control action was rejected"); + socket.end(); + return; + } + const enrollment = message.enrollment; + if (typeof enrollment.name !== "string" || enrollment.ttlMs !== void 0 && (typeof enrollment.ttlMs !== "number" || !Number.isSafeInteger(enrollment.ttlMs)) || enrollment.expiresAt !== void 0 && (typeof enrollment.expiresAt !== "number" || !Number.isSafeInteger(enrollment.expiresAt)) || enrollment.canDelegate !== void 0 && typeof enrollment.canDelegate !== "boolean" || enrollment.maxDepth !== void 0 && (typeof enrollment.maxDepth !== "number" || !Number.isSafeInteger(enrollment.maxDepth)) || enrollment.maxChildren !== void 0 && (typeof enrollment.maxChildren !== "number" || !Number.isSafeInteger(enrollment.maxChildren))) { + this.sendError(socket, "ACCESS_DENIED", "Remote delegation request was rejected"); + socket.end(); + return; + } + const delegation = authorize(policyState, parent.id, "delegate_child", parent.id, { + actorGeneration: parent.generation, + targetGeneration: parent.generation + }); + if (!delegation.allowed) { + this.sendError(socket, "ACCESS_DENIED", "Remote delegation policy denied the request"); + socket.end(); + return; + } + let issued; + try { + issued = this.accessRegistry.issueChildEnrollment(parent.id, parent.generation, { + name: enrollment.name, + ...enrollment.expiresAt !== void 0 ? { expiresAt: enrollment.expiresAt } : {}, + ...enrollment.canDelegate !== void 0 ? { canDelegate: enrollment.canDelegate } : {}, + ...enrollment.maxDepth !== void 0 ? { maxDepth: enrollment.maxDepth } : {}, + ...enrollment.maxChildren !== void 0 ? { maxChildren: enrollment.maxChildren } : {} + }, enrollment.ttlMs); + } catch { + this.sendError(socket, "ACCESS_DENIED", "Remote delegation limits denied the request"); + socket.end(); + return; + } + this.audit.record({ + event: "enrollment_issued", + outcome: "allowed", + actorId: parent.id, + targetId: enrollment.name, + remoteHostId: parent.remoteHostId, + generation: parent.generation, + reason: `delegated-expires:${issued.expiresAt}` + }); + writeMessage(socket, { + type: "access_control_result", + requestId: message.requestId, + action: "issue_child_enrollment", + enrollmentToken: issued.enrollmentToken, + expiresAt: issued.expiresAt, + parentSessionId: parent.id + }); + socket.end(); + } + registryPolicyState() { + const records = this.accessRegistry.snapshot().principals; + const principals = {}; + for (const record of Object.values(records)) { + principals[record.id] = { + id: record.id, + kind: "remote", + state: record.state, + generation: record.generation, + policy: "remote-tree", + parentSessionId: record.parentSessionId, + rootSessionId: record.rootSessionId + }; + if (!principals[record.rootSessionId]) { + principals[record.rootSessionId] = { + id: record.rootSessionId, + kind: "local", + state: "active", + generation: 1, + policy: "local-public", + rootSessionId: record.rootSessionId + }; + } + } + return { principals }; + } + principalSummary(principal) { + return { ...principal, connected: this.sessions.has(principal.id) }; + } + reconcileExpiredPrincipals() { + const priorSessions = Array.from(this.sessions.values(), (session) => session.info); + const changed = this.accessRegistry.expirePrincipals(); + if (changed.length > 0) this.disconnectTransitionedPrincipals(changed, priorSessions, "principal_expired"); + } + disconnectTransitionedPrincipals(changed, priorSessions, auditEvent = "principal_revoked") { + const changedIds = new Set(changed.map((principal) => principal.id)); + for (const principal of changed) { + const live = this.sessions.get(principal.id); + if (!live) { + this.audit.record({ + event: auditEvent, + outcome: "allowed", + actorId: principal.id, + remoteHostId: principal.remoteHostId, + generation: principal.generation, + reason: "OFFLINE" + }); + continue; + } + const subject = priorSessions.find((session) => session.id === principal.id) ?? live.info; + for (const [recipientId, recipient] of this.sessions) { + if (recipientId !== principal.id && !changedIds.has(recipientId) && authorizeSessionAction(priorSessions, recipientId, "discover", principal.id).allowed) { + writeMessage(recipient.socket, { type: "session_left", sessionId: principal.id }); + } + } + this.clearPendingDeliveriesForSession(principal.id, live.socket); + this.clearAskEdgesForSession(principal.id, "authorization_revoked"); + this.sessions.delete(principal.id); + for (const [key, recent] of this.recentDeliveries) { + if (recent.from === principal.id || recent.to === principal.id) this.recentDeliveries.delete(key); + } + this.audit.record({ + event: auditEvent, + outcome: "allowed", + actorId: principal.id, + targetId: subject.parentSessionId, + remoteHostId: principal.remoteHostId, + generation: principal.generation, + reason: "DISCONNECTED" + }); + live.socket.destroy(); + } + if (changed.length > 0) this.scheduleShutdownCheck(); + } + isCurrentPrincipal(sessionId) { + const session = this.sessions.get(sessionId); + if (!session) return false; + if (session.info.origin !== "remote") return true; + try { + this.accessRegistry.validatePrincipal(sessionId, session.info.generation ?? 0); + return true; + } catch { + return false; + } + } + isAuthorized(actorId, action, targetId) { + if (!this.isCurrentPrincipal(actorId) || !this.isCurrentPrincipal(targetId)) return false; + return authorizeSessionAction( + Array.from(this.sessions.values(), (session) => session.info), + actorId, + action, + targetId + ).allowed; + } + broadcastVisible(message, subject, exclude) { + for (const [id, session] of this.sessions) { + if (id !== exclude && this.isAuthorized(id, "discover", subject.id)) { + writeMessage(session.socket, message); + } + } + } askKey(fromSessionId, messageId) { return `${fromSessionId}\0${messageId}`; } @@ -941,11 +2127,11 @@ var IntercomBroker = class { return timeout; } loadAskEdges() { - if (!existsSync(ASK_STATE_PATH)) { + if (!existsSync2(ASK_STATE_PATH)) { return; } try { - const parsed = JSON.parse(readFileSync3(ASK_STATE_PATH, "utf-8")); + const parsed = JSON.parse(readFileSync4(ASK_STATE_PATH, "utf-8")); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw new Error("expected an object"); } @@ -1026,6 +2212,12 @@ var IntercomBroker = class { if (!pending || pending.to !== sessionId || pending.recipientSocket !== socket) { return; } + const sender = this.sessions.get(pending.from); + const recipient = this.sessions.get(pending.to); + if (!sender || !recipient || (sender.info.generation ?? 1) !== pending.fromGeneration || (recipient.info.generation ?? 1) !== pending.toGeneration || !this.isAuthorized(pending.from, pending.action, pending.to)) { + this.failPendingDelivery(deliveryId, "SESSION_NOT_FOUND", "Delivery authorization changed before acknowledgement"); + return; + } clearTimeout(pending.timeout); this.pendingDeliveries.delete(deliveryId); this.pendingDeliveryKeys.delete(pending.key); @@ -1035,12 +2227,16 @@ var IntercomBroker = class { const response = { type: "delivered", messageId: pending.message.id, deliveryId }; this.recentDeliveries.set(pending.key, { fingerprint: pending.fingerprint, + from: pending.from, + to: pending.to, + action: pending.action, + fromGeneration: pending.fromGeneration, + toGeneration: pending.toGeneration, retryable: false, response, expiresAt: Date.now() + RECENT_DELIVERY_TTL_MS }); - const sender = this.sessions.get(pending.from); - if (sender?.socket === pending.senderSocket) { + if (sender.socket === pending.senderSocket) { writeMessage(sender.socket, response); } } @@ -1064,6 +2260,11 @@ var IntercomBroker = class { }; this.recentDeliveries.set(pending.key, { fingerprint: pending.fingerprint, + from: pending.from, + to: pending.to, + action: pending.action, + fromGeneration: pending.fromGeneration, + toGeneration: pending.toGeneration, retryable: true, response, expiresAt: Date.now() + RECENT_DELIVERY_TTL_MS @@ -1101,15 +2302,12 @@ var IntercomBroker = class { } return Array.from(this.sessions.entries()).filter(([id]) => id.startsWith(nameOrId)).map(([, session]) => session); } - broadcast(msg, exclude) { - for (const [id, session] of this.sessions) { - if (id !== exclude) { - writeMessage(session.socket, msg); - } - } - } shutdown() { console.log("Broker shutting down"); + if (this.expiryTimer) { + clearInterval(this.expiryTimer); + this.expiryTimer = null; + } for (const session of this.sessions.values()) { session.socket.end(); } @@ -1125,9 +2323,11 @@ var IntercomBroker = class { this.askEdges.clear(); const ownsBroker = hasBrokerOwnership(OWNER_PATH); if (ownsBroker && typeof LISTEN_TARGET === "string" && process.platform !== "win32") { - try { - unlinkSync2(LISTEN_TARGET); - } catch { + for (const socketPath of [LISTEN_TARGET, REMOTE_LISTEN_TARGET]) { + try { + unlinkSync2(socketPath); + } catch { + } } } if (ownsBroker) { @@ -1142,6 +2342,7 @@ var IntercomBroker = class { releaseBrokerOwnership(OWNER_PATH); } this.server.close(); + this.remoteServer?.close(); process.exit(0); } }; diff --git a/dist/plugin.mjs b/dist/plugin.mjs index 35e62e8..e2d9886 100644 --- a/dist/plugin.mjs +++ b/dist/plugin.mjs @@ -13,6 +13,166 @@ import { EventEmitter } from "events"; import net from "net"; import { randomUUID as randomUUID2 } from "crypto"; +// node_modules/@dataforxyz/agent-intercom-core/dist/policy.js +var POLICY_SEMANTICS_VERSION = 2; + +// node_modules/@dataforxyz/agent-intercom-core/dist/policy-vectors.js +var localRoot = { + id: "local-root", + kind: "local", + state: "active", + generation: 1, + policy: "local-public", + rootSessionId: "local-root" +}; +var localPeer = { + id: "local-peer", + kind: "local", + state: "active", + generation: 1, + policy: "local-public", + rootSessionId: "local-peer" +}; +var remoteManager = { + id: "remote-manager", + kind: "remote", + state: "active", + generation: 1, + policy: "remote-tree", + parentSessionId: "local-root", + rootSessionId: "local-root" +}; +var remoteChild = { + id: "remote-child", + kind: "remote", + state: "active", + generation: 1, + policy: "remote-tree", + parentSessionId: "remote-manager", + rootSessionId: "local-root" +}; +var remoteSibling = { + id: "remote-sibling", + kind: "remote", + state: "active", + generation: 1, + policy: "remote-tree", + parentSessionId: "remote-manager", + rootSessionId: "local-root" +}; +var POLICY_VECTORS = [ + { + name: "local sessions remain public", + principals: [localRoot, localPeer], + actorId: "local-root", + action: "send", + targetId: "local-peer", + expectedAllowed: true, + expectedReasonOrCode: "local-public" + }, + { + name: "remote manager can reach direct local parent", + principals: [localRoot, remoteManager], + actorId: "remote-manager", + action: "send", + targetId: "local-root", + expectedAllowed: true, + expectedReasonOrCode: "direct-parent" + }, + { + name: "local parent can reach direct remote child", + principals: [localRoot, remoteManager], + actorId: "local-root", + action: "ask", + targetId: "remote-manager", + expectedAllowed: true, + expectedReasonOrCode: "direct-parent" + }, + { + name: "remote child can reach its local root through the ancestor chain", + principals: [localRoot, remoteManager, remoteChild], + actorId: "remote-child", + action: "send", + targetId: "local-root", + expectedAllowed: true, + expectedReasonOrCode: "ancestor-chain" + }, + { + name: "remote siblings cannot communicate in phase one", + principals: [localRoot, remoteManager, remoteChild, remoteSibling], + actorId: "remote-child", + action: "discover", + targetId: "remote-sibling", + expectedAllowed: false, + expectedReasonOrCode: "POLICY_DENIED" + }, + { + name: "unrelated local session cannot discover remote principal", + principals: [localRoot, localPeer, remoteManager], + actorId: "local-peer", + action: "discover", + targetId: "remote-manager", + expectedAllowed: false, + expectedReasonOrCode: "POLICY_DENIED" + }, + { + name: "remote principal cannot reach unrelated local session", + principals: [localRoot, localPeer, remoteManager], + actorId: "remote-manager", + action: "send", + targetId: "local-peer", + expectedAllowed: false, + expectedReasonOrCode: "POLICY_DENIED" + }, + { + name: "remote manager may inspect its descendant subtree", + principals: [localRoot, remoteManager, remoteChild], + actorId: "remote-manager", + action: "inspect_tree", + targetId: "remote-child", + expectedAllowed: true, + expectedReasonOrCode: "ancestor-control" + }, + { + name: "remote child cannot revoke its ancestor", + principals: [localRoot, remoteManager, remoteChild], + actorId: "remote-child", + action: "revoke", + targetId: "remote-manager", + expectedAllowed: false, + expectedReasonOrCode: "POLICY_DENIED" + }, + { + name: "remote principal may request attenuated delegation under itself", + principals: [localRoot, remoteManager], + actorId: "remote-manager", + action: "delegate_child", + targetId: "remote-manager", + expectedAllowed: true, + expectedReasonOrCode: "self" + }, + { + name: "revoked principal cannot communicate", + principals: [localRoot, { ...remoteManager, state: "revoked" }], + actorId: "remote-manager", + action: "send", + targetId: "local-root", + expectedAllowed: false, + expectedReasonOrCode: "REVOKED_PRINCIPAL" + }, + { + name: "stale actor generation cannot send", + principals: [localRoot, { ...remoteManager, generation: 2 }], + actorId: "remote-manager", + action: "send", + targetId: "local-root", + context: { actorGeneration: 1 }, + expectedAllowed: false, + expectedReasonOrCode: "STALE_GENERATION" + } +]; +var POLICY_SEMANTICS_HASH = "f3b00e503631bc91123aedfbcf1df72cc9913e1893c09728b2c598f3dcdfdfe0"; + // broker/framing.ts var MAX_FRAME_BYTES = 1024 * 1024; function writeMessage(socket, msg) { @@ -262,6 +422,49 @@ var PersistentOutboundOutbox = class { } }; +// broker/access-credential.ts +import { readFileSync as readFileSync3 } from "fs"; +var ACCESS_CREDENTIAL_ENV = "AGENT_INTERCOM_ACCESS_CREDENTIAL_PATH"; +var ACCESS_CREDENTIAL_VERSION = 1; +function nonEmptyString(value) { + return typeof value === "string" && value.length > 0 && !value.includes("\0"); +} +function loadRemoteAccessCredential(env = process.env) { + const path = env[ACCESS_CREDENTIAL_ENV]?.trim(); + if (!path) return void 0; + const parsed = JSON.parse(readFileSync3(path, "utf8")); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`Invalid Agent Intercom access credential at ${path}`); + } + const credential = parsed; + if (nonEmptyString(credential.enrollmentToken)) { + return { path, access: { enrollmentToken: credential.enrollmentToken }, enrollment: true }; + } + if (credential.version === ACCESS_CREDENTIAL_VERSION && nonEmptyString(credential.sessionCredential) && nonEmptyString(credential.sessionId) && typeof credential.generation === "number" && Number.isSafeInteger(credential.generation) && credential.generation > 0) { + return { + path, + access: { + sessionCredential: credential.sessionCredential, + sessionId: credential.sessionId, + generation: credential.generation + }, + enrollment: false + }; + } + throw new Error(`Invalid Agent Intercom access credential at ${path}`); +} +function writeRemoteSessionCredential(path, sessionId, metadata) { + if (!metadata.sessionCredential) { + throw new Error("Remote enrollment response omitted the session credential"); + } + writeDurableJson(path, { + version: ACCESS_CREDENTIAL_VERSION, + sessionCredential: metadata.sessionCredential, + sessionId, + generation: metadata.generation + }); +} + // broker/client.ts function toError(error) { return error instanceof Error ? error : new Error(String(error)); @@ -322,7 +525,22 @@ function isSessionInfo(value) { if (session.peerUid !== void 0 && typeof session.peerUid !== "number") { return false; } - return session.trustedLocal === void 0 || typeof session.trustedLocal === "boolean"; + if (session.trustedLocal !== void 0 && typeof session.trustedLocal !== "boolean") return false; + if (session.origin !== void 0 && session.origin !== "local" && session.origin !== "remote") return false; + if (session.remoteHostId !== void 0 && typeof session.remoteHostId !== "string") return false; + if (session.parentSessionId !== void 0 && typeof session.parentSessionId !== "string") return false; + if (session.rootSessionId !== void 0 && typeof session.rootSessionId !== "string") return false; + if (session.generation !== void 0 && (typeof session.generation !== "number" || !Number.isSafeInteger(session.generation))) return false; + if (session.canDelegate !== void 0 && typeof session.canDelegate !== "boolean") return false; + for (const field of ["depth", "maxDepth", "maxChildren"]) { + if (session[field] !== void 0 && (typeof session[field] !== "number" || !Number.isSafeInteger(session[field]))) return false; + } + return true; +} +function isRemoteAccessMetadata(value) { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const access = value; + return access.origin === "remote" && typeof access.remoteHostId === "string" && typeof access.parentSessionId === "string" && typeof access.rootSessionId === "string" && typeof access.generation === "number" && Number.isSafeInteger(access.generation) && access.generation > 0 && typeof access.canDelegate === "boolean" && typeof access.depth === "number" && Number.isSafeInteger(access.depth) && typeof access.maxDepth === "number" && Number.isSafeInteger(access.maxDepth) && typeof access.maxChildren === "number" && Number.isSafeInteger(access.maxChildren) && (access.sessionCredential === void 0 || typeof access.sessionCredential === "string"); } var IntercomClient = class extends EventEmitter { socket = null; @@ -331,6 +549,7 @@ var IntercomClient = class extends EventEmitter { pendingLists = /* @__PURE__ */ new Map(); pendingAskControls = /* @__PURE__ */ new Map(); outbox = null; + remoteAccessCredential; disconnecting = false; disconnectError = null; failPending(error) { @@ -380,6 +599,7 @@ var IntercomClient = class extends EventEmitter { let target; try { target = getBrokerConnectTarget(); + this.remoteAccessCredential = loadRemoteAccessCredential(); socket = connectToBrokerTarget(target); } catch (error) { reject(toError(error)); @@ -476,7 +696,8 @@ var IntercomClient = class extends EventEmitter { protocol: INTERCOM_PROTOCOL_NAME, version: INTERCOM_PROTOCOL_VERSION, session, - ...sessionId ? { sessionId } : {}, + ...!this.remoteAccessCredential && sessionId ? { sessionId } : {}, + ...this.remoteAccessCredential ? { access: this.remoteAccessCredential.access } : {}, ...typeof target === "string" ? {} : { stateId: target.stateId } }); } catch (error) { @@ -506,6 +727,24 @@ var IntercomClient = class extends EventEmitter { if (this._sessionId !== null) { throw new Error("Received duplicate registered message"); } + if (this.remoteAccessCredential) { + const contract = brokerMessage.remoteAccess; + const contractFields = typeof contract === "object" && contract !== null ? contract : void 0; + if (!contractFields || contractFields.feature !== "remote-access-v1" || contractFields.policySemanticsVersion !== POLICY_SEMANTICS_VERSION || contractFields.policySemanticsHash !== POLICY_SEMANTICS_HASH) { + throw new Error("Remote Intercom policy contract is absent or incompatible"); + } + if (!isRemoteAccessMetadata(brokerMessage.access)) { + throw new Error("Remote Intercom registration omitted broker-owned provenance"); + } + if (this.remoteAccessCredential.enrollment) { + writeRemoteSessionCredential(this.remoteAccessCredential.path, brokerMessage.sessionId, brokerMessage.access); + } else { + const reconnect = this.remoteAccessCredential.access; + if (!("sessionId" in reconnect) || reconnect.sessionId !== brokerMessage.sessionId || reconnect.generation !== brokerMessage.access.generation) { + throw new Error("Remote Intercom reconnect identity or generation changed unexpectedly"); + } + } + } this._sessionId = brokerMessage.sessionId; this.outbox = new PersistentOutboundOutbox(brokerMessage.sessionId); this.replayOutbox(); @@ -852,7 +1091,7 @@ var IntercomClient = class extends EventEmitter { // broker/spawn.ts import { spawn } from "child_process"; -import { existsSync as existsSync2, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync2 } from "fs"; +import { existsSync as existsSync2, readFileSync as readFileSync4, unlinkSync, writeFileSync as writeFileSync2 } from "fs"; import { join as join3, dirname as dirname2, extname, basename } from "path"; import { fileURLToPath } from "url"; import { createRequire } from "module"; @@ -915,7 +1154,11 @@ function isBrokerHealthOkMessage(message, requestId) { return false; } const response = message; - return response.type === "health_ok" && response.requestId === requestId && response.protocol === INTERCOM_PROTOCOL_NAME && response.version === INTERCOM_PROTOCOL_VERSION; + if (response.type !== "health_ok" || response.requestId !== requestId || response.protocol !== INTERCOM_PROTOCOL_NAME || response.version !== INTERCOM_PROTOCOL_VERSION || response.endpoint !== "local") return false; + const remoteAccess = response.remoteAccess; + if (typeof remoteAccess !== "object" || remoteAccess === null || Array.isArray(remoteAccess)) return false; + const contract = remoteAccess; + return contract.feature === "remote-access-v1" && contract.policySemanticsVersion === POLICY_SEMANTICS_VERSION && contract.policySemanticsHash === POLICY_SEMANTICS_HASH; } function writeWindowsHiddenLauncher(commandLine, launcherPath = getWindowsHiddenLauncherPath()) { ensureIntercomRuntimeDir(dirname2(launcherPath)); @@ -1039,7 +1282,7 @@ async function stopBrokerProcess(pidFile = BROKER_PID, timeoutMs = 3e3) { if (!existsSync2(pidFile)) return; let pid; try { - pid = Number.parseInt(readFileSync3(pidFile, "utf-8").trim(), 10); + pid = Number.parseInt(readFileSync4(pidFile, "utf-8").trim(), 10); } catch { return; } @@ -1066,7 +1309,7 @@ async function isBrokerRunning() { } if (!existsSync2(BROKER_PID)) return false; try { - const pid = parseInt(readFileSync3(BROKER_PID, "utf-8").trim(), 10); + const pid = parseInt(readFileSync4(BROKER_PID, "utf-8").trim(), 10); if (!Number.isFinite(pid)) return false; process.kill(pid, 0); return checkSocketConnectable(); @@ -1167,7 +1410,7 @@ function isSpawnLockStale() { return false; } try { - const [pidLine = "", createdAtLine = "0"] = readFileSync3(BROKER_SPAWN_LOCK, "utf-8").trim().split("\n"); + const [pidLine = "", createdAtLine = "0"] = readFileSync4(BROKER_SPAWN_LOCK, "utf-8").trim().split("\n"); const pid = Number.parseInt(pidLine, 10); const createdAt = Number.parseInt(createdAtLine, 10); const ageMs = Date.now() - createdAt; @@ -1201,7 +1444,7 @@ async function waitForBroker(timeoutMs = 5e3) { } // config.ts -import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs"; +import { existsSync as existsSync3, readFileSync as readFileSync5 } from "fs"; import { join as join4, resolve as resolve2 } from "path"; import { homedir as homedir2 } from "os"; var DEFAULT_ASK_TIMEOUT_MS = 45 * 1e3; @@ -1238,7 +1481,7 @@ function loadConfig() { return { ...defaults }; } try { - const raw = readFileSync4(configPath, "utf-8"); + const raw = readFileSync5(configPath, "utf-8"); const parsed = JSON.parse(raw); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw new Error("Config must be a JSON object"); @@ -1282,7 +1525,7 @@ function loadConfig() { } // opencode/inbound-store.ts -import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs"; +import { existsSync as existsSync4, readFileSync as readFileSync6 } from "fs"; import { dirname as dirname3, join as join5 } from "path"; var EMPTY_STATE = { version: 1, records: {}, delivered: [] }; var MAX_DELIVERED_IDS = 1e3; @@ -1315,7 +1558,7 @@ var DurableInboundStore = class { load() { if (!existsSync4(this.path)) return structuredClone(EMPTY_STATE); try { - return normalizeState(JSON.parse(readFileSync5(this.path, "utf8"))); + return normalizeState(JSON.parse(readFileSync6(this.path, "utf8"))); } catch { return structuredClone(EMPTY_STATE); } @@ -1437,7 +1680,14 @@ function pendingSelector(entries, entry) { } function publicPendingEntry(entry, selector) { return { - from: { id: entry.from.id, name: entry.from.name }, + from: { + id: entry.from.id, + name: entry.from.name, + origin: entry.from.origin ?? "local", + ...entry.from.remoteHostId ? { remote_host_id: entry.from.remoteHostId } : {}, + ...entry.from.parentSessionId ? { parent_session_id: entry.from.parentSessionId } : {}, + ...entry.from.generation ? { generation: entry.from.generation } : {} + }, received_at: entry.receivedAt, read: entry.read, text: entry.message.content.text, @@ -1498,6 +1748,10 @@ function resolveSessionTarget(sessions, nameOrId) { } return null; } +function formatSessionDisplay(session) { + const name = session.name || session.id; + return session.origin === "remote" ? `${name} [remote:${session.remoteHostId || "unknown-host"}]` : name; +} function formatSessionList(sessions, currentSessionId, currentCwd) { if (!sessions.length) return "No intercom sessions connected."; return sessions.map((session) => { @@ -1507,7 +1761,7 @@ function formatSessionList(sessions, currentSessionId, currentCwd) { session.status ].filter((tag) => Boolean(tag)); const suffix = tags.length ? ` [${tags.join(", ")}]` : ""; - return `- ${session.name || "unnamed"} (${session.id.slice(0, 8)}) - ${session.cwd} (${session.model})${suffix}`; + return `- ${formatSessionDisplay(session)} (${session.id.slice(0, 8)}) - ${session.cwd} (${session.model})${suffix}`; }).join("\n"); } function detectGitRoot(cwd) { @@ -1819,12 +2073,12 @@ ${replyText}`, { ok: true, message_id: result.id, reply }); } const pendingAsks = Array.from(this.unresolvedAsks.values()).sort((a, b) => a.receivedAt - b.receivedAt); const lines = [ - unreadMessages.length ? unreadMessages.map((entry) => `- ${entry.from.name || entry.from.id}: ${entry.message.content.text}${formatAttachments(entry.message.content.attachments)}`).join("\n") : "No unread messages.", + unreadMessages.length ? unreadMessages.map((entry) => `- ${formatSessionDisplay(entry.from)}: ${entry.message.content.text}${formatAttachments(entry.message.content.attachments)}`).join("\n") : "No unread messages.", pendingAsks.length ? ` Pending asks: ${pendingAsks.map((entry) => { const selector = pendingSelector(pendingAsks, entry); - return `- ${entry.from.name || entry.from.id}${selector ? ` [${selector}]` : ""}: ${entry.message.content.text}`; + return `- ${formatSessionDisplay(entry.from)}${selector ? ` [${selector}]` : ""}: ${entry.message.content.text}`; }).join("\n")}` : "" ].filter(Boolean); return textResult(lines.join("\n"), { @@ -1958,7 +2212,7 @@ async function invokeAgentFleet(params, context, env = process.env) { // opencode/control.ts import { randomUUID as randomUUID5 } from "node:crypto"; -import { mkdirSync as mkdirSync4, readFileSync as readFileSync6, readdirSync, renameSync as renameSync3, rmSync, writeFileSync as writeFileSync3 } from "node:fs"; +import { mkdirSync as mkdirSync4, readFileSync as readFileSync7, readdirSync, renameSync as renameSync3, rmSync, writeFileSync as writeFileSync3 } from "node:fs"; import { join as join7 } from "node:path"; var CONTROL_DIR_NAME = "opencode-control"; function controlDir() { @@ -1991,7 +2245,7 @@ function startOpenCodeControlServer(options) { const requestPath = join7(directory, file); let request; try { - request = JSON.parse(readFileSync6(requestPath, "utf8")); + request = JSON.parse(readFileSync7(requestPath, "utf8")); } catch { continue; } @@ -2090,7 +2344,7 @@ var OpenCodeIntercomPlugin = async ({ client, directory, serverUrl }) => { return `[agent-intercom-message:${messageID}]`; } function formatInboundPrompt(entry) { - const from = entry.from.name || entry.from.id; + const from = formatSessionDisplay(entry.from); const replyHint = entry.message.expectsReply ? "\n\nThis message expects a reply. Use intercom_reply with only your reply text while this turn is active. If you reply later, use intercom_pending plus the sender and oldest/latest selector." : ""; return [ `Incoming intercom message from ${from} (${entry.from.model}, ${entry.from.cwd}):`, @@ -2225,7 +2479,7 @@ var OpenCodeIntercomPlugin = async ({ client, directory, serverUrl }) => { } } async function injectInbound(entry) { - const from = entry.from.name || entry.from.id; + const from = formatSessionDisplay(entry.from); const prompt = formatInboundPrompt(entry); if (deliveredMessageIDs.has(entry.message.id)) { logInject("inject.skip_delivered", { messageID: entry.message.id }); diff --git a/dist/tui.mjs b/dist/tui.mjs index 9a0b804..b059e59 100644 --- a/dist/tui.mjs +++ b/dist/tui.mjs @@ -1,6 +1,162 @@ // opencode/contact.ts import { spawnSync } from "node:child_process"; +// node_modules/@dataforxyz/agent-intercom-core/dist/policy-vectors.js +var localRoot = { + id: "local-root", + kind: "local", + state: "active", + generation: 1, + policy: "local-public", + rootSessionId: "local-root" +}; +var localPeer = { + id: "local-peer", + kind: "local", + state: "active", + generation: 1, + policy: "local-public", + rootSessionId: "local-peer" +}; +var remoteManager = { + id: "remote-manager", + kind: "remote", + state: "active", + generation: 1, + policy: "remote-tree", + parentSessionId: "local-root", + rootSessionId: "local-root" +}; +var remoteChild = { + id: "remote-child", + kind: "remote", + state: "active", + generation: 1, + policy: "remote-tree", + parentSessionId: "remote-manager", + rootSessionId: "local-root" +}; +var remoteSibling = { + id: "remote-sibling", + kind: "remote", + state: "active", + generation: 1, + policy: "remote-tree", + parentSessionId: "remote-manager", + rootSessionId: "local-root" +}; +var POLICY_VECTORS = [ + { + name: "local sessions remain public", + principals: [localRoot, localPeer], + actorId: "local-root", + action: "send", + targetId: "local-peer", + expectedAllowed: true, + expectedReasonOrCode: "local-public" + }, + { + name: "remote manager can reach direct local parent", + principals: [localRoot, remoteManager], + actorId: "remote-manager", + action: "send", + targetId: "local-root", + expectedAllowed: true, + expectedReasonOrCode: "direct-parent" + }, + { + name: "local parent can reach direct remote child", + principals: [localRoot, remoteManager], + actorId: "local-root", + action: "ask", + targetId: "remote-manager", + expectedAllowed: true, + expectedReasonOrCode: "direct-parent" + }, + { + name: "remote child can reach its local root through the ancestor chain", + principals: [localRoot, remoteManager, remoteChild], + actorId: "remote-child", + action: "send", + targetId: "local-root", + expectedAllowed: true, + expectedReasonOrCode: "ancestor-chain" + }, + { + name: "remote siblings cannot communicate in phase one", + principals: [localRoot, remoteManager, remoteChild, remoteSibling], + actorId: "remote-child", + action: "discover", + targetId: "remote-sibling", + expectedAllowed: false, + expectedReasonOrCode: "POLICY_DENIED" + }, + { + name: "unrelated local session cannot discover remote principal", + principals: [localRoot, localPeer, remoteManager], + actorId: "local-peer", + action: "discover", + targetId: "remote-manager", + expectedAllowed: false, + expectedReasonOrCode: "POLICY_DENIED" + }, + { + name: "remote principal cannot reach unrelated local session", + principals: [localRoot, localPeer, remoteManager], + actorId: "remote-manager", + action: "send", + targetId: "local-peer", + expectedAllowed: false, + expectedReasonOrCode: "POLICY_DENIED" + }, + { + name: "remote manager may inspect its descendant subtree", + principals: [localRoot, remoteManager, remoteChild], + actorId: "remote-manager", + action: "inspect_tree", + targetId: "remote-child", + expectedAllowed: true, + expectedReasonOrCode: "ancestor-control" + }, + { + name: "remote child cannot revoke its ancestor", + principals: [localRoot, remoteManager, remoteChild], + actorId: "remote-child", + action: "revoke", + targetId: "remote-manager", + expectedAllowed: false, + expectedReasonOrCode: "POLICY_DENIED" + }, + { + name: "remote principal may request attenuated delegation under itself", + principals: [localRoot, remoteManager], + actorId: "remote-manager", + action: "delegate_child", + targetId: "remote-manager", + expectedAllowed: true, + expectedReasonOrCode: "self" + }, + { + name: "revoked principal cannot communicate", + principals: [localRoot, { ...remoteManager, state: "revoked" }], + actorId: "remote-manager", + action: "send", + targetId: "local-root", + expectedAllowed: false, + expectedReasonOrCode: "REVOKED_PRINCIPAL" + }, + { + name: "stale actor generation cannot send", + principals: [localRoot, { ...remoteManager, generation: 2 }], + actorId: "remote-manager", + action: "send", + targetId: "local-root", + context: { actorGeneration: 1 }, + expectedAllowed: false, + expectedReasonOrCode: "STALE_GENERATION" + } +]; + // broker/framing.ts var MAX_FRAME_BYTES = 1024 * 1024; diff --git a/opencode/plugin.ts b/opencode/plugin.ts index b460a8d..7e3370c 100644 --- a/opencode/plugin.ts +++ b/opencode/plugin.ts @@ -1,6 +1,6 @@ import { appendFileSync } from "fs"; import { tool, type Plugin } from "@opencode-ai/plugin"; -import { OpenCodeIntercomRuntime, formatAttachments, type PendingInboundMessage } from "./runtime.ts"; +import { OpenCodeIntercomRuntime, formatAttachments, formatSessionDisplay, type PendingInboundMessage } from "./runtime.ts"; import { normalizeOpenCodeSessionStatus, OpenCodePeerHealthReporter } from "./health.ts"; import { invokeAgentFleet, isFleetManagementEnabled } from "./fleet.ts"; import { startOpenCodeControlServer } from "./control.ts"; @@ -101,7 +101,7 @@ export const OpenCodeIntercomPlugin: Plugin = async ({ client, directory, server } function formatInboundPrompt(entry: PendingInboundMessage): string { - const from = entry.from.name || entry.from.id; + const from = formatSessionDisplay(entry.from); const replyHint = entry.message.expectsReply ? "\n\nThis message expects a reply. Use intercom_reply with only your reply text while this turn is active. If you reply later, use intercom_pending plus the sender and oldest/latest selector." : ""; @@ -249,7 +249,7 @@ export const OpenCodeIntercomPlugin: Plugin = async ({ client, directory, server } async function injectInbound(entry: PendingInboundMessage): Promise { - const from = entry.from.name || entry.from.id; + const from = formatSessionDisplay(entry.from); const prompt = formatInboundPrompt(entry); if (deliveredMessageIDs.has(entry.message.id)) { logInject("inject.skip_delivered", { messageID: entry.message.id }); diff --git a/opencode/runtime.test.ts b/opencode/runtime.test.ts index e2a528b..bd8ea8a 100644 --- a/opencode/runtime.test.ts +++ b/opencode/runtime.test.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import test from "node:test"; import type { IntercomClient } from "../broker/client.ts"; import { DurableInboundStore } from "./inbound-store.ts"; -import { buildOpenCodeRuntimeIdentity, OpenCodeIntercomRuntime, selectPendingAsk, type PendingInboundMessage } from "./runtime.ts"; +import { buildOpenCodeRuntimeIdentity, formatSessionDisplay, formatSessionList, OpenCodeIntercomRuntime, selectPendingAsk, type PendingInboundMessage } from "./runtime.ts"; class FakeIntercomClient extends EventEmitter { connected = false; @@ -38,6 +38,12 @@ test("Intercom identity does not conflate the OpenCode session namespace", () => assert.notEqual(fallback.sessionId, "ses_open_code"); }); +test("remote session provenance is visible in model-facing labels", () => { + const remote = { id: "remote", name: "worker", cwd: "/repo", model: "test", pid: 1, startedAt: 1, lastActivity: 1, origin: "remote" as const, remoteHostId: "ika-dev-v3" }; + assert.equal(formatSessionDisplay(remote), "worker [remote:ika-dev-v3]"); + assert.match(formatSessionList([remote], null, "/other"), /worker \[remote:ika-dev-v3\]/); +}); + test("selectPendingAsk uses oldest/latest without exposing message IDs", () => { const from = { id: "sender-1", name: "sender", cwd: "/repo", model: "test", pid: 1, startedAt: 1, lastActivity: 1 }; const pending = (id: string, receivedAt: number): PendingInboundMessage => ({ diff --git a/opencode/runtime.ts b/opencode/runtime.ts index c071293..ae3b9b1 100644 --- a/opencode/runtime.ts +++ b/opencode/runtime.ts @@ -54,7 +54,14 @@ function pendingSelector(entries: PendingInboundMessage[], entry: PendingInbound function publicPendingEntry(entry: PendingInboundMessage, selector?: string): Record { return { - from: { id: entry.from.id, name: entry.from.name }, + from: { + id: entry.from.id, + name: entry.from.name, + origin: entry.from.origin ?? "local", + ...(entry.from.remoteHostId ? { remote_host_id: entry.from.remoteHostId } : {}), + ...(entry.from.parentSessionId ? { parent_session_id: entry.from.parentSessionId } : {}), + ...(entry.from.generation ? { generation: entry.from.generation } : {}), + }, received_at: entry.receivedAt, read: entry.read, text: entry.message.content.text, @@ -134,6 +141,11 @@ export function resolveSessionTarget(sessions: SessionInfo[], nameOrId: string): return null; } +export function formatSessionDisplay(session: SessionInfo): string { + const name = session.name || session.id; + return session.origin === "remote" ? `${name} [remote:${session.remoteHostId || "unknown-host"}]` : name; +} + export function formatSessionList(sessions: SessionInfo[], currentSessionId: string | null, currentCwd: string): string { if (!sessions.length) return "No intercom sessions connected."; return sessions.map((session) => { @@ -143,7 +155,7 @@ export function formatSessionList(sessions: SessionInfo[], currentSessionId: str session.status, ].filter((tag): tag is string => Boolean(tag)); const suffix = tags.length ? ` [${tags.join(", ")}]` : ""; - return `- ${session.name || "unnamed"} (${session.id.slice(0, 8)}) - ${session.cwd} (${session.model})${suffix}`; + return `- ${formatSessionDisplay(session)} (${session.id.slice(0, 8)}) - ${session.cwd} (${session.model})${suffix}`; }).join("\n"); } @@ -486,12 +498,12 @@ export class OpenCodeIntercomRuntime { const pendingAsks = Array.from(this.unresolvedAsks.values()).sort((a, b) => a.receivedAt - b.receivedAt); const lines = [ unreadMessages.length - ? unreadMessages.map((entry) => `- ${entry.from.name || entry.from.id}: ${entry.message.content.text}${formatAttachments(entry.message.content.attachments)}`).join("\n") + ? unreadMessages.map((entry) => `- ${formatSessionDisplay(entry.from)}: ${entry.message.content.text}${formatAttachments(entry.message.content.attachments)}`).join("\n") : "No unread messages.", pendingAsks.length ? `\nPending asks:\n${pendingAsks.map((entry) => { const selector = pendingSelector(pendingAsks, entry); - return `- ${entry.from.name || entry.from.id}${selector ? ` [${selector}]` : ""}: ${entry.message.content.text}`; + return `- ${formatSessionDisplay(entry.from)}${selector ? ` [${selector}]` : ""}: ${entry.message.content.text}`; }).join("\n")}` : "", ].filter(Boolean); diff --git a/package.json b/package.json index eca5839..e65cab5 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "scripts": { "build": "node scripts/build.mjs", "prepare": "npm run build", - "test": "tsx --test broker/client.test.ts broker/framing.test.ts broker/ownership.test.ts broker/paths.test.ts broker/spawn.test.ts opencode/contact.test.ts opencode/control.test.ts opencode/fleet.test.ts opencode/health.test.ts opencode/inbound-store.test.ts opencode/runtime.test.ts opencode/team.test.ts opencode/tui.test.ts", + "test": "tsx --test broker/*.test.ts opencode/contact.test.ts opencode/control.test.ts opencode/fleet.test.ts opencode/health.test.ts opencode/inbound-store.test.ts opencode/runtime.test.ts opencode/team.test.ts opencode/tui.test.ts", "typecheck": "tsc --noEmit" }, "keywords": [ @@ -44,6 +44,7 @@ "claude" ], "dependencies": { + "@dataforxyz/agent-intercom-core": "git+https://github.com/dataforxyz/agent-intercom-core.git#cb5d2212912db0cd8abbb16ab08e4b539424a05d", "@opencode-ai/plugin": "^1.17.15" }, "devDependencies": { diff --git a/types.ts b/types.ts index a186af3..bd304c9 100644 --- a/types.ts +++ b/types.ts @@ -9,6 +9,15 @@ export interface SessionInfo { status?: string; peerUid?: number; trustedLocal?: boolean; + origin?: "local" | "remote"; + remoteHostId?: string; + parentSessionId?: string; + rootSessionId?: string; + generation?: number; + canDelegate?: boolean; + depth?: number; + maxDepth?: number; + maxChildren?: number; } export interface Message { @@ -29,7 +38,60 @@ export interface Attachment { language?: string; } -export type SessionRegistration = Omit; +export type SessionRegistration = Omit< + SessionInfo, + "id" | "peerUid" | "trustedLocal" | "origin" | "remoteHostId" | "parentSessionId" | "rootSessionId" | "generation" | "canDelegate" | "depth" | "maxDepth" | "maxChildren" +>; + +export interface RemoteEnrollmentAccess { + enrollmentToken: string; +} + +export interface RemoteSessionAccess { + sessionCredential: string; + sessionId: string; + generation: number; +} + +export type RemoteRegistrationAccess = RemoteEnrollmentAccess | RemoteSessionAccess; + +export interface RemoteAccessMetadata { + origin: "remote"; + remoteHostId: string; + parentSessionId: string; + rootSessionId: string; + generation: number; + canDelegate: boolean; + depth: number; + maxDepth: number; + maxChildren: number; + sessionCredential?: string; +} + +export interface RemotePrincipalSummary { + id: string; + name: string; + parentSessionId: string; + rootSessionId: string; + remoteHostId: string; + generation: number; + policy: "remote-tree"; + canDelegate: boolean; + depth: number; + maxDepth: number; + maxChildren: number; + state: "active" | "revoked"; + expiresAt: number; + createdAt: number; + updatedAt: number; + connected: boolean; +} + +export interface RemoteAccessContract { + feature: "remote-access-v1"; + policySemanticsVersion: number; + policySemanticsHash: string; +} export type DeliveryFailureCode = | "INVALID_MESSAGE" @@ -50,6 +112,8 @@ export type DeliveryFailureCode = export type BrokerErrorCode = | "PROTOCOL_MISMATCH" | "INVALID_REQUEST" + | "ACCESS_DENIED" + | "REMOTE_ACCESS_INCOMPATIBLE" | "RATE_LIMITED" | "TOO_MANY_SESSIONS"; @@ -57,10 +121,18 @@ export type AskCancellationReason = | "cancelled" | "expired" | "delivery_failed" - | "session_disconnected"; + | "session_disconnected" + | "authorization_revoked"; export type ClientMessage = - | { type: "register"; protocol: string; version: number; session: SessionRegistration; sessionId?: string; stateId?: string } + | { type: "health"; requestId: string; stateId?: string } + | { type: "register"; protocol: string; version: number; session: SessionRegistration; sessionId?: string; stateId?: string; access?: RemoteRegistrationAccess } + | { type: "access_control"; requestId: string; adminToken: string; action: "issue_enrollment"; enrollment: { name: string; parentSessionId: string; rootSessionId: string; remoteHostId: string; ttlMs?: number; expiresAt?: number; canDelegate?: boolean; maxDepth?: number; maxChildren?: number } } + | { type: "access_control"; requestId: string; adminToken: string; action: "revoke_subtree"; principalId: string } + | { type: "access_control"; requestId: string; adminToken: string; action: "inspect_tree"; principalId: string } + | { type: "access_control"; requestId: string; adminToken: string; action: "adopt_subtree"; principalId: string; newParentSessionId: string } + | { type: "access_control"; requestId: string; access: RemoteSessionAccess; action: "issue_child_enrollment"; enrollment: { name: string; ttlMs?: number; expiresAt?: number; canDelegate?: boolean; maxDepth?: number; maxChildren?: number } } + | { type: "access_control"; requestId: string; access: RemoteSessionAccess; action: "inspect_tree"; principalId?: string } | { type: "unregister"; preserveAsks?: boolean } | { type: "list"; requestId: string } | { type: "send"; to: string; message: Message } @@ -71,7 +143,13 @@ export type ClientMessage = | { type: "presence"; name?: string; status?: string; model?: string }; export type BrokerMessage = - | { type: "registered"; sessionId: string; protocol: string; version: number } + | { type: "health_ok"; requestId: string; protocol: string; version: number; endpoint: "local" | "remote"; remoteAccess?: RemoteAccessContract } + | { type: "registered"; sessionId: string; protocol: string; version: number; remoteAccess?: RemoteAccessContract; access?: RemoteAccessMetadata } + | { type: "access_control_result"; requestId: string; action: "issue_enrollment"; enrollmentToken: string; expiresAt: number } + | { type: "access_control_result"; requestId: string; action: "revoke_subtree"; changedPrincipalIds: string[] } + | { type: "access_control_result"; requestId: string; action: "inspect_tree"; principals: RemotePrincipalSummary[] } + | { type: "access_control_result"; requestId: string; action: "adopt_subtree"; principals: RemotePrincipalSummary[] } + | { type: "access_control_result"; requestId: string; action: "issue_child_enrollment"; enrollmentToken: string; expiresAt: number; parentSessionId: string } | { type: "sessions"; requestId: string; sessions: SessionInfo[] } | { type: "message"; deliveryId: string; from: SessionInfo; message: Message } | { type: "presence_update"; session: SessionInfo }