From 0f58b18fe3b14bfff9ce6c27d36230bb6836ad5f Mon Sep 17 00:00:00 2001 From: Luke Brevoort Date: Thu, 13 Aug 2026 13:44:21 -0700 Subject: [PATCH 01/14] =?UTF-8?q?feat(peers):=20linked=20Dispatch=20instan?= =?UTF-8?q?ces=20=E2=80=94=20pairing,=20remote=20launch,=20shadow=20rows,?= =?UTF-8?q?=20cross-instance=20messaging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the peer service: tailnet identity & binding (whois-pinned StableID), TV-model pairing with per-direction credentials and pair-time launch policy, dispatch_launch_agent location param with local shadow rows, SSE status mirroring per peer, and durable outbox + idempotency-keyed message delivery. Co-Authored-By: Claude Fable 5 --- apps/server/src/agents/manager.ts | 56 +++ apps/server/src/agents/types.ts | 4 + apps/server/src/db/migrations/0041_peers.sql | 87 ++++ apps/server/src/peers/events.ts | 223 ++++++++++ apps/server/src/peers/identity.ts | 20 + apps/server/src/peers/launch.ts | 269 ++++++++++++ apps/server/src/peers/messages.ts | 244 +++++++++++ apps/server/src/peers/pairing.ts | 391 ++++++++++++++++++ apps/server/src/peers/peer-auth.ts | 103 +++++ apps/server/src/peers/peer-settings.ts | 21 + apps/server/src/peers/runtime.ts | 110 +++++ apps/server/src/peers/tailnet-listener.ts | 84 ++++ apps/server/src/peers/tailscale.ts | 157 +++++++ apps/server/src/routes/peers.ts | 355 ++++++++++++++++ apps/server/src/server.ts | 77 +++- apps/server/src/server/agent-prompts.ts | 14 +- apps/server/src/server/mcp-handlers.ts | 126 +++++- .../src/shared/mcp/agent-launch-tools.ts | 10 +- apps/server/test/peers-pairing.test.ts | 256 ++++++++++++ .../test/peers-tailnet-listener.test.ts | 89 ++++ apps/server/test/peers-tailscale.test.ts | 113 +++++ apps/web/src/components/app/agent-card.tsx | 8 + .../app/linked-instances-settings.tsx | 298 +++++++++++++ apps/web/src/components/app/settings-pane.tsx | 10 +- apps/web/src/components/app/types.ts | 3 + 25 files changed, 3120 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/db/migrations/0041_peers.sql create mode 100644 apps/server/src/peers/events.ts create mode 100644 apps/server/src/peers/identity.ts create mode 100644 apps/server/src/peers/launch.ts create mode 100644 apps/server/src/peers/messages.ts create mode 100644 apps/server/src/peers/pairing.ts create mode 100644 apps/server/src/peers/peer-auth.ts create mode 100644 apps/server/src/peers/peer-settings.ts create mode 100644 apps/server/src/peers/runtime.ts create mode 100644 apps/server/src/peers/tailnet-listener.ts create mode 100644 apps/server/src/peers/tailscale.ts create mode 100644 apps/server/src/routes/peers.ts create mode 100644 apps/server/test/peers-pairing.test.ts create mode 100644 apps/server/test/peers-tailnet-listener.test.ts create mode 100644 apps/server/test/peers-tailscale.test.ts create mode 100644 apps/web/src/components/app/linked-instances-settings.tsx diff --git a/apps/server/src/agents/manager.ts b/apps/server/src/agents/manager.ts index cb816cfe..7207fb1f 100644 --- a/apps/server/src/agents/manager.ts +++ b/apps/server/src/agents/manager.ts @@ -584,6 +584,53 @@ export class AgentManager { ); } + /** + * Insert a shadow row for an agent that runs on a linked instance. No tmux + * session, no setup script — status and events are mirrored from the peer. + */ + async createShadowAgent(input: { + peerId: string; + remoteId: string; + name: string; + type: AgentType; + cwd: string; + status?: AgentStatus; + parentAgentId?: string; + }): Promise { + const id = this.newAgentId(); + await this.pool.query( + `INSERT INTO agents (id, name, type, role, status, cwd, peer_id, remote_id, parent_agent_id, codex_args, updated_at) + VALUES ($1, $2, $3, 'standard', $4, $5, $6, $7, $8, '[]'::jsonb, NOW())`, + [ + id, + input.name, + input.type, + input.status ?? "creating", + input.cwd, + input.peerId, + input.remoteId, + input.parentAgentId ?? null, + ] + ); + return await this.getRequiredAgent(id); + } + + /** Mirror a peer-reported status onto a shadow row. */ + async updateShadowAgent( + id: string, + update: { status?: AgentStatus; name?: string } + ): Promise { + await this.pool.query( + `UPDATE agents + SET status = COALESCE($2, status), + name = COALESCE($3, name), + updated_at = NOW() + WHERE id = $1 AND peer_id IS NOT NULL`, + [id, update.status ?? null, update.name ?? null] + ); + return await this.getAgent(id); + } + private async launchInertAgent(opts: { id: string; type: AgentType; @@ -979,6 +1026,13 @@ export class AgentManager { async getTerminalAccess(id: string): Promise { const agent = await this.getRequiredAgent(id); + if (agent.peerId) { + // Shadow row: the pane lives on another instance and is not proxied. + return { + mode: "inert", + message: `This agent runs on linked instance "${agent.peerId}" — its terminal is not available here.`, + }; + } if (agent.status !== "running" && agent.status !== "creating") { throw new AgentError("Agent is not running.", 409); } @@ -1433,6 +1487,8 @@ export class AgentManager { template_id AS "templateId", auto_review AS "autoReview", cli_session_id AS "cliSessionId", + peer_id AS "peerId", + remote_id AS "remoteId", ( SELECT unified_review.id FROM reviews unified_review diff --git a/apps/server/src/agents/types.ts b/apps/server/src/agents/types.ts index f2c74cd5..972d464c 100644 --- a/apps/server/src/agents/types.ts +++ b/apps/server/src/agents/types.ts @@ -124,6 +124,10 @@ export type AgentRecord = { templateId: string | null; autoReview: boolean; cliSessionId: string | null; + /** Set on shadow rows: the linked instance actually running this agent. */ + peerId: string | null; + /** The agent's id on that peer instance. */ + remoteId: string | null; createdAt: string; updatedAt: string; }; diff --git a/apps/server/src/db/migrations/0041_peers.sql b/apps/server/src/db/migrations/0041_peers.sql new file mode 100644 index 00000000..9073935b --- /dev/null +++ b/apps/server/src/db/migrations/0041_peers.sql @@ -0,0 +1,87 @@ +-- Linked Dispatch instances: pairing, per-direction credentials, and the +-- pair-time launch policy. Generalized from the browser-extension pairing +-- shape (0035); differences: the code renders on the accepting side, +-- credentials are issued in BOTH directions, and the caller's tailnet +-- StableID is pinned alongside the token. + +CREATE TABLE IF NOT EXISTS peers ( + id text PRIMARY KEY, -- the peer's instance_id (inst_*) + name text NOT NULL, -- display name shown in pickers + url text NOT NULL, -- base URL we dial (MagicDNS or public) + tailnet_stable_id text, -- the peer node's durable tailscale ID + outbound_token text NOT NULL, -- bearer WE present to THEM + created_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz, + revoked_at timestamptz +); + +-- Bearer tokens THEY present to US, plus the standing pair-time policy. +CREATE TABLE IF NOT EXISTS peer_credentials ( + id uuid PRIMARY KEY, + peer_id text NOT NULL REFERENCES peers(id) ON DELETE CASCADE, + token_hash text NOT NULL UNIQUE, + tailnet_stable_id text, -- pinned caller identity; NULL only for non-tailnet pairings + allow_launch boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + last_used_at timestamptz, + revoked_at timestamptz +); + +CREATE INDEX IF NOT EXISTS peer_credentials_active_idx + ON peer_credentials (token_hash) + WHERE revoked_at IS NULL; + +-- Short-lived pairing offers displayed on THIS (accepting) instance. +CREATE TABLE IF NOT EXISTS peer_pairings ( + id uuid PRIMARY KEY, + code_hash text NOT NULL, + allow_launch boolean NOT NULL DEFAULT true, + require_tailnet boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + claimed_at timestamptz, + peer_id text REFERENCES peers(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS peer_pairings_expires_idx + ON peer_pairings (expires_at); + +-- Shadow rows: an agent launched on a peer gets a LOCAL agents row (a local +-- id, no tmux session) so every existing consumer — sidebar, list_agents, +-- message targeting, SSE — keeps working unchanged. Only the ORIGINATING +-- instance holds the shadow; on the executing instance it is a plain agent. +ALTER TABLE agents ADD COLUMN IF NOT EXISTS peer_id text; +ALTER TABLE agents ADD COLUMN IF NOT EXISTS remote_id text; + +CREATE INDEX IF NOT EXISTS agents_peer_remote_idx + ON agents (peer_id, remote_id) + WHERE peer_id IS NOT NULL; + +-- Messages are CONTENT: losing one loses work, and a blind retry would +-- double-inject. Hence a durable sender-side outbox with backoff, and a +-- receiver-side receipt per idempotency key. Status/events are STATE and get +-- neither — the next event supersedes a dropped one. +CREATE TABLE IF NOT EXISTS peer_outbox ( + id uuid PRIMARY KEY, + peer_id text NOT NULL REFERENCES peers(id) ON DELETE CASCADE, + path text NOT NULL, + body jsonb NOT NULL, + idempotency_key uuid NOT NULL, + attempts int NOT NULL DEFAULT 0, + next_attempt_at timestamptz NOT NULL DEFAULT now(), + last_error text, + created_at timestamptz NOT NULL DEFAULT now(), + delivered_at timestamptz +); + +CREATE INDEX IF NOT EXISTS peer_outbox_due_idx + ON peer_outbox (next_attempt_at) + WHERE delivered_at IS NULL; + +CREATE TABLE IF NOT EXISTS peer_message_receipts ( + peer_id text NOT NULL, + idempotency_key uuid NOT NULL, + delivered boolean NOT NULL DEFAULT false, + received_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (peer_id, idempotency_key) +); diff --git a/apps/server/src/peers/events.ts b/apps/server/src/peers/events.ts new file mode 100644 index 00000000..62468e34 --- /dev/null +++ b/apps/server/src/peers/events.ts @@ -0,0 +1,223 @@ +import type { FastifyBaseLogger } from "fastify"; +import type { Pool } from "pg"; + +import type { AgentManager } from "../agents/manager.js"; +import type { AgentRecord, AgentStatus } from "../agents/types.js"; + +const RECONNECT_BASE_MS = 5_000; +const RECONNECT_MAX_MS = 60_000; +const PEER_RESCAN_INTERVAL_MS = 60_000; + +type RemoteAgentShape = { + id?: string; + name?: string; + status?: string; +}; + +type PeerRow = { id: string; url: string; outbound_token: string }; + +const AGENT_STATUSES: AgentStatus[] = [ + "creating", + "running", + "stopping", + "stopped", + "error", + "archived", +] as AgentStatus[]; + +function asStatus(value: string | undefined): AgentStatus | undefined { + return AGENT_STATUSES.includes(value as AgentStatus) + ? (value as AgentStatus) + : undefined; +} + +/** + * One long-lived SSE subscription per linked peer. Remote agent.upsert events + * are mirrored onto this instance's shadow rows and rebroadcast on the local + * bus, so the web UI needs zero changes. Status is state, not content: no + * outbox, no replay — the snapshot each (re)connect pushes supersedes + * anything missed while disconnected. + */ +export class PeerEventSubscriber { + private controllers = new Map(); + private rescanTimer: NodeJS.Timeout | null = null; + private stopped = false; + + constructor( + private readonly deps: { + pool: Pool; + agentManager: AgentManager; + publishUiEvent: (event: { type: string; agent: unknown }) => void; + withStreamFlag: ( + agent: T + ) => T & { hasStream: boolean }; + /** Called on each successful connect — the moment to drain the outbox. */ + onPeerReachable?: (peerId: string) => void; + log: FastifyBaseLogger; + fetchImpl?: typeof fetch; + } + ) {} + + start(): void { + this.stopped = false; + void this.rescan(); + this.rescanTimer = setInterval( + () => void this.rescan(), + PEER_RESCAN_INTERVAL_MS + ); + this.rescanTimer.unref(); + } + + stop(): void { + this.stopped = true; + if (this.rescanTimer) clearInterval(this.rescanTimer); + this.rescanTimer = null; + for (const controller of this.controllers.values()) controller.abort(); + this.controllers.clear(); + } + + /** Reconcile subscriptions with the current peer list. */ + private async rescan(): Promise { + if (this.stopped) return; + let peers: PeerRow[]; + try { + const result = await this.deps.pool.query( + `SELECT id, url, outbound_token FROM peers WHERE revoked_at IS NULL` + ); + peers = result.rows; + } catch (error) { + this.deps.log.warn({ err: error }, "Peer rescan query failed"); + return; + } + const wanted = new Set(peers.map((p) => p.id)); + for (const [peerId, controller] of this.controllers) { + if (!wanted.has(peerId)) { + controller.abort(); + this.controllers.delete(peerId); + } + } + for (const peer of peers) { + if (!this.controllers.has(peer.id)) { + const controller = new AbortController(); + this.controllers.set(peer.id, controller); + void this.subscribeLoop(peer, controller.signal); + } + } + } + + private async subscribeLoop( + peer: PeerRow, + signal: AbortSignal + ): Promise { + let attempt = 0; + while (!signal.aborted && !this.stopped) { + try { + const response = await (this.deps.fetchImpl ?? fetch)( + `${peer.url}/api/v1/peers/events`, + { + headers: { authorization: `Bearer ${peer.outbound_token}` }, + signal, + } + ); + if (!response.ok || !response.body) { + throw new Error(`Peer event stream responded ${response.status}.`); + } + attempt = 0; + this.deps.onPeerReachable?.(peer.id); + await this.consumeStream(peer.id, response.body, signal); + } catch (error) { + if (signal.aborted) return; + this.deps.log.debug( + { err: error, peerId: peer.id }, + "Peer event stream dropped; will reconnect" + ); + } + attempt += 1; + const delay = Math.min( + RECONNECT_BASE_MS * 2 ** Math.min(attempt, 6), + RECONNECT_MAX_MS + ); + await new Promise((resolve) => setTimeout(resolve, delay).unref?.()); + } + } + + private async consumeStream( + peerId: string, + body: ReadableStream, + signal: AbortSignal + ): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + while (!signal.aborted) { + const { done, value } = await reader.read(); + if (done) return; + buffer += decoder.decode(value, { stream: true }); + let sep: number; + while ((sep = buffer.indexOf("\n\n")) !== -1) { + const frame = buffer.slice(0, sep); + buffer = buffer.slice(sep + 2); + const data = frame + .split("\n") + .filter((line) => line.startsWith("data: ")) + .map((line) => line.slice(6)) + .join("\n"); + if (data) await this.handleEvent(peerId, data); + } + } + } finally { + reader.releaseLock(); + } + } + + private async handleEvent(peerId: string, data: string): Promise { + let event: { + type?: string; + agent?: RemoteAgentShape; + agents?: RemoteAgentShape[]; + }; + try { + event = JSON.parse(data); + } catch { + return; + } + if (event.type === "agent.upsert" && event.agent?.id) { + await this.mirrorRemoteAgent(peerId, event.agent); + } else if (event.type === "snapshot" && Array.isArray(event.agents)) { + for (const agent of event.agents) { + if (agent?.id) await this.mirrorRemoteAgent(peerId, agent); + } + } + } + + private async mirrorRemoteAgent( + peerId: string, + remote: RemoteAgentShape + ): Promise { + try { + const shadow = await this.deps.pool.query<{ id: string }>( + `SELECT id FROM agents + WHERE peer_id = $1 AND remote_id = $2 AND deleted_at IS NULL`, + [peerId, remote.id] + ); + const shadowId = shadow.rows[0]?.id; + if (!shadowId) return; + const updated = await this.deps.agentManager.updateShadowAgent(shadowId, { + status: asStatus(remote.status), + name: remote.name, + }); + if (updated) { + this.deps.publishUiEvent({ + type: "agent.upsert", + agent: this.deps.withStreamFlag(updated), + }); + } + } catch (error) { + this.deps.log.warn( + { err: error, peerId, remoteId: remote.id }, + "Failed to mirror remote agent onto shadow row" + ); + } + } +} diff --git a/apps/server/src/peers/identity.ts b/apps/server/src/peers/identity.ts new file mode 100644 index 00000000..06ac11d2 --- /dev/null +++ b/apps/server/src/peers/identity.ts @@ -0,0 +1,20 @@ +import crypto from "node:crypto"; +import type { Pool } from "pg"; + +import { getSetting, setSetting } from "../db/settings.js"; + +/** + * Durable identity for this Dispatch instance, used in peer pairing and + * qualified agent addresses. Distinct from the cosmetic, user-editable + * `instance_name` in routes/system.ts — this one is unique and never shown + * for vanity purposes. + */ +const INSTANCE_ID_KEY = "instance_id"; + +export async function getOrCreateInstanceId(pool: Pool): Promise { + const stored = await getSetting(pool, INSTANCE_ID_KEY); + if (stored) return stored; + const id = `inst_${crypto.randomBytes(6).toString("hex")}`; + await setSetting(pool, INSTANCE_ID_KEY, id); + return id; +} diff --git a/apps/server/src/peers/launch.ts b/apps/server/src/peers/launch.ts new file mode 100644 index 00000000..13d8e28a --- /dev/null +++ b/apps/server/src/peers/launch.ts @@ -0,0 +1,269 @@ +import { randomUUID } from "node:crypto"; + +import type { Pool } from "pg"; + +import type { AgentManager } from "../agents/manager.js"; +import type { AgentType } from "../agents/types.js"; +import { + CLI_AGENT_TYPES, + getEnabledAgentTypes, +} from "../agent-type-settings.js"; +import { validateAgentModel } from "../shared/agent-models.js"; +import { getWorktreeLocation } from "../worktree-location-settings.js"; +import { getOrCreateInstanceId } from "./identity.js"; + +/** + * Everything a remote launch needs is explicit in this payload — the usual + * parent-derived defaults (type, cwd, fullAccess) cannot cross instances + * because the parent lives on the other machine. + */ +export type PeerLaunchPayload = { + name: string; + prompt: string; + type: string; + model?: string; + cwd: string; + fullAccess?: boolean; + useWorktree?: boolean; + createNewBranch?: boolean; + baseBranch?: string; + worktreeBranch?: string; + /** Qualified address of the launching agent: ":". */ + parentAddress: string; +}; + +export type PeerLaunchResult = { + agentId: string; + name: string; + status: string; +}; + +function buildRemoteChildInitialPrompt( + parentAddress: string, + prompt: string +): string { + return [ + `You were launched from a linked Dispatch instance by agent "${parentAddress}" via dispatch_launch_agent.`, + "Use that full address (instance:agent) as the target when coordinating back with dispatch_send_message.", + "", + prompt, + ].join("\n"); +} + +/** + * Receiver side of POST /api/v1/peers/launch: run the launch through this + * instance's own createAgent, exactly like a local launch. Nothing here is + * peer-special beyond the qualified parent address in the child's preamble. + */ +export async function handleIncomingPeerLaunch( + deps: { pool: Pool; agentManager: AgentManager }, + payload: PeerLaunchPayload +): Promise { + const agentType = payload.type; + if ( + !CLI_AGENT_TYPES.includes(agentType as (typeof CLI_AGENT_TYPES)[number]) + ) { + throw new Error( + `Unsupported agent type "${agentType}". Must be one of: ${CLI_AGENT_TYPES.join(", ")}.` + ); + } + const enabled = await getEnabledAgentTypes(deps.pool); + if (!enabled.includes(agentType as (typeof CLI_AGENT_TYPES)[number])) { + throw new Error(`${agentType} agents are disabled on this instance.`); + } + const model = validateAgentModel( + agentType as (typeof CLI_AGENT_TYPES)[number], + payload.model + ); + const worktreeLocation = await getWorktreeLocation(deps.pool); + const cliSessionId = agentType === "claude" ? randomUUID() : undefined; + + const agent = await deps.agentManager.createAgent({ + cliSessionId, + name: payload.name, + type: agentType as AgentType, + cwd: payload.cwd, + fullAccess: payload.fullAccess ?? false, + model, + useWorktree: payload.useWorktree ?? false, + createNewBranch: payload.createNewBranch ?? false, + baseBranch: payload.baseBranch, + worktreeBranch: payload.worktreeBranch, + worktreeLocation, + initialPrompt: buildRemoteChildInitialPrompt( + payload.parentAddress, + payload.prompt + ), + }); + return { agentId: agent.id, name: agent.name, status: agent.status }; +} + +export type PeerRepo = { root: string; name: string }; + +/** + * The repos this instance can launch into: distinct repo roots its agents + * have worked in. "The environment already exists" is a design assumption — + * remote provisioning is out of scope. + */ +export async function listLocalRepos(pool: Pool): Promise { + const result = await pool.query<{ root: string }>( + `SELECT DISTINCT COALESCE(git_context->>'repoRoot', cwd) AS root + FROM agents + WHERE deleted_at IS NULL AND peer_id IS NULL + ORDER BY root` + ); + return result.rows.map((row) => ({ + root: row.root, + name: row.root.split("/").filter(Boolean).at(-1) ?? row.root, + })); +} + +export type ResolvedPeer = { + id: string; + name: string; + url: string; + outboundToken: string; +}; + +/** Look up a linked peer by display name or instance id. */ +export async function resolvePeerLocation( + pool: Pool, + location: string +): Promise<{ ok: true; peer: ResolvedPeer } | { ok: false; error: string }> { + const peers = await pool.query<{ + id: string; + name: string; + url: string; + outbound_token: string; + }>( + `SELECT id, name, url, outbound_token FROM peers WHERE revoked_at IS NULL` + ); + const matches = peers.rows.filter( + (p) => p.id === location || p.name === location + ); + if (matches.length === 0) { + const known = peers.rows.map((p) => p.name).join(", ") || "none"; + return { + ok: false, + error: `Unknown location "${location}". Linked instances: ${known}.`, + }; + } + if (matches.length > 1) { + return { + ok: false, + error: `Location "${location}" is ambiguous — use the instance id instead (${matches.map((p) => p.id).join(", ")}).`, + }; + } + const peer = matches[0]; + return { + ok: true, + peer: { + id: peer.id, + name: peer.name, + url: peer.url, + outboundToken: peer.outbound_token, + }, + }; +} + +async function peerFetch( + peer: ResolvedPeer, + path: string, + init: RequestInit | undefined, + fetchImpl: typeof fetch +): Promise { + return await fetchImpl(`${peer.url}${path}`, { + ...init, + headers: { + authorization: `Bearer ${peer.outboundToken}`, + ...(init?.body ? { "content-type": "application/json" } : {}), + ...(init?.headers ?? {}), + }, + signal: AbortSignal.timeout(30_000), + }); +} + +export async function fetchPeerRepos( + peer: ResolvedPeer, + fetchImpl: typeof fetch = fetch +): Promise { + const response = await peerFetch(peer, "/api/v1/peers/repos", {}, fetchImpl); + if (!response.ok) { + throw new Error( + `Peer "${peer.name}" repo listing failed (${response.status}).` + ); + } + const body = (await response.json()) as { repos?: PeerRepo[] }; + return body.repos ?? []; +} + +/** + * Sender side: POST the launch to the peer, then mint the local shadow row + * pointing at the remote agent so the rest of Dispatch needs no changes. + */ +export async function launchAgentOnPeer( + deps: { + pool: Pool; + agentManager: AgentManager; + fetchImpl?: typeof fetch; + }, + peer: ResolvedPeer, + input: { + name: string; + prompt: string; + type: string; + model?: string; + cwd: string; + fullAccess?: boolean; + parentAgentId: string; + } +): Promise<{ shadowAgentId: string; remoteAgentId: string; name: string }> { + const fetchImpl = deps.fetchImpl ?? fetch; + const instanceId = await getOrCreateInstanceId(deps.pool); + const payload: PeerLaunchPayload = { + name: input.name, + prompt: input.prompt, + type: input.type, + model: input.model, + cwd: input.cwd, + fullAccess: input.fullAccess, + parentAddress: `${instanceId}:${input.parentAgentId}`, + }; + let response: Response; + try { + response = await peerFetch( + peer, + "/api/v1/peers/launch", + { method: "POST", body: JSON.stringify(payload) }, + fetchImpl + ); + } catch { + throw new Error( + `Could not reach linked instance "${peer.name}" at ${peer.url}.` + ); + } + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { + error?: string; + } | null; + throw new Error( + body?.error ?? `Launch on "${peer.name}" failed (${response.status}).` + ); + } + const result = (await response.json()) as PeerLaunchResult; + + const shadow = await deps.agentManager.createShadowAgent({ + peerId: peer.id, + remoteId: result.agentId, + name: result.name, + type: input.type as AgentType, + cwd: input.cwd, + status: "creating", + parentAgentId: input.parentAgentId, + }); + return { + shadowAgentId: shadow.id, + remoteAgentId: result.agentId, + name: result.name, + }; +} diff --git a/apps/server/src/peers/messages.ts b/apps/server/src/peers/messages.ts new file mode 100644 index 00000000..b468d28a --- /dev/null +++ b/apps/server/src/peers/messages.ts @@ -0,0 +1,244 @@ +import crypto from "node:crypto"; + +import type { FastifyBaseLogger } from "fastify"; +import type { Pool } from "pg"; + +const MAX_BACKOFF_MS = 15 * 60 * 1000; +const BASE_BACKOFF_MS = 30 * 1000; +const DRAIN_INTERVAL_MS = 30 * 1000; +const RECEIPT_RETENTION_DAYS = 30; + +export type PeerMessageBody = { + targetAgentId: string; + prompt: string; + idempotencyKey: string; +}; + +type OutboxRow = { + id: string; + peer_id: string; + path: string; + body: PeerMessageBody; + attempts: number; +}; + +type PeerDialInfo = { url: string; outbound_token: string }; + +async function loadPeerDialInfo( + pool: Pool, + peerId: string +): Promise { + const result = await pool.query( + `SELECT url, outbound_token FROM peers WHERE id = $1 AND revoked_at IS NULL`, + [peerId] + ); + return result.rows[0] ?? null; +} + +async function postToPeer( + peer: PeerDialInfo, + path: string, + body: unknown, + fetchImpl: typeof fetch +): Promise { + const response = await fetchImpl(`${peer.url}${path}`, { + method: "POST", + headers: { + authorization: `Bearer ${peer.outbound_token}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { + error?: string; + } | null; + throw new Error(payload?.error ?? `Peer responded ${response.status}.`); + } +} + +/** + * Durable cross-instance message delivery. Every message is written to the + * outbox BEFORE the first send attempt, so a crash mid-send retries instead + * of losing content; the receiver dedupes on the idempotency key, so a retry + * after an ambiguous failure never double-injects. + */ +export class PeerMessenger { + private timer: NodeJS.Timeout | null = null; + private draining = false; + + constructor( + private readonly deps: { + pool: Pool; + log: FastifyBaseLogger; + fetchImpl?: typeof fetch; + } + ) {} + + /** Queue a prompt for an agent on a peer and try to deliver it now. */ + async sendPrompt( + peerId: string, + message: Omit + ): Promise<{ delivered: boolean }> { + const idempotencyKey = crypto.randomUUID(); + const body: PeerMessageBody = { ...message, idempotencyKey }; + const outboxId = crypto.randomUUID(); + await this.deps.pool.query( + `INSERT INTO peer_outbox (id, peer_id, path, body, idempotency_key) + VALUES ($1, $2, $3, $4, $5)`, + [outboxId, peerId, "/api/v1/peers/messages", body, idempotencyKey] + ); + const delivered = await this.attempt({ + id: outboxId, + peer_id: peerId, + path: "/api/v1/peers/messages", + body, + attempts: 0, + }); + return { delivered }; + } + + private async attempt(row: OutboxRow): Promise { + const peer = await loadPeerDialInfo(this.deps.pool, row.peer_id); + if (!peer) { + // Peer was revoked with mail still queued — drop it, there is no one to + // deliver to and retrying forever would hold the queue open. + await this.deps.pool.query( + `UPDATE peer_outbox SET delivered_at = now(), last_error = 'peer revoked' WHERE id = $1`, + [row.id] + ); + return false; + } + try { + await postToPeer(peer, row.path, row.body, this.deps.fetchImpl ?? fetch); + await this.deps.pool.query( + `UPDATE peer_outbox SET delivered_at = now(), last_error = NULL WHERE id = $1`, + [row.id] + ); + return true; + } catch (error) { + const attempts = row.attempts + 1; + const backoff = Math.min( + BASE_BACKOFF_MS * 2 ** Math.min(attempts, 20), + MAX_BACKOFF_MS + ); + await this.deps.pool.query( + `UPDATE peer_outbox + SET attempts = $2, + next_attempt_at = now() + ($3 || ' milliseconds')::interval, + last_error = $4 + WHERE id = $1`, + [ + row.id, + attempts, + String(backoff), + error instanceof Error + ? error.message.slice(0, 2_000) + : "send failed", + ] + ); + return false; + } + } + + /** Deliver every due message; called on a timer and after reconnects. */ + async drain(): Promise { + if (this.draining) return; + this.draining = true; + try { + const due = await this.deps.pool.query( + `SELECT id, peer_id, path, body, attempts + FROM peer_outbox + WHERE delivered_at IS NULL AND next_attempt_at <= now() + ORDER BY created_at + LIMIT 100` + ); + for (const row of due.rows) { + await this.attempt(row); + } + await this.deps.pool.query( + `DELETE FROM peer_outbox + WHERE delivered_at IS NOT NULL + AND delivered_at < now() - interval '7 days'` + ); + await this.deps.pool.query( + `DELETE FROM peer_message_receipts + WHERE received_at < now() - interval '${RECEIPT_RETENTION_DAYS} days'` + ); + } catch (error) { + this.deps.log.warn({ err: error }, "Peer outbox drain failed"); + } finally { + this.draining = false; + } + } + + start(): void { + if (this.timer) return; + this.timer = setInterval(() => void this.drain(), DRAIN_INTERVAL_MS); + this.timer.unref(); + } + + stop(): void { + if (this.timer) clearInterval(this.timer); + this.timer = null; + } +} + +export type ReceiveResult = + | { status: "delivered" | "duplicate" } + | { status: "failed"; error: string }; + +/** + * Receiver side: dedupe on (peer, idempotencyKey), then hand the prompt to + * this instance's own injector — from the quiet gate onward, delivery is + * byte-for-byte the local path. + */ +export async function receivePeerMessage( + deps: { + pool: Pool; + injectAgentPrompt: ( + agentId: string, + prompt: string, + opts: { swallowFailure: boolean; awaitDelivery: boolean } + ) => Promise; + }, + peerId: string, + body: PeerMessageBody +): Promise { + const receipt = await deps.pool.query( + `INSERT INTO peer_message_receipts (peer_id, idempotency_key) + VALUES ($1, $2) + ON CONFLICT (peer_id, idempotency_key) DO NOTHING + RETURNING peer_id`, + [peerId, body.idempotencyKey] + ); + if (receipt.rowCount === 0) { + // A retry of something we already accepted. Report success either way — + // the first accept owns delivery, and re-injecting would duplicate it. + return { status: "duplicate" }; + } + try { + await deps.injectAgentPrompt(body.targetAgentId, body.prompt, { + swallowFailure: false, + awaitDelivery: false, + }); + } catch (error) { + // Enqueue failed (agent gone / not running). Release the receipt so the + // sender's retry is not swallowed as a duplicate. + await deps.pool.query( + `DELETE FROM peer_message_receipts WHERE peer_id = $1 AND idempotency_key = $2`, + [peerId, body.idempotencyKey] + ); + return { + status: "failed", + error: error instanceof Error ? error.message : "Prompt delivery failed.", + }; + } + await deps.pool.query( + `UPDATE peer_message_receipts SET delivered = true + WHERE peer_id = $1 AND idempotency_key = $2`, + [peerId, body.idempotencyKey] + ); + return { status: "delivered" }; +} diff --git a/apps/server/src/peers/pairing.ts b/apps/server/src/peers/pairing.ts new file mode 100644 index 00000000..a65460f7 --- /dev/null +++ b/apps/server/src/peers/pairing.ts @@ -0,0 +1,391 @@ +import crypto from "node:crypto"; +import dns from "node:dns/promises"; + +import type { Pool } from "pg"; + +import { tokensEqual } from "../auth.js"; +import { getOrCreateInstanceId } from "./identity.js"; +import { getTailscaleSelf, tailscaleWhois } from "./tailscale.js"; + +export const PAIRING_TTL_MS = 10 * 60 * 1000; + +export type PeerRecord = { + id: string; + name: string; + url: string; + tailnetStableId: string | null; + createdAt: string; + lastSeenAt: string | null; + allowLaunch: boolean; +}; + +function sha256(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function randomToken(bytes = 32): string { + return crypto.randomBytes(bytes).toString("base64url"); +} + +function pairingCode(): string { + // Six digits reads like every other device-pairing code. Single-use offers, + // a 10-minute TTL, and strict rate limiting on the claim route carry the + // brute-force load; the tailnet whois pin carries identity. + return crypto.randomInt(0, 1_000_000).toString().padStart(6, "0"); +} + +export async function cleanupExpiredPairings(pool: Pool): Promise { + await pool.query( + "DELETE FROM peer_pairings WHERE expires_at <= now() AND claimed_at IS NULL" + ); +} + +/** Acceptor side: create an offer whose code is displayed in THIS instance's UI. */ +export async function createPairingOffer( + pool: Pool, + input: { allowLaunch: boolean; requireTailnet: boolean } +): Promise<{ pairingId: string; code: string; expiresAt: string }> { + await cleanupExpiredPairings(pool); + const pairingId = crypto.randomUUID(); + const code = pairingCode(); + const expiresAt = new Date(Date.now() + PAIRING_TTL_MS); + await pool.query( + `INSERT INTO peer_pairings (id, code_hash, allow_launch, require_tailnet, expires_at) + VALUES ($1, $2, $3, $4, $5)`, + [ + pairingId, + sha256(code), + input.allowLaunch, + input.requireTailnet, + expiresAt, + ] + ); + return { pairingId, code, expiresAt: expiresAt.toISOString() }; +} + +export type ClaimInput = { + code: string; + claimer: { + instanceId: string; + name: string; + /** Base URL the acceptor should dial to reach the claimer. */ + url: string; + /** Bearer the acceptor will present when calling the claimer. */ + token: string; + }; + /** Source address of the claim call, "ip:port", for whois pinning. */ + callerAddr: string | null; +}; + +export type ClaimResult = + | { ok: true; instanceId: string; name: string; token: string } + | { ok: false; status: number; error: string }; + +/** + * Acceptor side: validate a claim against open offers, pin the caller's + * tailnet identity, and issue the reverse credential. Registers the claimer + * as a peer in the same stroke — pairing is the permission. + */ +export async function claimPairing( + pool: Pool, + input: ClaimInput, + instanceName: string +): Promise { + const offers = await pool.query<{ + id: string; + code_hash: string; + allow_launch: boolean; + require_tailnet: boolean; + }>( + `SELECT id, code_hash, allow_launch, require_tailnet + FROM peer_pairings + WHERE expires_at > now() AND claimed_at IS NULL` + ); + const offer = offers.rows.find((row) => + tokensEqual(row.code_hash, sha256(input.code)) + ); + if (!offer) { + return { ok: false, status: 401, error: "Invalid or expired code." }; + } + + let callerStableId: string | null = null; + if (input.callerAddr) { + const whois = await tailscaleWhois(input.callerAddr); + callerStableId = whois?.stableId ?? null; + } + if (offer.require_tailnet && !callerStableId) { + // Absent tailnet identity is a hard deny, never a fallback. + return { + ok: false, + status: 403, + error: "Caller is not an identifiable tailnet node.", + }; + } + + const inboundToken = randomToken(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const claimed = await client.query( + `UPDATE peer_pairings SET claimed_at = now() + WHERE id = $1 AND claimed_at IS NULL`, + [offer.id] + ); + if (claimed.rowCount === 0) { + await client.query("ROLLBACK"); + return { ok: false, status: 409, error: "Code was already used." }; + } + await client.query( + `INSERT INTO peers (id, name, url, tailnet_stable_id, outbound_token, last_seen_at) + VALUES ($1, $2, $3, $4, $5, now()) + ON CONFLICT (id) DO UPDATE + SET name = $2, url = $3, tailnet_stable_id = $4, + outbound_token = $5, last_seen_at = now(), revoked_at = NULL`, + [ + input.claimer.instanceId, + input.claimer.name, + input.claimer.url, + callerStableId, + input.claimer.token, + ] + ); + // Re-pairing replaces the credential — never leave two live tokens per peer. + await client.query( + `UPDATE peer_credentials SET revoked_at = now() + WHERE peer_id = $1 AND revoked_at IS NULL`, + [input.claimer.instanceId] + ); + await client.query( + `INSERT INTO peer_credentials (id, peer_id, token_hash, tailnet_stable_id, allow_launch) + VALUES ($1, $2, $3, $4, $5)`, + [ + crypto.randomUUID(), + input.claimer.instanceId, + sha256(inboundToken), + callerStableId, + offer.allow_launch, + ] + ); + await client.query(`UPDATE peer_pairings SET peer_id = $2 WHERE id = $1`, [ + offer.id, + input.claimer.instanceId, + ]); + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + client.release(); + } + + const instanceId = await getOrCreateInstanceId(pool); + return { ok: true, instanceId, name: instanceName, token: inboundToken }; +} + +export type LinkInput = { + /** Address of the accepting instance, e.g. cloud-vm.tailnet.ts.net:6767 */ + address: string; + code: string; + /** Whether the linked peer may launch agents HERE (the reverse policy). */ + allowLaunch: boolean; + /** Override for how the peer dials us back (needed off-tailnet). */ + selfUrl?: string; +}; + +export type LinkResult = + | { ok: true; peer: { id: string; name: string; url: string } } + | { ok: false; status: number; error: string }; + +function normalizePeerUrl(address: string): string { + const withScheme = /^https?:\/\//.test(address) + ? address + : `http://${address}`; + return withScheme.replace(/\/+$/, ""); +} + +/** Best-effort whois of the instance we are dialing, to pin its node identity. */ +async function whoisOfUrl(url: string): Promise { + try { + const parsed = new URL(url); + const { address } = await dns.lookup(parsed.hostname); + const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80"); + const whois = await tailscaleWhois(`${address}:${port}`); + return whois?.stableId ?? null; + } catch { + return null; + } +} + +/** + * Claimer side: dial the accepting instance with the code the user typed, + * hand it a freshly minted reverse credential, and store both directions. + */ +export async function linkToPeer( + pool: Pool, + input: LinkInput, + deps: { + instanceName: string; + port: number; + fetchImpl?: typeof fetch; + } +): Promise { + const doFetch = deps.fetchImpl ?? fetch; + const peerUrl = normalizePeerUrl(input.address); + + let selfUrl = input.selfUrl ?? null; + if (!selfUrl) { + const self = await getTailscaleSelf(); + if (!self?.dnsName) { + return { + ok: false, + status: 409, + error: + "Cannot determine this instance's tailnet address. Provide selfUrl or start tailscale.", + }; + } + selfUrl = `http://${self.dnsName}:${deps.port}`; + } + + const instanceId = await getOrCreateInstanceId(pool); + const reverseToken = randomToken(); + + let response: Response; + try { + response = await doFetch(`${peerUrl}/api/v1/auth/peers/claim`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + code: input.code, + instance: { + id: instanceId, + name: deps.instanceName, + url: selfUrl, + token: reverseToken, + }, + }), + signal: AbortSignal.timeout(15_000), + }); + } catch { + return { + ok: false, + status: 502, + error: `Could not reach ${peerUrl}. Are both machines on the tailnet?`, + }; + } + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { + error?: string; + } | null; + return { + ok: false, + status: response.status, + error: body?.error ?? `Pairing failed (${response.status}).`, + }; + } + const body = (await response.json()) as { + instanceId?: string; + name?: string; + token?: string; + }; + if (!body.instanceId || !body.token) { + return { ok: false, status: 502, error: "Peer sent a malformed response." }; + } + + const peerStableId = await whoisOfUrl(peerUrl); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query( + `INSERT INTO peers (id, name, url, tailnet_stable_id, outbound_token, last_seen_at) + VALUES ($1, $2, $3, $4, $5, now()) + ON CONFLICT (id) DO UPDATE + SET name = $2, url = $3, tailnet_stable_id = $4, + outbound_token = $5, last_seen_at = now(), revoked_at = NULL`, + [ + body.instanceId, + body.name ?? new URL(peerUrl).hostname, + peerUrl, + peerStableId, + body.token, + ] + ); + await client.query( + `UPDATE peer_credentials SET revoked_at = now() + WHERE peer_id = $1 AND revoked_at IS NULL`, + [body.instanceId] + ); + await client.query( + `INSERT INTO peer_credentials (id, peer_id, token_hash, tailnet_stable_id, allow_launch) + VALUES ($1, $2, $3, $4, $5)`, + [ + crypto.randomUUID(), + body.instanceId, + sha256(reverseToken), + peerStableId, + input.allowLaunch, + ] + ); + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + client.release(); + } + + return { + ok: true, + peer: { + id: body.instanceId, + name: body.name ?? new URL(peerUrl).hostname, + url: peerUrl, + }, + }; +} + +export async function listPeers(pool: Pool): Promise { + const result = await pool.query<{ + id: string; + name: string; + url: string; + tailnet_stable_id: string | null; + created_at: Date; + last_seen_at: Date | null; + allow_launch: boolean | null; + }>( + `SELECT p.id, p.name, p.url, p.tailnet_stable_id, p.created_at, p.last_seen_at, + c.allow_launch + FROM peers p + LEFT JOIN LATERAL ( + SELECT allow_launch FROM peer_credentials + WHERE peer_id = p.id AND revoked_at IS NULL + ORDER BY created_at DESC LIMIT 1 + ) c ON true + WHERE p.revoked_at IS NULL + ORDER BY p.created_at DESC` + ); + return result.rows.map((row) => ({ + id: row.id, + name: row.name, + url: row.url, + tailnetStableId: row.tailnet_stable_id, + createdAt: row.created_at.toISOString(), + lastSeenAt: row.last_seen_at?.toISOString() ?? null, + allowLaunch: row.allow_launch ?? false, + })); +} + +/** Revoke a peer locally: kill their inbound credentials and our outbound use. */ +export async function revokePeer(pool: Pool, peerId: string): Promise { + const result = await pool.query( + `UPDATE peers SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`, + [peerId] + ); + if (result.rowCount === 0) return false; + await pool.query( + `UPDATE peer_credentials SET revoked_at = now() + WHERE peer_id = $1 AND revoked_at IS NULL`, + [peerId] + ); + return true; +} diff --git a/apps/server/src/peers/peer-auth.ts b/apps/server/src/peers/peer-auth.ts new file mode 100644 index 00000000..2f500e9b --- /dev/null +++ b/apps/server/src/peers/peer-auth.ts @@ -0,0 +1,103 @@ +import crypto from "node:crypto"; + +import type { FastifyReply, FastifyRequest } from "fastify"; +import type { Pool } from "pg"; + +import { tailscaleWhois } from "./tailscale.js"; + +export type PeerAuth = { + peerId: string; + credentialId: string; + allowLaunch: boolean; +}; + +declare module "fastify" { + interface FastifyContextConfig { + /** Route authenticates with its own peer bearer token in a preHandler. */ + peerBearer?: boolean; + } + + interface FastifyRequest { + peerAuth?: PeerAuth; + } +} + +function sha256(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function bearerToken(request: FastifyRequest): string | null { + const header = request.headers.authorization; + if (!header?.startsWith("Bearer ")) return null; + const token = header.slice(7).trim(); + return token.length > 0 ? token : null; +} + +/** + * Authenticates a calling peer instance: bearer lookup first, then — when the + * credential was pinned at pair time — a live `tailscale whois` on the caller + * socket that must return the same StableID. A pinned credential presented + * from an unidentifiable or different node is a hard deny; identity headers + * are never consulted. + */ +export async function requirePeerAuth( + pool: Pool, + request: FastifyRequest, + reply: FastifyReply +): Promise { + const token = bearerToken(request); + if (!token) { + await reply.code(401).send({ error: "Peer authentication required." }); + return; + } + + const result = await pool.query<{ + id: string; + peer_id: string; + tailnet_stable_id: string | null; + allow_launch: boolean; + }>( + `SELECT c.id, c.peer_id, c.tailnet_stable_id, c.allow_launch + FROM peer_credentials c + JOIN peers p ON p.id = c.peer_id AND p.revoked_at IS NULL + WHERE c.token_hash = $1 AND c.revoked_at IS NULL`, + [sha256(token)] + ); + const row = result.rows[0]; + if (!row) { + await reply.code(401).send({ error: "Invalid or revoked peer token." }); + return; + } + + if (row.tailnet_stable_id) { + const remote = request.socket.remoteAddress; + const port = request.socket.remotePort; + const whois = remote + ? await tailscaleWhois(`${stripMapped(remote)}:${port ?? 0}`) + : null; + if (!whois || whois.stableId !== row.tailnet_stable_id) { + await reply + .code(403) + .send({ error: "Caller does not match the paired tailnet node." }); + return; + } + } + + await pool.query( + `UPDATE peer_credentials SET last_used_at = now() WHERE id = $1`, + [row.id] + ); + await pool.query(`UPDATE peers SET last_seen_at = now() WHERE id = $1`, [ + row.peer_id, + ]); + request.peerAuth = { + peerId: row.peer_id, + credentialId: row.id, + allowLaunch: row.allow_launch, + }; +} + +/** Node reports IPv4 callers as ::ffff:a.b.c.d on dual-stack sockets. */ +function stripMapped(address: string): string { + return address.startsWith("::ffff:") ? address.slice(7) : address; +} diff --git a/apps/server/src/peers/peer-settings.ts b/apps/server/src/peers/peer-settings.ts new file mode 100644 index 00000000..5c1f1304 --- /dev/null +++ b/apps/server/src/peers/peer-settings.ts @@ -0,0 +1,21 @@ +import type { Pool } from "pg"; + +import { getSetting, setSetting } from "../db/settings.js"; + +/** + * Whether this instance exposes its API on the tailnet interface so linked + * peers can reach it. Off by default; enabling requires a configured password + * and a running tailscale — enforced in the route and again at bind time. + */ +const TAILNET_BIND_KEY = "peer_tailnet_bind_enabled"; + +export async function isTailnetBindEnabled(pool: Pool): Promise { + return (await getSetting(pool, TAILNET_BIND_KEY)) === "true"; +} + +export async function setTailnetBindEnabled( + pool: Pool, + enabled: boolean +): Promise { + await setSetting(pool, TAILNET_BIND_KEY, enabled ? "true" : "false"); +} diff --git a/apps/server/src/peers/runtime.ts b/apps/server/src/peers/runtime.ts new file mode 100644 index 00000000..ef56fc35 --- /dev/null +++ b/apps/server/src/peers/runtime.ts @@ -0,0 +1,110 @@ +import type { FastifyBaseLogger } from "fastify"; +import type { Pool } from "pg"; + +import { getOrCreateInstanceId } from "./identity.js"; +import { isTailnetBindEnabled } from "./peer-settings.js"; +import { + getTailscaleSelf, + pickTailnetIPv4, + type TailscaleSelf, +} from "./tailscale.js"; +import type { TailnetListener } from "./tailnet-listener.js"; + +export type TailnetBindStatus = { + enabled: boolean; + /** Actually listening right now (enabled + password + tailscale all held). */ + active: boolean; + address: string | null; + /** Why the listener is not active despite being enabled, for the UI. */ + blockedReason: "no-password" | "no-tailscale" | null; +}; + +export type PeerSelfStatus = { + instanceId: string; + passwordSet: boolean; + tailscale: TailscaleSelf | null; + bind: TailnetBindStatus; +}; + +type PeerRuntimeDeps = { + pool: Pool; + listener: TailnetListener; + isPasswordSet: () => Promise; + log: FastifyBaseLogger; +}; + +/** + * Owns the tailnet exposure lifecycle: reads the bind setting and starts or + * stops the tailnet listener to match. A missing password is a hard stop — + * first-run mode leaves every route open, and the tailnet is not a perimeter. + */ +export class PeerRuntime { + constructor(private readonly deps: PeerRuntimeDeps) {} + + async applyTailnetBind(): Promise { + const enabled = await isTailnetBindEnabled(this.deps.pool); + if (!enabled) { + await this.deps.listener.stop(); + return { enabled, active: false, address: null, blockedReason: null }; + } + if (!(await this.deps.isPasswordSet())) { + await this.deps.listener.stop(); + this.deps.log.warn( + "Tailnet bind is enabled but no password is set — refusing to expose the API" + ); + return { + enabled, + active: false, + address: null, + blockedReason: "no-password", + }; + } + const self = await getTailscaleSelf(); + const address = self ? pickTailnetIPv4(self.ips) : null; + if (!address) { + await this.deps.listener.stop(); + this.deps.log.warn( + "Tailnet bind is enabled but tailscale is not running or has no IPv4 address" + ); + return { + enabled, + active: false, + address: null, + blockedReason: "no-tailscale", + }; + } + await this.deps.listener.start(address); + return { enabled, active: true, address, blockedReason: null }; + } + + async selfStatus(): Promise { + const [instanceId, passwordSet, tailscale, enabled] = await Promise.all([ + getOrCreateInstanceId(this.deps.pool), + this.deps.isPasswordSet(), + getTailscaleSelf(), + isTailnetBindEnabled(this.deps.pool), + ]); + const active = this.deps.listener.address !== null; + return { + instanceId, + passwordSet, + tailscale, + bind: { + enabled, + active, + address: this.deps.listener.address, + blockedReason: !enabled + ? null + : !passwordSet + ? "no-password" + : !tailscale + ? "no-tailscale" + : null, + }, + }; + } + + async shutdown(): Promise { + await this.deps.listener.stop(); + } +} diff --git a/apps/server/src/peers/tailnet-listener.ts b/apps/server/src/peers/tailnet-listener.ts new file mode 100644 index 00000000..ddc079d4 --- /dev/null +++ b/apps/server/src/peers/tailnet-listener.ts @@ -0,0 +1,84 @@ +import http from "node:http"; +import https from "node:https"; + +import type { FastifyBaseLogger } from "fastify"; + +import type { TlsConfig } from "../config.js"; + +type TailnetListenerDeps = { + /** The primary Fastify server — requests are re-emitted onto it. */ + appServer: () => http.Server; + port: number; + tls: TlsConfig | null; + log: FastifyBaseLogger; +}; + +/** + * A secondary listener bound to the tailnet IP that delegates every request + * and upgrade to the primary Fastify server. Delegation (rather than a TCP + * proxy) preserves request.socket.remoteAddress, which peer auth must feed + * to `tailscale whois` — a proxy would report 127.0.0.1 for every caller. + */ +export class TailnetListener { + private server: http.Server | https.Server | null = null; + private boundAddress: string | null = null; + + constructor(private readonly deps: TailnetListenerDeps) {} + + get address(): string | null { + return this.boundAddress; + } + + /** True when `localAddress` is the tailnet interface this listener bound. */ + isBoundAddress(localAddress: string | undefined): boolean { + if (!this.boundAddress || !localAddress) return false; + // Node reports IPv4 as ::ffff:100.x.y.z on dual-stack sockets. + return ( + localAddress === this.boundAddress || + localAddress === `::ffff:${this.boundAddress}` + ); + } + + async start(address: string): Promise { + if (this.server) { + if (this.boundAddress === address) return; + await this.stop(); + } + const target = this.deps.appServer(); + const server = this.deps.tls + ? https.createServer(this.deps.tls) + : http.createServer(); + server.on("request", (req, res) => { + target.emit("request", req, res); + }); + server.on("upgrade", (req, socket, head) => { + target.emit("upgrade", req, socket, head); + }); + await new Promise((resolve, reject) => { + const onError = (err: Error) => reject(err); + server.once("error", onError); + server.listen(this.deps.port, address, () => { + server.off("error", onError); + resolve(); + }); + }); + this.server = server; + this.boundAddress = address; + this.deps.log.info( + `Peer listener bound to tailnet interface ${address}:${this.deps.port}` + ); + } + + async stop(): Promise { + const server = this.server; + if (!server) return; + this.server = null; + this.boundAddress = null; + await new Promise((resolve) => { + server.close(() => resolve()); + // close() waits for open connections (incl. SSE); cut them loose. + server.closeAllConnections?.(); + }); + this.deps.log.info("Peer listener stopped"); + } +} diff --git a/apps/server/src/peers/tailscale.ts b/apps/server/src/peers/tailscale.ts new file mode 100644 index 00000000..b0ec5ea2 --- /dev/null +++ b/apps/server/src/peers/tailscale.ts @@ -0,0 +1,157 @@ +import { runCommand } from "../shared/lib/run-command.js"; + +/** + * Thin wrapper around the tailscale CLI. We shell out instead of talking to + * the LocalAPI socket because the socket transport differs per platform + * (root-owned unix socket on Linux, authed localhost TCP on macOS) and the + * CLI already abstracts both. + */ + +export type TailscaleSelf = { + /** Durable node identifier — the only field safe to authorize on. */ + stableId: string; + /** MagicDNS name without the trailing dot, e.g. host.tailnet.ts.net */ + dnsName: string; + /** Tailnet IPs (IPv4 100.x and IPv6). */ + ips: string[]; + online: boolean; +}; + +export type TailscaleWhois = { + stableId: string; + nodeName: string; + tags: string[]; + /** Set when the node is shared in from another tailnet — its user is not ours. */ + sharer: string | null; + loginName: string | null; +}; + +const BIN_CANDIDATES = [ + process.env.DISPATCH_TAILSCALE_BIN, + "tailscale", + "/usr/local/bin/tailscale", + "/Applications/Tailscale.app/Contents/MacOS/Tailscale", +].filter((c): c is string => Boolean(c)); + +let cachedBin: string | null | undefined; + +export async function findTailscaleBin(): Promise { + if (cachedBin !== undefined) return cachedBin; + for (const candidate of BIN_CANDIDATES) { + try { + await runCommand(candidate, ["version"], { timeoutMs: 5_000 }); + cachedBin = candidate; + return candidate; + } catch { + // try the next candidate + } + } + cachedBin = null; + return null; +} + +/** Test-only: forget the memoized binary lookup. */ +export function resetTailscaleBinCache(): void { + cachedBin = undefined; +} + +function stripTrailingDot(name: string): string { + return name.endsWith(".") ? name.slice(0, -1) : name; +} + +function asStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((v): v is string => typeof v === "string") + : []; +} + +/** + * Returns this node's tailnet identity, or null when tailscale is absent, + * logged out, or its output cannot be understood. The LocalAPI is semi-stable + * with no published schema, so every field read is defensive. + */ +export async function getTailscaleSelf(): Promise { + const bin = await findTailscaleBin(); + if (!bin) return null; + try { + const result = await runCommand(bin, ["status", "--json"], { + timeoutMs: 10_000, + }); + return parseTailscaleStatus(result.stdout); + } catch { + return null; + } +} + +export function parseTailscaleStatus(stdout: string): TailscaleSelf | null { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const status = parsed as Record; + if (status.BackendState !== "Running") return null; + const self = status.Self as Record | undefined; + if (!self || typeof self.ID !== "string" || self.ID.length === 0) return null; + const dnsName = typeof self.DNSName === "string" ? self.DNSName : ""; + return { + stableId: self.ID, + dnsName: stripTrailingDot(dnsName), + ips: asStringArray(self.TailscaleIPs), + online: self.Online === true, + }; +} + +/** + * Identifies the tailnet node behind a connection. `addr` should include the + * source port (matters in userspace mode). Returns null when the address is + * not a tailnet peer — callers must treat that as a hard deny, never a + * fallback to weaker identification. + */ +export async function tailscaleWhois( + addr: string +): Promise { + const bin = await findTailscaleBin(); + if (!bin) return null; + try { + const result = await runCommand(bin, ["whois", "--json", addr], { + timeoutMs: 10_000, + }); + return parseTailscaleWhois(result.stdout); + } catch { + return null; + } +} + +export function parseTailscaleWhois(stdout: string): TailscaleWhois | null { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const whois = parsed as Record; + const node = whois.Node as Record | undefined; + if (!node || typeof node.StableID !== "string" || node.StableID.length === 0) + return null; + const profile = whois.UserProfile as Record | undefined; + return { + stableId: node.StableID, + nodeName: stripTrailingDot(typeof node.Name === "string" ? node.Name : ""), + tags: asStringArray(node.Tags), + sharer: + typeof node.Sharer === "string" && node.Sharer.length > 0 + ? node.Sharer + : null, + loginName: + typeof profile?.LoginName === "string" ? profile.LoginName : null, + }; +} + +/** Picks the IPv4 tailnet address to bind the peer listener on. */ +export function pickTailnetIPv4(ips: string[]): string | null { + return ips.find((ip) => ip.startsWith("100.") && !ip.includes(":")) ?? null; +} diff --git a/apps/server/src/routes/peers.ts b/apps/server/src/routes/peers.ts new file mode 100644 index 00000000..5e4c6f8d --- /dev/null +++ b/apps/server/src/routes/peers.ts @@ -0,0 +1,355 @@ +import os from "node:os"; + +import type { FastifyInstance } from "fastify"; +import type { Pool } from "pg"; +import * as z from "zod/v4"; + +import type { AgentManager, AgentRecord } from "../agents/manager.js"; +import { getSetting } from "../db/settings.js"; +import { handleIncomingPeerLaunch, listLocalRepos } from "../peers/launch.js"; +import { receivePeerMessage } from "../peers/messages.js"; +import { requirePeerAuth } from "../peers/peer-auth.js"; +import { + claimPairing, + createPairingOffer, + linkToPeer, + listPeers, + revokePeer, +} from "../peers/pairing.js"; +import { setTailnetBindEnabled } from "../peers/peer-settings.js"; +import type { PeerRuntime } from "../peers/runtime.js"; +import { parseInput } from "../shared/lib/parse-input.js"; + +const TailnetBindBodySchema = z.object({ + enabled: z.boolean(), +}); + +const PairingOfferBodySchema = z.object({ + allowLaunch: z.boolean().default(true), + requireTailnet: z.boolean().default(true), +}); + +const ClaimBodySchema = z.object({ + code: z.string().trim().min(6).max(12), + instance: z.object({ + id: z.string().trim().min(1).max(64), + name: z.string().trim().min(1).max(120), + url: z.string().trim().min(1).max(4_096), + token: z.string().min(32).max(256), + }), +}); + +const LinkBodySchema = z.object({ + address: z.string().trim().min(1).max(4_096), + code: z.string().trim().min(6).max(12), + allowLaunch: z.boolean().default(true), + selfUrl: z.string().trim().max(4_096).optional(), +}); + +const PeerParamsSchema = z.object({ + id: z.string().trim().min(1).max(64), +}); + +const PeerMessageBodySchema = z.object({ + targetAgentId: z.string().trim().min(1).max(128), + prompt: z.string().min(1).max(200_000), + idempotencyKey: z.uuid(), +}); + +const PeerLaunchBodySchema = z.object({ + name: z.string().trim().min(1).max(100), + prompt: z.string().min(1).max(100_000), + type: z.string().trim().min(1).max(32), + model: z.string().trim().max(200).optional(), + cwd: z.string().trim().min(1).max(4_096), + fullAccess: z.boolean().optional(), + useWorktree: z.boolean().optional(), + createNewBranch: z.boolean().optional(), + baseBranch: z.string().trim().max(500).optional(), + worktreeBranch: z.string().trim().max(500).optional(), + parentAddress: z.string().trim().min(1).max(200), +}); + +type PeerRouteDeps = { + pool: Pool; + peerRuntime: PeerRuntime; + isPasswordSet: () => Promise; + port: number; + agentManager: AgentManager; + publishUiEvent: (event: { type: string; agent?: unknown }) => void; + withStreamFlag: ( + agent: T + ) => T & { hasStream: boolean }; + injectAgentPrompt: ( + agentId: string, + prompt: string, + opts: { swallowFailure: boolean; awaitDelivery: boolean } + ) => Promise; + subscribeUiEvents: (stream: NodeJS.WritableStream) => () => void; + sendUiSnapshot: (stream: NodeJS.WritableStream, agents: unknown[]) => void; +}; + +async function instanceDisplayName(pool: Pool): Promise { + const configured = await getSetting(pool, "instance_name"); + return configured && configured.length > 0 ? configured : os.hostname(); +} + +export async function registerPeerRoutes( + app: FastifyInstance, + deps: PeerRouteDeps +): Promise { + app.get("/api/v1/peers/self", async () => { + return await deps.peerRuntime.selfStatus(); + }); + + app.post("/api/v1/peers/settings/tailnet-bind", async (request, reply) => { + const input = parseInput(TailnetBindBodySchema, request.body, reply); + if (!input) return; + + if (input.enabled) { + const status = await deps.peerRuntime.selfStatus(); + if (!status.passwordSet) { + return reply.code(409).send({ + error: + "Set a password before exposing this instance on the tailnet — without one, every route is open.", + }); + } + if (!status.tailscale) { + return reply.code(409).send({ + error: "Tailscale is not running on this machine.", + }); + } + } + + await setTailnetBindEnabled(deps.pool, input.enabled); + const bind = await deps.peerRuntime.applyTailnetBind(); + return { bind }; + }); + + app.get("/api/v1/peers", async () => { + return { peers: await listPeers(deps.pool) }; + }); + + // Acceptor: mint an offer whose code renders in THIS instance's UI. + app.post("/api/v1/peers/pairings", async (request, reply) => { + const input = parseInput(PairingOfferBodySchema, request.body, reply); + if (!input) return; + if (!(await deps.isPasswordSet())) { + return reply.code(409).send({ + error: "Set a password before pairing with another instance.", + }); + } + const offer = await createPairingOffer(deps.pool, input); + const status = await deps.peerRuntime.selfStatus(); + return { + ...offer, + // What the human carries to the other machine, alongside the code. + address: status.tailscale + ? `${status.tailscale.dnsName}:${deps.port}` + : null, + tailnetBindActive: status.bind.active, + }; + }); + + // Acceptor: an instance the user typed our code into calls this. Open route + // (the caller has no credential yet) — code + rate limit + whois gate it. + app.post( + "/api/v1/auth/peers/claim", + { config: { rateLimit: { max: 5, timeWindow: "1 minute" } } }, + async (request, reply) => { + const input = parseInput(ClaimBodySchema, request.body, reply); + if (!input) return; + if (!(await deps.isPasswordSet())) { + return reply + .code(409) + .send({ error: "This instance has no password set." }); + } + const remote = request.socket.remoteAddress; + const callerAddr = remote + ? `${remote.startsWith("::ffff:") ? remote.slice(7) : remote}:${request.socket.remotePort ?? 0}` + : null; + const result = await claimPairing( + deps.pool, + { + code: input.code, + claimer: { + instanceId: input.instance.id, + name: input.instance.name, + url: input.instance.url, + token: input.instance.token, + }, + callerAddr, + }, + await instanceDisplayName(deps.pool) + ); + if (!result.ok) { + return reply.code(result.status).send({ error: result.error }); + } + return { + instanceId: result.instanceId, + name: result.name, + token: result.token, + }; + } + ); + + // Claimer: the user typed a code shown on another instance — dial it. + app.post("/api/v1/peers/link", async (request, reply) => { + const input = parseInput(LinkBodySchema, request.body, reply); + if (!input) return; + if (!(await deps.isPasswordSet())) { + return reply.code(409).send({ + error: "Set a password before pairing with another instance.", + }); + } + const result = await linkToPeer(deps.pool, input, { + instanceName: await instanceDisplayName(deps.pool), + port: deps.port, + }); + if (!result.ok) { + return reply.code(result.status).send({ error: result.error }); + } + return { peer: result.peer }; + }); + + // Called BY a linked peer. Runs this instance's own createAgent — nothing + // about the remote path is special on the receiving side. + app.post( + "/api/v1/peers/launch", + { + config: { + peerBearer: true, + rateLimit: { max: 30, timeWindow: "1 minute" }, + }, + preHandler: (request, reply) => + requirePeerAuth(deps.pool, request, reply), + }, + async (request, reply) => { + const input = parseInput(PeerLaunchBodySchema, request.body, reply); + if (!input) return; + if (!request.peerAuth!.allowLaunch) { + return reply.code(403).send({ + error: + "This peer is not allowed to launch agents here (pair-time policy).", + }); + } + try { + const result = await handleIncomingPeerLaunch( + { pool: deps.pool, agentManager: deps.agentManager }, + input + ); + const agent = await deps.agentManager.getAgent(result.agentId); + if (agent) { + deps.publishUiEvent({ + type: "agent.upsert", + agent: deps.withStreamFlag(agent), + }); + } + return result; + } catch (error) { + return reply.code(422).send({ + error: error instanceof Error ? error.message : "Launch failed.", + }); + } + } + ); + + // Called BY a linked peer. Deduped on idempotency key, then injected via + // this instance's own prompt path — the quiet gate and all. + app.post( + "/api/v1/peers/messages", + { + config: { + peerBearer: true, + rateLimit: { max: 120, timeWindow: "1 minute" }, + }, + preHandler: (request, reply) => + requirePeerAuth(deps.pool, request, reply), + }, + async (request, reply) => { + const input = parseInput(PeerMessageBodySchema, request.body, reply); + if (!input) return; + const result = await receivePeerMessage( + { pool: deps.pool, injectAgentPrompt: deps.injectAgentPrompt }, + request.peerAuth!.peerId, + input + ); + if (result.status === "failed") { + return reply.code(502).send({ error: result.error }); + } + return { status: result.status }; + } + ); + + // Called BY a linked peer: the event stream it mirrors shadow rows from. + // Same mechanics as /api/v1/events — snapshot on connect, then live events. + app.get( + "/api/v1/peers/events", + { + config: { peerBearer: true }, + preHandler: (request, reply) => + requirePeerAuth(deps.pool, request, reply), + }, + async (request, reply) => { + reply.raw.setHeader("Content-Type", "text/event-stream"); + reply.raw.setHeader("Cache-Control", "no-cache, no-transform"); + reply.raw.setHeader("Connection", "keep-alive"); + reply.hijack(); + + const stream = reply.raw; + const unsubscribe = deps.subscribeUiEvents(stream); + const heartbeat = setInterval(() => { + stream.write(": keepalive\n\n"); + }, 20_000); + let cleanedUp = false; + const cleanup = () => { + if (cleanedUp) return; + cleanedUp = true; + clearInterval(heartbeat); + unsubscribe(); + }; + request.raw.once("close", cleanup); + request.raw.once("aborted", cleanup); + if (request.raw.destroyed) { + cleanup(); + return; + } + try { + const agents = await deps.agentManager.listAgents(); + if (!request.raw.destroyed) { + deps.sendUiSnapshot( + stream, + agents.filter((agent) => !agent.peerId).map(deps.withStreamFlag) + ); + } else { + cleanup(); + } + } catch { + // Live events still flow; the subscriber re-snapshots on reconnect. + } + } + ); + + // Called BY a linked peer to populate its location/repo picker. + app.get( + "/api/v1/peers/repos", + { + config: { peerBearer: true }, + preHandler: (request, reply) => + requirePeerAuth(deps.pool, request, reply), + }, + async () => { + return { repos: await listLocalRepos(deps.pool) }; + } + ); + + app.delete("/api/v1/peers/:id", async (request, reply) => { + const params = parseInput(PeerParamsSchema, request.params, reply); + if (!params) return; + const revoked = await revokePeer(deps.pool, params.id); + if (!revoked) { + return reply.code(404).send({ error: "Peer not found." }); + } + return { ok: true }; + }); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ac626095..8c6bdb0e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -134,6 +134,11 @@ import { loadInjectionHoldEnabled, } from "./injection-hold-settings.js"; import { createAuthRuntime } from "./server/auth-runtime.js"; +import { TailnetListener } from "./peers/tailnet-listener.js"; +import { PeerRuntime } from "./peers/runtime.js"; +import { PeerMessenger } from "./peers/messages.js"; +import { PeerEventSubscriber } from "./peers/events.js"; +import { registerPeerRoutes } from "./routes/peers.js"; import { getBearerToken, handleAgentError } from "./server/http-helpers.js"; import { createReleaseRuntime, @@ -393,10 +398,17 @@ const injectionCoordinator = new InjectionCoordinator({ holdState, }), }); +const peerMessenger = new PeerMessenger({ pool, log: app.log }); const injectAgentPrompt = createPromptInjector( agentManager, app.log, - injectionCoordinator + injectionCoordinator, + async (peerId, remoteAgentId, prompt) => { + await peerMessenger.sendPrompt(peerId, { + targetAgentId: remoteAgentId, + prompt, + }); + } ); agentManager.onLatestEvent( createAutoRenamePrompter({ injectAgentPrompt, log: app.log }) @@ -411,6 +423,27 @@ const authRuntime = createAuthRuntime({ pool, sessionCleanupIntervalMs: 60 * 60 * 1000, }); +const tailnetListener = new TailnetListener({ + appServer: () => app.server, + port: config.port, + tls: config.tls, + log: app.log, +}); +const peerRuntime = new PeerRuntime({ + pool, + listener: tailnetListener, + isPasswordSet: () => authRuntime.isPasswordSetCached(), + log: app.log, +}); +const peerEventSubscriber = new PeerEventSubscriber({ + pool, + agentManager, + publishUiEvent: (event) => uiEventBroker.publish(event as UiEvent), + withStreamFlag, + // The laptop-reopens moment: the peer is reachable again, ship queued mail. + onPeerReachable: () => void peerMessenger.drain(), + log: app.log, +}); const brainStore = new BrainStore(pool); const mcpHandlers = createMcpHandlers({ pool, @@ -423,6 +456,8 @@ const mcpHandlers = createMcpHandlers({ withStreamFlag, sendAgentPrompt: injectAgentPrompt, appLog: app.log, + sendPeerPrompt: (peerId, targetAgentId, prompt) => + peerMessenger.sendPrompt(peerId, { targetAgentId, prompt }), }); const jobTerminalStatuses = new Set([ "completed", @@ -525,6 +560,9 @@ async function registerRoutes() { // bearer shortcut so the server auth token is never accepted as an // extension credential. if (request.routeOptions.config.browserExtensionBearer) return; + // Peer-federation routes authenticate with their own peer bearer token + // (and a tailscale whois pin) in a route-local preHandler. + if (request.routeOptions.config.peerBearer) return; if (/^\/api\/v1\/agents\/[^/]+\/terminal\/ws$/.test(url)) return; // The assisted-update phase endpoint authenticates via a per-job nonce // embedded in the launched agent's prompt — see assisted-update.ts. The @@ -532,8 +570,16 @@ async function registerRoutes() { // session cookie or bearer token. if (url === "/api/v1/release/assisted/phase") return; - // If no password is set, all routes are open (first-run mode). - if (!(await authRuntime.isPasswordSetCached())) return; + // If no password is set, all routes are open (first-run mode) — except on + // the tailnet interface, where open mode would expose the API to every + // node on the tailnet. Belt-and-braces: the listener refuses to start + // without a password, but the password can be cleared while it runs. + if (!(await authRuntime.isPasswordSetCached())) { + if (tailnetListener.isBoundAddress(request.socket.localAddress)) { + return reply.code(401).send({ error: "Authentication required." }); + } + return; + } // Bearer token is accepted on all API routes (for MCP agents, scripts, etc.) const authHeader = request.headers.authorization; @@ -647,6 +693,20 @@ async function registerRoutes() { mcpMethodNotAllowed, }); + await registerPeerRoutes(app, { + pool, + peerRuntime, + isPasswordSet: () => authRuntime.isPasswordSetCached(), + port: config.port, + agentManager, + publishUiEvent: (event) => uiEventBroker.publish(event as UiEvent), + withStreamFlag, + injectAgentPrompt, + subscribeUiEvents: (stream) => uiEventBroker.subscribe(stream), + sendUiSnapshot: (stream, agents) => + uiEventBroker.sendSnapshot(stream, agents as AgentRecord[]), + }); + await registerSystemRoutes(app, { pool, appLog: app.log, @@ -860,6 +920,8 @@ export async function initializeApp(options?: { agentLifecycleRuntime.startReconcileLoop(); authRuntime.startSessionCleanupTimer(); autoCheckRuntime.startScheduler(); + peerMessenger.start(); + peerEventSubscriber.start(); } if (!routesRegistered) { await registerRoutes(); @@ -885,6 +947,12 @@ export async function start() { `Dispatch listening on ${protocol}://${config.host}:${config.port}` ); + try { + await peerRuntime.applyTailnetBind(); + } catch (err) { + app.log.error({ err }, "Failed to bind tailnet peer listener"); + } + // The process that activated a new binary exits during the service restart, // so only this newly healthy process can truthfully promote the candidate. try { @@ -908,6 +976,9 @@ async function cleanupAppResources(): Promise { shuttingDown = true; streamManager.stopAll(); + peerMessenger.stop(); + peerEventSubscriber.stop(); + await peerRuntime.shutdown().catch(() => null); agentLifecycleRuntime.stopReconcileLoop(); authRuntime.stopSessionCleanupTimer(); autoCheckRuntime.stopScheduler(); diff --git a/apps/server/src/server/agent-prompts.ts b/apps/server/src/server/agent-prompts.ts index cc6f008a..cf107a22 100644 --- a/apps/server/src/server/agent-prompts.ts +++ b/apps/server/src/server/agent-prompts.ts @@ -7,7 +7,12 @@ import { TmuxTerminal } from "../terminal/tmux-terminal.js"; export function createPromptInjector( agentManager: AgentManager, appLog: FastifyBaseLogger, - coordinator: InjectionCoordinator + coordinator: InjectionCoordinator, + forwardToPeer?: ( + peerId: string, + remoteAgentId: string, + prompt: string + ) => Promise ) { return async function injectAgentPrompt( agentId: string, @@ -15,6 +20,13 @@ export function createPromptInjector( opts: { swallowFailure?: boolean; awaitDelivery?: boolean } = {} ): Promise { try { + // Shadow rows have no pane here — the peer service intercepts and the + // prompt is injected by the instance that actually runs the agent. + const record = await agentManager.getAgent(agentId); + if (record?.peerId && record.remoteId && forwardToPeer) { + await forwardToPeer(record.peerId, record.remoteId, prompt); + return; + } const access = await agentManager.getTerminalAccess(agentId); if (access.mode !== "tmux") { const err = new Error( diff --git a/apps/server/src/server/mcp-handlers.ts b/apps/server/src/server/mcp-handlers.ts index 376aee67..c0e0f9fc 100644 --- a/apps/server/src/server/mcp-handlers.ts +++ b/apps/server/src/server/mcp-handlers.ts @@ -66,6 +66,12 @@ import { } from "../db/personalities.js"; import { errorMessage } from "../shared/lib/error-message.js"; import { getWorktreeLocation } from "../worktree-location-settings.js"; +import { + fetchPeerRepos, + launchAgentOnPeer, + resolvePeerLocation, +} from "../peers/launch.js"; +import { getOrCreateInstanceId } from "../peers/identity.js"; function buildChildAgentInitialPrompt( parentAgentId: string, @@ -94,6 +100,12 @@ type CreateMcpHandlersDeps = { ) => T & { hasStream: boolean }; sendAgentPrompt: SendAgentPrompt; appLog: FastifyBaseLogger; + /** Durable cross-instance prompt delivery (peer outbox). */ + sendPeerPrompt?: ( + peerId: string, + targetAgentId: string, + prompt: string + ) => Promise<{ delivered: boolean }>; }; function normalizePersonalityDuplicateName(error: unknown): never { @@ -426,11 +438,16 @@ async function handleLaunchAgent( templateId?: string; templateArgs?: Record; cwd?: string; + location?: string; } ): Promise<{ agentId: string; name: string; note?: string }> { const parent = await deps.agentManager.getAgent(agentId); if (!parent) throw new Error("Parent agent not found."); + if (input.location) { + return await handleLaunchAgentOnPeer(deps, agentId, parent, input); + } + const agentType = input.type ?? parent.type ?? "claude"; if ( !CLI_AGENT_TYPES.includes(agentType as (typeof CLI_AGENT_TYPES)[number]) @@ -540,6 +557,73 @@ async function handleLaunchAgent( return { agentId: agent.id, name: agent.name, ...(note ? { note } : {}) }; } +/** + * Remote branch of handleLaunchAgent: the same tool, pointed at a linked + * instance. Parent-derived defaults become explicit on the wire, the peer + * runs its own normal launch path, and the local shadow row keeps the rest + * of Dispatch unchanged. + */ +async function handleLaunchAgentOnPeer( + deps: CreateMcpHandlersDeps, + agentId: string, + parent: AgentRecord, + input: { + name: string; + prompt: string; + type?: string; + model?: string; + fullAccess?: boolean; + templateId?: string; + cwd?: string; + location?: string; + } +): Promise<{ agentId: string; name: string; note?: string }> { + if (input.templateId) { + throw new Error( + "Templates are not supported for remote launches yet — pass the full prompt instead." + ); + } + const resolved = await resolvePeerLocation(deps.pool, input.location!); + if (!resolved.ok) throw new Error(resolved.error); + + // cwd cannot be inherited from a parent on another machine. + if (!input.cwd) { + const repos = await fetchPeerRepos(resolved.peer).catch(() => []); + const listing = + repos.map((repo) => `${repo.name} (${repo.root})`).join(", ") || + "none advertised"; + throw new Error( + `A launch on "${resolved.peer.name}" needs an explicit cwd. Repos there: ${listing}.` + ); + } + + const result = await launchAgentOnPeer( + { pool: deps.pool, agentManager: deps.agentManager }, + resolved.peer, + { + name: input.name, + prompt: input.prompt, + type: input.type ?? parent.type ?? "claude", + model: input.model, + cwd: input.cwd, + fullAccess: input.fullAccess, + parentAgentId: agentId, + } + ); + const shadow = await deps.agentManager.getAgent(result.shadowAgentId); + if (shadow) { + deps.publishUiEvent({ + type: "agent.upsert", + agent: deps.withStreamFlag(shadow), + }); + } + return { + agentId: result.shadowAgentId, + name: result.name, + note: `Launched on linked instance "${resolved.peer.name}" (remote id ${result.remoteAgentId}).`, + }; +} + async function handleArchiveAgent( deps: CreateMcpHandlersDeps, agentId: string, @@ -709,6 +793,38 @@ async function handleSendMessage( const sender = await deps.agentManager.getAgent(agentId); if (!sender) throw new Error("Sender agent not found."); + // Qualified address (":") — an agent on a linked instance + // that has no shadow row here. Forward through the peer outbox; the peer's + // own injector delivers it. + if (!input.target.startsWith("agt_") && input.target.includes(":")) { + const colon = input.target.indexOf(":"); + const location = input.target.slice(0, colon); + const remoteAgentId = input.target.slice(colon + 1); + const resolved = await resolvePeerLocation(deps.pool, location); + if (!resolved.ok) throw new Error(resolved.error); + if (!deps.sendPeerPrompt) { + throw new Error("Cross-instance messaging is not available."); + } + const instanceId = await getOrCreateInstanceId(deps.pool); + const envelope = JSON.stringify({ + from: sender.name, + senderId: `${instanceId}:${agentId}`, + message: input.message, + replyTarget: `${instanceId}:${agentId}`, + }); + const prompt = `--- DISPATCH MESSAGE ---\n${envelope}\n--- END MESSAGE ---\nOptional reply channel: If a response is necessary, use dispatch_send_message with the replyTarget above. Do not acknowledge routine status updates or completion messages unless a reply is explicitly requested.`; + const { delivered } = await deps.sendPeerPrompt( + resolved.peer.id, + remoteAgentId, + prompt + ); + return { + delivered, + targetAgentId: input.target, + targetAgentName: `${resolved.peer.name}:${remoteAgentId}`, + }; + } + const senderRepoRoot = input.senderRepoRoot; const crossRepo = await isCrossRepoMessagingEnabled(deps.pool); @@ -765,15 +881,21 @@ async function handleSendMessage( const senderRelation = relationTo(lineage, target.id, agentId); const chain = delegationChain(lineage, agentId, target.id); + // A shadow target's reply comes from another instance, so the reply address + // must be qualified — a bare local id means nothing over there. + const replyTarget = target.peerId + ? `${await getOrCreateInstanceId(deps.pool)}:${agentId}` + : agentId; + const envelope = JSON.stringify({ from: sender.name, - senderId: agentId, + senderId: replyTarget, senderRelation, ...(chain.length > 1 ? { delegationChain: chain.map((node) => `${node.name} (${node.id})`) } : {}), message: input.message, - replyTarget: agentId, + replyTarget, }); // The prose line only fires when it tells the recipient something the sender // name alone does not: that the sender is further down its tree than a direct diff --git a/apps/server/src/shared/mcp/agent-launch-tools.ts b/apps/server/src/shared/mcp/agent-launch-tools.ts index c5751b25..023e3167 100644 --- a/apps/server/src/shared/mcp/agent-launch-tools.ts +++ b/apps/server/src/shared/mcp/agent-launch-tools.ts @@ -25,6 +25,7 @@ export type LaunchAgentInput = { templateId?: string; templateArgs?: Record; cwd?: string; + location?: string; }; export type AgentLaunchToolsContext = { @@ -123,7 +124,13 @@ export function registerAgentLaunchTools( .string() .optional() .describe( - "Working directory for the new agent. Defaults to the parent's working directory." + "Working directory for the new agent. Defaults to the parent's working directory. Required with location (the remote instance's path — it cannot be inherited across machines)." + ), + location: z + .string() + .optional() + .describe( + "Name (or instance id) of a linked Dispatch instance to launch the agent on. Omit to launch locally. Requires an explicit cwd; calling without cwd returns the repos available there. Templates are not supported remotely." ), }, }, @@ -147,6 +154,7 @@ export function registerAgentLaunchTools( if (args.templateArgs !== undefined) input.templateArgs = args.templateArgs; if (args.cwd !== undefined) input.cwd = args.cwd; + if (args.location !== undefined) input.location = args.location; const result = await launchAgent(agentId, input); const text = `Launched agent "${result.name}" (${result.agentId}).`; diff --git a/apps/server/test/peers-pairing.test.ts b/apps/server/test/peers-pairing.test.ts new file mode 100644 index 00000000..d6c9ff3e --- /dev/null +++ b/apps/server/test/peers-pairing.test.ts @@ -0,0 +1,256 @@ +import { beforeAll, afterAll, describe, expect, it } from "vitest"; +import type { Pool } from "pg"; + +import { + claimPairing, + createPairingOffer, + linkToPeer, + listPeers, + revokePeer, +} from "../src/peers/pairing.js"; +import { requirePeerAuth } from "../src/peers/peer-auth.js"; +import { setupTestDb, teardownTestDb, runTestMigrations } from "./db/setup.js"; + +let pool: Pool; + +beforeAll(async () => { + pool = await setupTestDb(); + await runTestMigrations(); +}); + +afterAll(async () => { + await teardownTestDb(); +}); + +function claimer(id: string) { + return { + instanceId: id, + name: `Peer ${id}`, + url: `http://${id}.example:6767`, + token: `reverse-token-${id}-${"x".repeat(24)}`, + }; +} + +describe("pairing claim", () => { + it("accepts a valid code once and registers the peer with both credentials", async () => { + const offer = await createPairingOffer(pool, { + allowLaunch: true, + requireTailnet: false, + }); + const result = await claimPairing( + pool, + { code: offer.code, claimer: claimer("inst_aaa"), callerAddr: null }, + "acceptor-name" + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.name).toBe("acceptor-name"); + expect(result.token.length).toBeGreaterThan(30); + + const peers = await listPeers(pool); + const peer = peers.find((p) => p.id === "inst_aaa"); + expect(peer).toMatchObject({ + name: "Peer inst_aaa", + url: "http://inst_aaa.example:6767", + allowLaunch: true, + }); + + // Single use: the same code is dead after a successful claim. + const replay = await claimPairing( + pool, + { code: offer.code, claimer: claimer("inst_bbb"), callerAddr: null }, + "acceptor-name" + ); + expect(replay).toMatchObject({ ok: false, status: 401 }); + }); + + it("rejects an unknown code", async () => { + const result = await claimPairing( + pool, + { code: "000000", claimer: claimer("inst_ccc"), callerAddr: null }, + "acceptor-name" + ); + expect(result).toMatchObject({ ok: false, status: 401 }); + }); + + it("hard-denies a tailnet-required offer when the caller has no tailnet identity", async () => { + const offer = await createPairingOffer(pool, { + allowLaunch: true, + requireTailnet: true, + }); + const result = await claimPairing( + pool, + { code: offer.code, claimer: claimer("inst_ddd"), callerAddr: null }, + "acceptor-name" + ); + expect(result).toMatchObject({ ok: false, status: 403 }); + }); + + it("carries the offer's launch policy onto the issued credential", async () => { + const offer = await createPairingOffer(pool, { + allowLaunch: false, + requireTailnet: false, + }); + const result = await claimPairing( + pool, + { code: offer.code, claimer: claimer("inst_eee"), callerAddr: null }, + "acceptor-name" + ); + expect(result.ok).toBe(true); + const peers = await listPeers(pool); + expect(peers.find((p) => p.id === "inst_eee")?.allowLaunch).toBe(false); + }); +}); + +describe("linkToPeer", () => { + it("stores both directions after a successful remote claim", async () => { + const remoteToken = `accepted-token-${"y".repeat(24)}`; + let claimedBody: unknown; + const fetchImpl = (async (_url: unknown, init?: RequestInit) => { + claimedBody = JSON.parse(String(init?.body)); + return new Response( + JSON.stringify({ + instanceId: "inst_remote", + name: "cloud-vm", + token: remoteToken, + }), + { status: 200 } + ); + }) as typeof fetch; + + const result = await linkToPeer( + pool, + { + address: "127.0.0.1:6767", + code: "123456", + allowLaunch: true, + selfUrl: "http://laptop.example:6767", + }, + { instanceName: "laptop", port: 6767, fetchImpl } + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.peer).toMatchObject({ + id: "inst_remote", + name: "cloud-vm", + url: "http://127.0.0.1:6767", + }); + + // The reverse credential we minted went over the wire... + const sent = claimedBody as { + code: string; + instance: { token: string; url: string }; + }; + expect(sent.code).toBe("123456"); + expect(sent.instance.url).toBe("http://laptop.example:6767"); + + // ...and its hash is what authenticates the peer when it calls back. + const peers = await listPeers(pool); + expect(peers.some((p) => p.id === "inst_remote")).toBe(true); + }); + + it("surfaces the remote error body on a failed claim", async () => { + const fetchImpl = (async () => + new Response(JSON.stringify({ error: "Invalid or expired code." }), { + status: 401, + })) as typeof fetch; + const result = await linkToPeer( + pool, + { + address: "127.0.0.1:6767", + code: "999999", + allowLaunch: true, + selfUrl: "http://laptop.example:6767", + }, + { instanceName: "laptop", port: 6767, fetchImpl } + ); + expect(result).toMatchObject({ + ok: false, + status: 401, + error: "Invalid or expired code.", + }); + }); +}); + +describe("requirePeerAuth", () => { + function fakeReply() { + const state: { code?: number; body?: unknown } = {}; + return { + state, + code(c: number) { + state.code = c; + return this; + }, + async send(body: unknown) { + state.body = body; + }, + }; + } + + function fakeRequest(token: string | null) { + return { + headers: token ? { authorization: `Bearer ${token}` } : {}, + socket: { remoteAddress: "127.0.0.1", remotePort: 55555 }, + }; + } + + async function pairUnpinned(id: string): Promise { + const offer = await createPairingOffer(pool, { + allowLaunch: true, + requireTailnet: false, + }); + const result = await claimPairing( + pool, + { code: offer.code, claimer: claimer(id), callerAddr: null }, + "acceptor" + ); + if (!result.ok) throw new Error("pairing failed in test setup"); + return result.token; + } + + it("accepts a live unpinned credential and attaches peer identity", async () => { + const token = await pairUnpinned("inst_auth1"); + const request = fakeRequest(token); + const reply = fakeReply(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await requirePeerAuth(pool, request as any, reply as any); + expect(reply.state.code).toBeUndefined(); + expect( + (request as { peerAuth?: { peerId: string; allowLaunch: boolean } }) + .peerAuth + ).toMatchObject({ peerId: "inst_auth1", allowLaunch: true }); + }); + + it("rejects a missing or unknown token", async () => { + const none = fakeReply(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await requirePeerAuth(pool, fakeRequest(null) as any, none as any); + expect(none.state.code).toBe(401); + + const bad = fakeReply(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await requirePeerAuth(pool, fakeRequest("nope") as any, bad as any); + expect(bad.state.code).toBe(401); + }); + + it("rejects a token whose peer was revoked", async () => { + const token = await pairUnpinned("inst_auth2"); + expect(await revokePeer(pool, "inst_auth2")).toBe(true); + const reply = fakeReply(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await requirePeerAuth(pool, fakeRequest(token) as any, reply as any); + expect(reply.state.code).toBe(401); + }); + + it("hard-denies a pinned credential when whois cannot identify the caller", async () => { + const token = await pairUnpinned("inst_auth3"); + await pool.query( + `UPDATE peer_credentials SET tailnet_stable_id = 'nPINNED' + WHERE peer_id = 'inst_auth3' AND revoked_at IS NULL` + ); + const reply = fakeReply(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await requirePeerAuth(pool, fakeRequest(token) as any, reply as any); + expect(reply.state.code).toBe(403); + }); +}); diff --git a/apps/server/test/peers-tailnet-listener.test.ts b/apps/server/test/peers-tailnet-listener.test.ts new file mode 100644 index 00000000..7adeea47 --- /dev/null +++ b/apps/server/test/peers-tailnet-listener.test.ts @@ -0,0 +1,89 @@ +import http from "node:http"; +import type { AddressInfo } from "node:net"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { TailnetListener } from "../src/peers/tailnet-listener.js"; + +const noopLog = { + info: () => {}, + warn: () => {}, + error: () => {}, +} as unknown as ConstructorParameters[0]["log"]; + +function freePort(): Promise { + return new Promise((resolve, reject) => { + const probe = http.createServer(); + probe.once("error", reject); + probe.listen(0, "127.0.0.1", () => { + const port = (probe.address() as AddressInfo).port; + probe.close(() => resolve(port)); + }); + }); +} + +describe("TailnetListener", () => { + let target: http.Server | null = null; + let listener: TailnetListener | null = null; + + afterEach(async () => { + await listener?.stop(); + listener = null; + await new Promise((resolve) => { + if (!target) return resolve(); + target.close(() => resolve()); + target.closeAllConnections?.(); + }); + target = null; + }); + + it("delegates requests to the app server and preserves the caller socket", async () => { + let seenRemote: string | undefined; + target = http.createServer((req, res) => { + seenRemote = req.socket.remoteAddress ?? undefined; + res.end("ok"); + }); + // The target never listens — proves delegation, not accidental routing. + const port = await freePort(); + listener = new TailnetListener({ + appServer: () => target!, + port, + tls: null, + log: noopLog, + }); + await listener.start("127.0.0.1"); + + const body = await new Promise((resolve, reject) => { + http.get(`http://127.0.0.1:${port}/`, (res) => { + let data = ""; + res.on("data", (c) => (data += String(c))); + res.on("end", () => resolve(data)); + res.on("error", reject); + }); + }); + expect(body).toBe("ok"); + // The socket the handler sees is the listener's own — remoteAddress is real. + expect(seenRemote).toContain("127.0.0.1"); + }); + + it("reports its bound address including the IPv4-mapped form", async () => { + target = http.createServer(); + const port = await freePort(); + listener = new TailnetListener({ + appServer: () => target!, + port, + tls: null, + log: noopLog, + }); + expect(listener.isBoundAddress("127.0.0.1")).toBe(false); + await listener.start("127.0.0.1"); + expect(listener.address).toBe("127.0.0.1"); + expect(listener.isBoundAddress("127.0.0.1")).toBe(true); + expect(listener.isBoundAddress("::ffff:127.0.0.1")).toBe(true); + expect(listener.isBoundAddress("100.64.0.1")).toBe(false); + expect(listener.isBoundAddress(undefined)).toBe(false); + await listener.stop(); + expect(listener.address).toBeNull(); + expect(listener.isBoundAddress("127.0.0.1")).toBe(false); + }); +}); diff --git a/apps/server/test/peers-tailscale.test.ts b/apps/server/test/peers-tailscale.test.ts new file mode 100644 index 00000000..f9a360ae --- /dev/null +++ b/apps/server/test/peers-tailscale.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; + +import { + parseTailscaleStatus, + parseTailscaleWhois, + pickTailnetIPv4, +} from "../src/peers/tailscale.js"; + +describe("parseTailscaleStatus", () => { + const running = { + BackendState: "Running", + Self: { + ID: "n7q7tzN4dQ11CNTRL", + DNSName: "laptop.tailnet.ts.net.", + TailscaleIPs: ["100.64.1.2", "fd7a:115c:a1e0::1"], + Online: true, + }, + }; + + it("extracts the durable identity and strips the trailing DNS dot", () => { + const self = parseTailscaleStatus(JSON.stringify(running)); + expect(self).toEqual({ + stableId: "n7q7tzN4dQ11CNTRL", + dnsName: "laptop.tailnet.ts.net", + ips: ["100.64.1.2", "fd7a:115c:a1e0::1"], + online: true, + }); + }); + + it("returns null when the backend is not running", () => { + expect( + parseTailscaleStatus( + JSON.stringify({ ...running, BackendState: "NeedsLogin" }) + ) + ).toBeNull(); + }); + + it("returns null when Self.ID is missing", () => { + expect( + parseTailscaleStatus( + JSON.stringify({ BackendState: "Running", Self: { DNSName: "x." } }) + ) + ).toBeNull(); + }); + + it("returns null on garbage output", () => { + expect(parseTailscaleStatus("not json")).toBeNull(); + expect(parseTailscaleStatus("null")).toBeNull(); + }); + + it("tolerates missing optional fields", () => { + const self = parseTailscaleStatus( + JSON.stringify({ BackendState: "Running", Self: { ID: "nABC" } }) + ); + expect(self).toEqual({ + stableId: "nABC", + dnsName: "", + ips: [], + online: false, + }); + }); +}); + +describe("parseTailscaleWhois", () => { + it("extracts node identity, tags, sharer, and login name", () => { + const whois = parseTailscaleWhois( + JSON.stringify({ + Node: { + StableID: "nXYZCNTRL", + Name: "cloud-vm.tailnet.ts.net.", + Tags: ["tag:ci"], + Sharer: "userid:123", + }, + UserProfile: { LoginName: "luke@example.com" }, + }) + ); + expect(whois).toEqual({ + stableId: "nXYZCNTRL", + nodeName: "cloud-vm.tailnet.ts.net", + tags: ["tag:ci"], + sharer: "userid:123", + loginName: "luke@example.com", + }); + }); + + it("returns null when Node.StableID is absent — hard deny, no fallback", () => { + expect( + parseTailscaleWhois( + JSON.stringify({ Node: { Name: "x." }, UserProfile: {} }) + ) + ).toBeNull(); + expect(parseTailscaleWhois("{}")).toBeNull(); + expect(parseTailscaleWhois("not json")).toBeNull(); + }); + + it("normalizes an empty sharer to null", () => { + const whois = parseTailscaleWhois( + JSON.stringify({ Node: { StableID: "nA", Sharer: "" } }) + ); + expect(whois?.sharer).toBeNull(); + }); +}); + +describe("pickTailnetIPv4", () => { + it("prefers the 100.x IPv4 address", () => { + expect(pickTailnetIPv4(["fd7a:115c::1", "100.64.9.9"])).toBe("100.64.9.9"); + }); + + it("returns null when only IPv6 is present", () => { + expect(pickTailnetIPv4(["fd7a:115c::1"])).toBeNull(); + expect(pickTailnetIPv4([])).toBeNull(); + }); +}); diff --git a/apps/web/src/components/app/agent-card.tsx b/apps/web/src/components/app/agent-card.tsx index 065f9036..0effaf57 100644 --- a/apps/web/src/components/app/agent-card.tsx +++ b/apps/web/src/components/app/agent-card.tsx @@ -196,6 +196,14 @@ export function AgentCard({ {agent.lastError ? ( ) : null} + {agent.peerId ? ( +
+ + Location + + {agent.peerId} +
+ ) : null} {agent.persona ? (
diff --git a/apps/web/src/components/app/linked-instances-settings.tsx b/apps/web/src/components/app/linked-instances-settings.tsx new file mode 100644 index 00000000..4a7f75b4 --- /dev/null +++ b/apps/web/src/components/app/linked-instances-settings.tsx @@ -0,0 +1,298 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Link2, Loader2, MonitorSmartphone, Trash2 } from "lucide-react"; +import { useState } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { api } from "@/lib/api"; + +type PeerSelf = { + instanceId: string; + passwordSet: boolean; + tailscale: { dnsName: string; stableId: string } | null; + bind: { + enabled: boolean; + active: boolean; + address: string | null; + blockedReason: "no-password" | "no-tailscale" | null; + }; +}; + +type Peer = { + id: string; + name: string; + url: string; + tailnetStableId: string | null; + createdAt: string; + lastSeenAt: string | null; + allowLaunch: boolean; +}; + +type PairingOffer = { + pairingId: string; + code: string; + expiresAt: string; + address: string | null; +}; + +const selfQueryKey = ["peers", "self"] as const; +const peersQueryKey = ["peers", "list"] as const; + +export function LinkedInstancesSettings(): JSX.Element { + const queryClient = useQueryClient(); + const selfQuery = useQuery({ + queryKey: selfQueryKey, + queryFn: () => api("/api/v1/peers/self"), + }); + const peersQuery = useQuery({ + queryKey: peersQueryKey, + queryFn: async () => (await api<{ peers: Peer[] }>("/api/v1/peers")).peers, + }); + + const bindMutation = useMutation({ + mutationFn: (enabled: boolean) => + api("/api/v1/peers/settings/tailnet-bind", { + method: "POST", + body: JSON.stringify({ enabled }), + }), + onSettled: () => queryClient.invalidateQueries({ queryKey: selfQueryKey }), + }); + + const offerMutation = useMutation({ + mutationFn: () => + api("/api/v1/peers/pairings", { + method: "POST", + body: JSON.stringify({ allowLaunch: true, requireTailnet: true }), + }), + }); + + const [linkAddress, setLinkAddress] = useState(""); + const [linkCode, setLinkCode] = useState(""); + const linkMutation = useMutation({ + mutationFn: () => + api<{ peer: Peer }>("/api/v1/peers/link", { + method: "POST", + body: JSON.stringify({ + address: linkAddress.trim(), + code: linkCode.trim(), + allowLaunch: true, + }), + }), + onSuccess: () => { + setLinkAddress(""); + setLinkCode(""); + void queryClient.invalidateQueries({ queryKey: peersQueryKey }); + }, + }); + + const revokeMutation = useMutation({ + mutationFn: (peerId: string) => + api(`/api/v1/peers/${peerId}`, { method: "DELETE" }), + onSuccess: () => + void queryClient.invalidateQueries({ queryKey: peersQueryKey }), + }); + + const self = selfQuery.data; + const peers = peersQuery.data ?? []; + const offer = offerMutation.data; + + return ( +
+
+

Linked instances

+

+ Pair this Dispatch with another one you own — then agents can be + launched there by adding a location to the same launch tools. +

+
+ + + + This instance + {self && ( + + {self.tailscale + ? `On the tailnet as ${self.tailscale.dnsName}` + : "Tailscale not detected — linking needs both machines on one tailnet."} + + )} + + +
+
+

Accept tailnet connections

+

+ Expose the API on the tailnet interface so linked instances can + reach this one. +

+
+ bindMutation.mutate(enabled)} + aria-label="Accept tailnet connections" + /> +
+ {bindMutation.isError && ( +

+ {(bindMutation.error as Error).message} +

+ )} + {self?.bind.enabled && self.bind.blockedReason === "no-password" && ( +

+ Blocked: set a password first — without one every route is open. +

+ )} + {self?.bind.enabled && self.bind.blockedReason === "no-tailscale" && ( +

+ Blocked: tailscale is not running on this machine. +

+ )} + {self?.bind.active && self.bind.address && ( +

+ Listening on {self.bind.address} +

+ )} +
+
+ + + + Pair a new instance + + Show a code here and type it on the other instance — or type a code + another instance is showing. + + + + {offer ? ( +
+

+ {offer.code} +

+

+ On the other instance, link to{" "} + + {offer.address ?? "this instance's address"} + {" "} + with this code. Expires in 10 minutes. +

+
+ ) : ( + + )} + {offerMutation.isError && ( +

+ {(offerMutation.error as Error).message} +

+ )} + +
{ + event.preventDefault(); + linkMutation.mutate(); + }} + > + setLinkAddress(e.target.value)} + placeholder="other-host.tailnet.ts.net:6767" + aria-label="Instance address" + className="sm:flex-1" + /> + setLinkCode(e.target.value)} + placeholder="Code" + aria-label="Pairing code" + className="sm:w-28" + /> + +
+ {linkMutation.isError && ( +

+ {(linkMutation.error as Error).message} +

+ )} +
+
+ + {(peers.length > 0 || peersQuery.isError) && ( + + + Linked + + + {peersQuery.isError ? ( +

+ Could not load linked instances. +

+ ) : ( +
    + {peers.map((peer) => ( +
  • +
    +

    + {peer.name} + {peer.allowLaunch && ( + can launch here + )} +

    +

    + {peer.url} + {peer.tailnetStableId ? " · tailnet-pinned" : ""} +

    +
    + +
  • + ))} +
+ )} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/app/settings-pane.tsx b/apps/web/src/components/app/settings-pane.tsx index da3653e8..f72aa635 100644 --- a/apps/web/src/components/app/settings-pane.tsx +++ b/apps/web/src/components/app/settings-pane.tsx @@ -3,6 +3,7 @@ import { Database, Server, Settings } from "lucide-react"; import { AgentTypeSettings } from "@/components/app/agent-type-settings"; import { AppearanceSettings } from "@/components/app/appearance-settings"; import { BrowserExtensionSettings } from "@/components/app/browser-extension-settings"; +import { LinkedInstancesSettings } from "@/components/app/linked-instances-settings"; import { CrossRepoMessagingSettings } from "@/components/app/cross-repo-messaging-settings"; import { InjectionHoldSettings } from "@/components/app/injection-hold-settings"; import { LaunchGuidanceSettings } from "@/components/app/launch-guidance-settings"; @@ -223,7 +224,14 @@ export function SettingsContent({
)} {activeSection === "notifications" && } - {activeSection === "connections" && } + {activeSection === "connections" && ( +
+ +
+ +
+
+ )} {activeSection === "resources" && } {activeSection === "updates" && ( diff --git a/apps/web/src/components/app/types.ts b/apps/web/src/components/app/types.ts index c5699e25..1cbd15ec 100644 --- a/apps/web/src/components/app/types.ts +++ b/apps/web/src/components/app/types.ts @@ -58,6 +58,9 @@ export type Agent = { worktreePath: string | null; worktreeBranch: string | null; tmuxSession: string | null; + /** Set on shadow rows for agents running on a linked instance. */ + peerId?: string | null; + remoteId?: string | null; agentArgs: string[]; model: string | null; fullAccess: boolean; From 57b9e07da83c1e42c5887ad3c0e473f82c70ea1c Mon Sep 17 00:00:00 2001 From: Luke Brevoort Date: Thu, 13 Aug 2026 14:43:49 -0700 Subject: [PATCH 02/14] fix(peers): address persona review feedback Reconciler skips shadow rows; peer auth fails closed without a password; tailnet listener destroys upgraded sockets and skips wildcard-host binds; peer SSE is scoped (agent.upsert only, slim fields) with snapshot-first buffering; pairing handshake carries a protocol version; shadows are marked stale when their peer drops; UI: role-split pairing cards, password hints, peer display name on the Location badge; remote-launch context limits documented in tool description and child preamble. Co-Authored-By: Claude Fable 5 --- apps/server/src/agents/reconciler.ts | 4 +- apps/server/src/db/migrations/0041_peers.sql | 6 +- apps/server/src/peers/events.ts | 36 ++++++++++ apps/server/src/peers/launch.ts | 1 + apps/server/src/peers/pairing.ts | 17 +++++ apps/server/src/peers/peer-auth.ts | 12 ++++ apps/server/src/peers/runtime.ts | 34 ++++++++-- apps/server/src/peers/tailnet-listener.ts | 9 +++ apps/server/src/routes/peers.ts | 67 ++++++++++++++++--- apps/server/src/server.ts | 3 +- .../src/shared/mcp/agent-launch-tools.ts | 2 +- apps/server/test/peers-pairing.test.ts | 21 ++++++ apps/web/src/components/app/agent-card.tsx | 20 +++++- .../app/linked-instances-settings.tsx | 43 ++++++++++-- 14 files changed, 247 insertions(+), 28 deletions(-) diff --git a/apps/server/src/agents/reconciler.ts b/apps/server/src/agents/reconciler.ts index d6774b6f..49d7ddee 100644 --- a/apps/server/src/agents/reconciler.ts +++ b/apps/server/src/agents/reconciler.ts @@ -108,7 +108,9 @@ async function reconcileAgentStatuses( await diagnostics.maybeMaintenanceLogs(); const result = await pool.query( - "SELECT id, tmux_session AS \"tmuxSession\", status, updated_at AS \"updatedAt\" FROM agents WHERE deleted_at IS NULL AND status IN ('running', 'stopping', 'creating', 'archiving')" + // Shadow rows (peer_id set) have no local pane by design — their status is + // mirrored from the owning peer, so local tmux reconciliation must skip them. + "SELECT id, tmux_session AS \"tmuxSession\", status, updated_at AS \"updatedAt\" FROM agents WHERE deleted_at IS NULL AND peer_id IS NULL AND status IN ('running', 'stopping', 'creating', 'archiving')" ); const reconciled: AgentRecord[] = []; diff --git a/apps/server/src/db/migrations/0041_peers.sql b/apps/server/src/db/migrations/0041_peers.sql index 9073935b..319d7d0c 100644 --- a/apps/server/src/db/migrations/0041_peers.sql +++ b/apps/server/src/db/migrations/0041_peers.sql @@ -53,9 +53,9 @@ CREATE INDEX IF NOT EXISTS peer_pairings_expires_idx ALTER TABLE agents ADD COLUMN IF NOT EXISTS peer_id text; ALTER TABLE agents ADD COLUMN IF NOT EXISTS remote_id text; -CREATE INDEX IF NOT EXISTS agents_peer_remote_idx - ON agents (peer_id, remote_id) - WHERE peer_id IS NOT NULL; +-- Deliberately no index on (peer_id, remote_id): building one at startup +-- migration write-locks a potentially large agents table, and shadow lookups +-- filter a table that stays small in practice. -- Messages are CONTENT: losing one loses work, and a blind retry would -- double-inject. Hence a durable sender-side outbox with backoff, and a diff --git a/apps/server/src/peers/events.ts b/apps/server/src/peers/events.ts index 62468e34..56124db7 100644 --- a/apps/server/src/peers/events.ts +++ b/apps/server/src/peers/events.ts @@ -131,6 +131,7 @@ export class PeerEventSubscriber { { err: error, peerId: peer.id }, "Peer event stream dropped; will reconnect" ); + await this.markPeerUnreachable(peer.id); } attempt += 1; const delay = Math.min( @@ -191,6 +192,41 @@ export class PeerEventSubscriber { } } + /** + * The peer stopped talking — stamp its shadows so "last known state" is + * visibly distinct from live state. The next snapshot supersedes this. + */ + private async markPeerUnreachable(peerId: string): Promise { + try { + const shadows = await this.deps.pool.query<{ id: string }>( + `UPDATE agents + SET latest_event_type = 'working', + latest_event_message = 'Linked instance unreachable — status may be stale.', + latest_event_metadata = '{"source":"system","peerUnreachable":true}'::jsonb, + latest_event_updated_at = now(), + updated_at = now() + WHERE peer_id = $1 AND deleted_at IS NULL + AND status IN ('creating', 'running', 'stopping') + RETURNING id`, + [peerId] + ); + for (const row of shadows.rows) { + const agent = await this.deps.agentManager.getAgent(row.id); + if (agent) { + this.deps.publishUiEvent({ + type: "agent.upsert", + agent: this.deps.withStreamFlag(agent), + }); + } + } + } catch (error) { + this.deps.log.warn( + { err: error, peerId }, + "Failed to mark shadows for unreachable peer" + ); + } + } + private async mirrorRemoteAgent( peerId: string, remote: RemoteAgentShape diff --git a/apps/server/src/peers/launch.ts b/apps/server/src/peers/launch.ts index 13d8e28a..79b5a4f1 100644 --- a/apps/server/src/peers/launch.ts +++ b/apps/server/src/peers/launch.ts @@ -45,6 +45,7 @@ function buildRemoteChildInitialPrompt( return [ `You were launched from a linked Dispatch instance by agent "${parentAddress}" via dispatch_launch_agent.`, "Use that full address (instance:agent) as the target when coordinating back with dispatch_send_message.", + "You start fresh: the prompt below is your entire briefing — you have no access to the launching agent's transcript or context. Ask it via dispatch_send_message if something is missing.", "", prompt, ].join("\n"); diff --git a/apps/server/src/peers/pairing.ts b/apps/server/src/peers/pairing.ts index a65460f7..cef2db85 100644 --- a/apps/server/src/peers/pairing.ts +++ b/apps/server/src/peers/pairing.ts @@ -9,6 +9,14 @@ import { getTailscaleSelf, tailscaleWhois } from "./tailscale.js"; export const PAIRING_TTL_MS = 10 * 60 * 1000; +/** + * Version of the cross-instance wire contract (pairing, launch, messages, + * events). Negotiated once at pairing time: a mismatch fails the handshake + * with a clear error instead of failing ambiguously on a later payload. + * Bump on any incompatible change to the peer routes. + */ +export const PEER_PROTOCOL_VERSION = 1; + export type PeerRecord = { id: string; name: string; @@ -255,6 +263,7 @@ export async function linkToPeer( method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ + protocolVersion: PEER_PROTOCOL_VERSION, code: input.code, instance: { id: instanceId, @@ -286,10 +295,18 @@ export async function linkToPeer( instanceId?: string; name?: string; token?: string; + protocolVersion?: number; }; if (!body.instanceId || !body.token) { return { ok: false, status: 502, error: "Peer sent a malformed response." }; } + if (body.protocolVersion !== PEER_PROTOCOL_VERSION) { + return { + ok: false, + status: 409, + error: `Peer speaks protocol v${body.protocolVersion ?? "unknown"}, this instance v${PEER_PROTOCOL_VERSION} — update the older instance and pair again.`, + }; + } const peerStableId = await whoisOfUrl(peerUrl); const client = await pool.connect(); diff --git a/apps/server/src/peers/peer-auth.ts b/apps/server/src/peers/peer-auth.ts index 2f500e9b..5babf14f 100644 --- a/apps/server/src/peers/peer-auth.ts +++ b/apps/server/src/peers/peer-auth.ts @@ -3,6 +3,7 @@ import crypto from "node:crypto"; import type { FastifyReply, FastifyRequest } from "fastify"; import type { Pool } from "pg"; +import { getSetting } from "../db/settings.js"; import { tailscaleWhois } from "./tailscale.js"; export type PeerAuth = { @@ -45,6 +46,17 @@ export async function requirePeerAuth( request: FastifyRequest, reply: FastifyReply ): Promise { + // Fail closed if the password was cleared after pairing: first-run open + // mode must never extend to peers, even ones holding a valid token. + if ((await getSetting(pool, "password_hash")) === null) { + await reply + .code(403) + .send({ + error: "This instance has no password set — peer access is disabled.", + }); + return; + } + const token = bearerToken(request); if (!token) { await reply.code(401).send({ error: "Peer authentication required." }); diff --git a/apps/server/src/peers/runtime.ts b/apps/server/src/peers/runtime.ts index ef56fc35..33a2aee6 100644 --- a/apps/server/src/peers/runtime.ts +++ b/apps/server/src/peers/runtime.ts @@ -16,7 +16,7 @@ export type TailnetBindStatus = { active: boolean; address: string | null; /** Why the listener is not active despite being enabled, for the UI. */ - blockedReason: "no-password" | "no-tailscale" | null; + blockedReason: "no-password" | "no-tailscale" | "wildcard-host" | null; }; export type PeerSelfStatus = { @@ -30,9 +30,15 @@ type PeerRuntimeDeps = { pool: Pool; listener: TailnetListener; isPasswordSet: () => Promise; + /** The primary server's bind host — wildcard binds already own the port. */ + primaryHost: string; log: FastifyBaseLogger; }; +function primaryBindsAllInterfaces(host: string): boolean { + return host === "0.0.0.0" || host === "::"; +} + /** * Owns the tailnet exposure lifecycle: reads the bind setting and starts or * stops the tailnet listener to match. A missing password is a hard stop — @@ -47,6 +53,20 @@ export class PeerRuntime { await this.deps.listener.stop(); return { enabled, active: false, address: null, blockedReason: null }; } + if (primaryBindsAllInterfaces(this.deps.primaryHost)) { + // The wildcard bind already serves the tailnet IP; a second bind on the + // same port would EADDRINUSE. Peer auth still whois-pins every caller. + await this.deps.listener.stop(); + this.deps.log.info( + "Tailnet bind: primary server binds all interfaces — no secondary listener needed" + ); + return { + enabled, + active: false, + address: null, + blockedReason: "wildcard-host", + }; + } if (!(await this.deps.isPasswordSet())) { await this.deps.listener.stop(); this.deps.log.warn( @@ -95,11 +115,13 @@ export class PeerRuntime { address: this.deps.listener.address, blockedReason: !enabled ? null - : !passwordSet - ? "no-password" - : !tailscale - ? "no-tailscale" - : null, + : primaryBindsAllInterfaces(this.deps.primaryHost) + ? "wildcard-host" + : !passwordSet + ? "no-password" + : !tailscale + ? "no-tailscale" + : null, }, }; } diff --git a/apps/server/src/peers/tailnet-listener.ts b/apps/server/src/peers/tailnet-listener.ts index ddc079d4..a39ab955 100644 --- a/apps/server/src/peers/tailnet-listener.ts +++ b/apps/server/src/peers/tailnet-listener.ts @@ -22,6 +22,9 @@ type TailnetListenerDeps = { export class TailnetListener { private server: http.Server | https.Server | null = null; private boundAddress: string | null = null; + // closeAllConnections() does not cover upgraded (WebSocket) sockets, so we + // track every accepted socket and destroy them ourselves on stop. + private readonly sockets = new Set(); constructor(private readonly deps: TailnetListenerDeps) {} @@ -54,6 +57,10 @@ export class TailnetListener { server.on("upgrade", (req, socket, head) => { target.emit("upgrade", req, socket, head); }); + server.on("connection", (socket) => { + this.sockets.add(socket); + socket.once("close", () => this.sockets.delete(socket)); + }); await new Promise((resolve, reject) => { const onError = (err: Error) => reject(err); server.once("error", onError); @@ -78,6 +85,8 @@ export class TailnetListener { server.close(() => resolve()); // close() waits for open connections (incl. SSE); cut them loose. server.closeAllConnections?.(); + for (const socket of this.sockets) socket.destroy(); + this.sockets.clear(); }); this.deps.log.info("Peer listener stopped"); } diff --git a/apps/server/src/routes/peers.ts b/apps/server/src/routes/peers.ts index 5e4c6f8d..b3bd42ef 100644 --- a/apps/server/src/routes/peers.ts +++ b/apps/server/src/routes/peers.ts @@ -14,6 +14,7 @@ import { createPairingOffer, linkToPeer, listPeers, + PEER_PROTOCOL_VERSION, revokePeer, } from "../peers/pairing.js"; import { setTailnetBindEnabled } from "../peers/peer-settings.js"; @@ -30,6 +31,7 @@ const PairingOfferBodySchema = z.object({ }); const ClaimBodySchema = z.object({ + protocolVersion: z.number().int().optional(), code: z.string().trim().min(6).max(12), instance: z.object({ id: z.string().trim().min(1).max(64), @@ -86,7 +88,6 @@ type PeerRouteDeps = { opts: { swallowFailure: boolean; awaitDelivery: boolean } ) => Promise; subscribeUiEvents: (stream: NodeJS.WritableStream) => () => void; - sendUiSnapshot: (stream: NodeJS.WritableStream, agents: unknown[]) => void; }; async function instanceDisplayName(pool: Pool): Promise { @@ -159,6 +160,11 @@ export async function registerPeerRoutes( async (request, reply) => { const input = parseInput(ClaimBodySchema, request.body, reply); if (!input) return; + if (input.protocolVersion !== PEER_PROTOCOL_VERSION) { + return reply.code(409).send({ + error: `This instance speaks peer protocol v${PEER_PROTOCOL_VERSION}; the claiming instance sent v${input.protocolVersion ?? "unknown"}. Update the older instance and pair again.`, + }); + } if (!(await deps.isPasswordSet())) { return reply .code(409) @@ -186,6 +192,7 @@ export async function registerPeerRoutes( return reply.code(result.status).send({ error: result.error }); } return { + protocolVersion: PEER_PROTOCOL_VERSION, instanceId: result.instanceId, name: result.name, token: result.token, @@ -297,7 +304,46 @@ export async function registerPeerRoutes( reply.hijack(); const stream = reply.raw; - const unsubscribe = deps.subscribeUiEvents(stream); + // Peers get a scoped view, not the full UI event firehose: only + // agent.upsert, only local (non-shadow) agents, only the status fields + // the mirror consumes. Live events buffer until the snapshot is written + // so a reconnect can never regress a shadow with an older snapshot. + const slim = (agent: { + id: string; + name: string; + type: string; + status: string; + }) => ({ + id: agent.id, + name: agent.name, + type: agent.type, + status: agent.status, + }); + let snapshotSent = false; + const pending: string[] = []; + const filtered = { + write(chunk: unknown): boolean { + const dataLine = String(chunk) + .split("\n") + .find((line) => line.startsWith("data: ")); + if (!dataLine) return true; + try { + const event = JSON.parse(dataLine.slice(6)) as { + type?: string; + agent?: AgentRecord; + }; + if (event.type !== "agent.upsert" || !event.agent) return true; + if (event.agent.peerId) return true; + const payload = `data: ${JSON.stringify({ type: "agent.upsert", agent: slim(event.agent) })}\n\n`; + if (snapshotSent) stream.write(payload); + else pending.push(payload); + } catch { + // Malformed frame — never a peer's problem. + } + return true; + }, + } as unknown as NodeJS.WritableStream; + const unsubscribe = deps.subscribeUiEvents(filtered); const heartbeat = setInterval(() => { stream.write(": keepalive\n\n"); }, 20_000); @@ -316,14 +362,19 @@ export async function registerPeerRoutes( } try { const agents = await deps.agentManager.listAgents(); - if (!request.raw.destroyed) { - deps.sendUiSnapshot( - stream, - agents.filter((agent) => !agent.peerId).map(deps.withStreamFlag) - ); - } else { + if (request.raw.destroyed) { cleanup(); + return; } + stream.write( + `data: ${JSON.stringify({ + type: "snapshot", + agents: agents.filter((agent) => !agent.peerId).map(slim), + })}\n\n` + ); + snapshotSent = true; + for (const payload of pending) stream.write(payload); + pending.length = 0; } catch { // Live events still flow; the subscriber re-snapshots on reconnect. } diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 8c6bdb0e..5e555f23 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -433,6 +433,7 @@ const peerRuntime = new PeerRuntime({ pool, listener: tailnetListener, isPasswordSet: () => authRuntime.isPasswordSetCached(), + primaryHost: config.host, log: app.log, }); const peerEventSubscriber = new PeerEventSubscriber({ @@ -703,8 +704,6 @@ async function registerRoutes() { withStreamFlag, injectAgentPrompt, subscribeUiEvents: (stream) => uiEventBroker.subscribe(stream), - sendUiSnapshot: (stream, agents) => - uiEventBroker.sendSnapshot(stream, agents as AgentRecord[]), }); await registerSystemRoutes(app, { diff --git a/apps/server/src/shared/mcp/agent-launch-tools.ts b/apps/server/src/shared/mcp/agent-launch-tools.ts index 023e3167..5b344067 100644 --- a/apps/server/src/shared/mcp/agent-launch-tools.ts +++ b/apps/server/src/shared/mcp/agent-launch-tools.ts @@ -130,7 +130,7 @@ export function registerAgentLaunchTools( .string() .optional() .describe( - "Name (or instance id) of a linked Dispatch instance to launch the agent on. Omit to launch locally. Requires an explicit cwd; calling without cwd returns the repos available there. Templates are not supported remotely." + "Name (or instance id) of a linked Dispatch instance to launch the agent on. Omit to launch locally. Requires an explicit cwd; calling without cwd returns the repos available there. Templates are not supported remotely. IMPORTANT: only the prompt you write crosses instances — the remote agent does not see your transcript, messages, or files, so write the prompt as a complete self-contained briefing." ), }, }, diff --git a/apps/server/test/peers-pairing.test.ts b/apps/server/test/peers-pairing.test.ts index d6c9ff3e..220bf6af 100644 --- a/apps/server/test/peers-pairing.test.ts +++ b/apps/server/test/peers-pairing.test.ts @@ -110,6 +110,7 @@ describe("linkToPeer", () => { claimedBody = JSON.parse(String(init?.body)); return new Response( JSON.stringify({ + protocolVersion: 1, instanceId: "inst_remote", name: "cloud-vm", token: remoteToken, @@ -173,6 +174,26 @@ describe("linkToPeer", () => { }); describe("requirePeerAuth", () => { + beforeAll(async () => { + // Peer auth fails closed without a configured password. + await pool.query( + `INSERT INTO settings (key, value) VALUES ('password_hash', 'test-hash') + ON CONFLICT (key) DO UPDATE SET value = 'test-hash'` + ); + }); + + it("hard-denies every token while no password is set", async () => { + await pool.query(`DELETE FROM settings WHERE key = 'password_hash'`); + const token = await pairUnpinned("inst_auth0"); + const reply = fakeReply(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await requirePeerAuth(pool, fakeRequest(token) as any, reply as any); + expect(reply.state.code).toBe(403); + await pool.query( + `INSERT INTO settings (key, value) VALUES ('password_hash', 'test-hash')` + ); + }); + function fakeReply() { const state: { code?: number; body?: unknown } = {}; return { diff --git a/apps/web/src/components/app/agent-card.tsx b/apps/web/src/components/app/agent-card.tsx index 0effaf57..cbc8f838 100644 --- a/apps/web/src/components/app/agent-card.tsx +++ b/apps/web/src/components/app/agent-card.tsx @@ -1,4 +1,7 @@ import React from "react"; +import { useQuery } from "@tanstack/react-query"; + +import { api } from "@/lib/api"; import { AgentMeta } from "@/components/app/agent-meta"; import { AgentCardActions } from "@/components/app/agent-card-actions"; @@ -110,6 +113,19 @@ export function AgentCard({ // Owned here rather than in AgentCardDetails so the copy confirmation is not // lost when the details panel unmounts on collapse. const [worktreePathCopied, copyWorktreePath] = useCopyText(); + const { data: linkedPeers } = useQuery({ + queryKey: ["peers", "list"], + queryFn: async () => + ( + await api<{ peers: Array<{ id: string; name: string }> }>( + "/api/v1/peers" + ) + ).peers, + enabled: Boolean(agent.peerId), + staleTime: 60_000, + }); + const peerDisplayName = + linkedPeers?.find((peer) => peer.id === agent.peerId)?.name ?? agent.peerId; const isTerminalAgent = agent.type === "terminal"; const { diffStats, refresh: refreshDiffStats } = useAgentDiffStats( agent.id, @@ -201,7 +217,9 @@ export function AgentCard({ Location - {agent.peerId} + + {peerDisplayName} + ) : null} {agent.persona ? ( diff --git a/apps/web/src/components/app/linked-instances-settings.tsx b/apps/web/src/components/app/linked-instances-settings.tsx index 4a7f75b4..1bd2fa44 100644 --- a/apps/web/src/components/app/linked-instances-settings.tsx +++ b/apps/web/src/components/app/linked-instances-settings.tsx @@ -111,7 +111,10 @@ export function LinkedInstancesSettings(): JSX.Element {

Linked instances

Pair this Dispatch with another one you own — then agents can be - launched there by adding a location to the same launch tools. + launched there by adding a location to the same launch tools. While + the instances can't reach each other (laptop closed, VPN down), + messages queue and deliver on reconnect; remote agent status shows the + last known state until then.

@@ -167,10 +170,12 @@ export function LinkedInstancesSettings(): JSX.Element { - Pair a new instance + + Accept a connection from another instance + - Show a code here and type it on the other instance — or type a code - another instance is showing. + Do this on the machine being connected TO (e.g. the cloud box). It + shows a code; you enter that code on the other machine below. @@ -180,11 +185,12 @@ export function LinkedInstancesSettings(): JSX.Element { {offer.code}

- On the other instance, link to{" "} + On the OTHER instance, open Settings → Connections and enter{" "} {offer.address ?? "this instance's address"} {" "} - with this code. Expires in 10 minutes. + plus this code into its "Connect to another instance" form. + Expires in 10 minutes.

) : ( @@ -197,12 +203,31 @@ export function LinkedInstancesSettings(): JSX.Element { Show pairing code )} + {self && !self.passwordSet && ( +

+ Pairing requires a password — set one in Settings → Security + first, on both instances. +

+ )} {offerMutation.isError && (

{(offerMutation.error as Error).message}

)} +
+
+ + + + Connect to another instance + + + Do this on the machine you're connecting FROM (e.g. your laptop), + using the address and code the other instance is showing. + + +
{ @@ -240,6 +265,12 @@ export function LinkedInstancesSettings(): JSX.Element { Link
+ {self && !self.passwordSet && ( +

+ Pairing requires a password — set one in Settings → Security + first, on both instances. +

+ )} {linkMutation.isError && (

{(linkMutation.error as Error).message} From 92eb2b448784495ae51f0b7c5109bcc9cc2673bb Mon Sep 17 00:00:00 2001 From: Luke Brevoort Date: Thu, 13 Aug 2026 14:54:42 -0700 Subject: [PATCH 03/14] fix(peers): drop the peer event stream when snapshot generation fails Buffered delivery without a snapshot would hold events forever; destroying the connection makes the subscriber reconnect and re-snapshot. Co-Authored-By: Claude Fable 5 --- apps/server/src/routes/peers.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/routes/peers.ts b/apps/server/src/routes/peers.ts index b3bd42ef..8e9b92a3 100644 --- a/apps/server/src/routes/peers.ts +++ b/apps/server/src/routes/peers.ts @@ -376,7 +376,10 @@ export async function registerPeerRoutes( for (const payload of pending) stream.write(payload); pending.length = 0; } catch { - // Live events still flow; the subscriber re-snapshots on reconnect. + // Without a snapshot, buffered delivery would hold events forever — + // drop the connection so the peer reconnects and re-snapshots. + cleanup(); + stream.destroy(); } } ); From 0c6d322b630b945a52058f92fd7278c91855639b Mon Sep 17 00:00:00 2001 From: Luke Brevoort Date: Mon, 17 Aug 2026 10:42:51 -0600 Subject: [PATCH 04/14] fix(peers): capability set, race fixes, and per-peer outbox draining MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review of the linked-instances branch. Capability model — pairing granted three separable powers (launch, message, full-access) behind one allow_launch boolean, so the messages route ended up with no gate at all and the launch route honored any cwd off the wire: - peer_credentials carries allow_launch/allow_message/allow_full_access; each route gates on its own member. full-access is off by default. - incoming launches must land inside a repo root this instance advertises, resolved through realpath and compared segment-wise. - a pairing claim can no longer take over a live peer slot pinned to a different tailnet node. Races: - re-pairing rotates the outbound token, but the SSE loop was keyed on peer id alone and kept presenting the revoked one forever. Subscriptions now carry a url+token fingerprint and restart when it changes. - shadow rows seeded from the launch response status instead of a hardcoded "creating"; the peer's own upsert lands before the shadow exists. - qualified peer addresses are recognised by shape, not by "contains a colon". Agents named "fix: sse race" were being routed to a nonexistent instance. Efficiency and lifecycle: - whois memoized per remote address (30s, negatives uncached); auth folded to one query; last-seen writes throttled to once a minute. - outbox drains per peer, concurrently, abandoning a peer on first failure, with a dead-letter cap. One closed laptop no longer holds every peer's mail. - shadows are reaped when a snapshot stops mentioning them and when a peer is unlinked, instead of freezing at their last status. Also drops the unrelated pnpm-workspace shamefullyHoist change. --- apps/server/src/agents/manager.ts | 9 +- apps/server/src/db/migrations/0041_peers.sql | 38 ++- apps/server/src/peers/events.ts | Bin 7947 -> 11888 bytes apps/server/src/peers/launch.ts | 147 ++++++++- apps/server/src/peers/messages.ts | 106 +++++-- apps/server/src/peers/pairing.ts | 282 +++++++++++++++--- apps/server/src/peers/peer-auth.ts | 106 +++++-- apps/server/src/peers/status.ts | 23 ++ apps/server/src/routes/mcp.ts | 20 ++ apps/server/src/routes/peers.ts | 45 ++- apps/server/src/server.ts | 2 + apps/server/src/server/mcp-handlers.ts | 23 +- .../src/shared/mcp/agent-launch-tools.ts | 9 +- apps/server/src/shared/mcp/server.ts | 6 + 14 files changed, 710 insertions(+), 106 deletions(-) create mode 100644 apps/server/src/peers/status.ts diff --git a/apps/server/src/agents/manager.ts b/apps/server/src/agents/manager.ts index 7207fb1f..62bc3f74 100644 --- a/apps/server/src/agents/manager.ts +++ b/apps/server/src/agents/manager.ts @@ -1028,9 +1028,16 @@ export class AgentManager { const agent = await this.getRequiredAgent(id); if (agent.peerId) { // Shadow row: the pane lives on another instance and is not proxied. + // Name it the way every other surface does — "inst_9f2c62a1b0" means + // nothing to the person reading this. + const peer = await this.pool.query<{ name: string }>( + `SELECT name FROM peers WHERE id = $1`, + [agent.peerId] + ); + const label = peer.rows[0]?.name ?? agent.peerId; return { mode: "inert", - message: `This agent runs on linked instance "${agent.peerId}" — its terminal is not available here.`, + message: `This agent runs on linked instance "${label}" — its terminal is not available here.`, }; } if (agent.status !== "running" && agent.status !== "creating") { diff --git a/apps/server/src/db/migrations/0041_peers.sql b/apps/server/src/db/migrations/0041_peers.sql index 319d7d0c..36ec3154 100644 --- a/apps/server/src/db/migrations/0041_peers.sql +++ b/apps/server/src/db/migrations/0041_peers.sql @@ -6,7 +6,13 @@ CREATE TABLE IF NOT EXISTS peers ( id text PRIMARY KEY, -- the peer's instance_id (inst_*) - name text NOT NULL, -- display name shown in pickers + -- Two names, because they answer different questions. `reported_name` is what + -- the peer calls itself (its own instance_name, or hostname); `name` is what + -- THIS instance calls it, seeded from reported_name and editable here. The + -- local label is the one agents type as `location`, so "Cloud" can mean + -- different machines on different laptops without renaming anything remote. + name text NOT NULL, + reported_name text, url text NOT NULL, -- base URL we dial (MagicDNS or public) tailnet_stable_id text, -- the peer node's durable tailscale ID outbound_token text NOT NULL, -- bearer WE present to THEM @@ -15,13 +21,30 @@ CREATE TABLE IF NOT EXISTS peers ( revoked_at timestamptz ); +-- One peer per local label, so `location: "Cloud"` is never ambiguous. Partial +-- so a revoked peer's label is free for reuse. +CREATE UNIQUE INDEX IF NOT EXISTS peers_active_name_idx + ON peers (lower(name)) + WHERE revoked_at IS NULL; + -- Bearer tokens THEY present to US, plus the standing pair-time policy. +-- +-- Capabilities are a SET, not a flag. Pairing grants three separable things — +-- run code here, inject prompts into agents here, and do either with the +-- sandbox off — and a single boolean cannot describe them. Each route gates on +-- its own column, so a launch-only CI box or a message-only observer is a +-- policy row rather than a protocol version. CREATE TABLE IF NOT EXISTS peer_credentials ( id uuid PRIMARY KEY, peer_id text NOT NULL REFERENCES peers(id) ON DELETE CASCADE, token_hash text NOT NULL UNIQUE, tailnet_stable_id text, -- pinned caller identity; NULL only for non-tailnet pairings allow_launch boolean NOT NULL DEFAULT true, + allow_message boolean NOT NULL DEFAULT true, + -- Off by default even when launching is allowed: fullAccess disables the + -- sandbox, and "may launch here" should not silently mean "may launch + -- unsandboxed here". Opt in per pairing. + allow_full_access boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT now(), last_used_at timestamptz, revoked_at timestamptz @@ -36,6 +59,8 @@ CREATE TABLE IF NOT EXISTS peer_pairings ( id uuid PRIMARY KEY, code_hash text NOT NULL, allow_launch boolean NOT NULL DEFAULT true, + allow_message boolean NOT NULL DEFAULT true, + allow_full_access boolean NOT NULL DEFAULT false, require_tailnet boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz NOT NULL, @@ -71,12 +96,17 @@ CREATE TABLE IF NOT EXISTS peer_outbox ( next_attempt_at timestamptz NOT NULL DEFAULT now(), last_error text, created_at timestamptz NOT NULL DEFAULT now(), - delivered_at timestamptz + delivered_at timestamptz, + -- Retry is not forever. A peer that is merely gone — a machine nobody + -- explicitly unlinked — would otherwise accumulate rows that are re-attempted + -- until the end of time, and each attempt costs a 30s connect timeout. Past + -- the cap the row is dead-lettered: kept for inspection, never sent again. + dead_lettered_at timestamptz ); CREATE INDEX IF NOT EXISTS peer_outbox_due_idx - ON peer_outbox (next_attempt_at) - WHERE delivered_at IS NULL; + ON peer_outbox (peer_id, next_attempt_at) + WHERE delivered_at IS NULL AND dead_lettered_at IS NULL; CREATE TABLE IF NOT EXISTS peer_message_receipts ( peer_id text NOT NULL, diff --git a/apps/server/src/peers/events.ts b/apps/server/src/peers/events.ts index 56124db72879fcd5bd6fea04db45be434e2ff859..75ac09be5a4212853b522c5e754a4c9080befac7 100644 GIT binary patch delta 4246 zcmb7HOK%(36|Rt`v1vQb!_iyziQt5!Dh(CaKwPPmOGtDhz>*9}M$oE)e95^ary9;o z=3a^sY=P~ftD>(=7ulwZHk-mQv@N=CHtoJZUw@$e3H{E!!yzqOi)ulU_i^sI=ljlg z&i&cSe}DL^->uOJHR7;MvyM{|O&Mra(#Mfh&X3gWa?Wcj=e=MWA|~iKK1`O+L)RiWitOyIz2O)>=*Ku9S8>mhDi(B>@y##uMvd z9VmK%lCgHorV>75`AJ7Ay)FBm8i>~2et?Qg8*~(Uu0e=6zDkn`5S2|hoI%Ky)07tC zb+@?8N08T`f>~X(n`~6Sed)bq@wL&@KVA6y$lVPQnKPBs#wMULmrQ?y@9 z4K>1;3ik9p24Eet7O}tOZuIN~kHf332()xx^o6KYqHTEu3Sl$s0iQ^CF#-ybFrx$6 zCD5*9Smw|!aK+74CqD9yBrxsouxvB-0Dsr(=@uAjFd5<))myTDIABLN?D#GnN*PfU zO9k&4en4X)Wvp%8Ujqw3al(D&5w62Ly1n=TJS6^oY#G8FGZ@+sf3NVax%-nRCRNWS zT#?=_m(%zU`BzLaWt>bX8Jz!YaVqxz2H5tm%riqamR^3~VD-uA=;hBZUbO(#TLMzx zQaSmhPk;ImWB)t!f^fJCfAj>P+b|O|(mPB5E7o4PW53!tO z-3pqs>B5ww2EtWVpE`2HpoI;bd;R1BxE-(p^uuUCwKx=RT_B^7(Oa8UXsm)#R8q5` zYql|#kuQ$H!A1`kJj(^6$Z6D=R1q_WT_!`ifjv+js%4$(wnEk!vkc+Z;84z%HB2kJiK7O@-|v|PS+Fero?Aqs7+wH()hb}X zY6lr{X2Lx;pMm2zPGN{NxL35{DTVv*7(rD78jum~M-Yu{H`$w~sw0f*2moBnRuubR zGk=CoOM|@uneKD6xSV`3I@QY{1KAk+v|QA~cI2Z`e!D83J&VF%{Y zqCiLqH-z?VZtYSVfTs1f83vL<76=>>OZxYZe}fv#_Ip;_YSIsbrV$t@n>Fcs0m~8U zND$(9;0Pjp3gp4LkG;NzS+|c#;pnu`D9@EO5f4O3TZ3KLGAdS;51-BtbBy?wa#Hj@1T+9FTM z@7OHe+fRNyHhCe8BhXB`$&-uMlfRDLc`J3gOnIflD3WeI`PcZ3E1dBlb>4G*X+}P5 zOQnDl4^~XJoDCnVGvAd1Y|1lC2leD9*TAMPm;e(zA5Wm=iut7YTs~i-`(rrYVZyZ7&cdGZRtGf&M87w?}uvXcvf)UE-D((z>x{KEU zzj+qG+=yQt{gMu`0e%9+HCm3&pl4eHh%Q(3ej*PZ8`h%jNU}GIL#w_N+{PH}I)f;( z%k2m{j=};Ou*x&$+ySTH!9TWx`UAO(HgGlk@r7ca3=PiH$;w<`CmJFC^g&2OP(nip;I=EU3M0bZs^v%&OjPz_@| znS3~YwPzVa<&1pHEc>@w*{RaUA5}O150;@47+Sr=oH4q4#@w#XSW64^0&CARQ@sz@ zUdnx{ZmuP7P25X2=dB8U24TmA%rL`c^8fpr7Ra2SNi}X8-^I delta 1033 zcmZWoOKTHR6ed+{9ovGC(uXBIc{I+@Nl-VoDQyiUmVgg%(oJKOF>}+7CU=HAcN#%M ze}Egy5iPDRsLMp2zo{$340KV)fsC-y(a zkA4ir_UaaOI21QZcPbCHO0lx3m9zpBw+ZDM*SYIdYp?=U2_RKxMCT^mPN^PZE~S0o zaLcw&BVq)8gfWW+SDn>&%w6A51Ksgh_9B{PNou&X&P|I7V?ZwN>P?pvz~M|}hv(o@ zgEUM^4CR!_K?^(+@)+SRqyN;EH)*};8pH|GdJ>ifDpf=rz#2r>|BQu5K==L7huCBw zOoieji}7`fa%MG~gmqt?JvbwY-i8>jj?CfF*c8%{Om|WG5}Vctmu*6rlW*y^EZYjK ztb(044OB;GFGP_w5gN}aeo>x0vjK9lty4~n{EqJUIm(6-IRFTv;{opO@%X`cd^2_Z zTn9+gAsCun+_wL??pgMef zIfcK~c|4hmZ!yxcI1$XXFiAgeAUn`oHpJQoS6_McO3iCWPpm(No~IJ78;&i=SWS-Y zxwfHmqWOgvz5dEQSG7M#5KHa-uR^r!p|apz@qSrwsNNEPAhdd*XNp?l=?Pd31@Itk Y4TlIvGqZRoZ{V-ogAuQ79jPPz1 { + const roots = await listLocalRepos(pool); + if (roots.length === 0) { + throw new Error( + "This instance advertises no repositories, so it cannot accept remote launches." + ); + } + + let resolved: string; + try { + resolved = await fs.realpath(path.resolve(requested)); + } catch { + throw new Error(`Directory "${requested}" does not exist on this instance.`); + } + + for (const root of roots) { + let resolvedRoot: string; + try { + resolvedRoot = await fs.realpath(path.resolve(root.root)); + } catch { + continue; // A repo that has since been deleted cannot authorize anything. + } + const rel = path.relative(resolvedRoot, resolved); + const contained = + rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); + if (contained) return resolved; + } + + throw new Error( + `Directory "${requested}" is not inside a repository this instance shares. Available: ${roots + .map((r) => r.root) + .join(", ")}.` + ); +} /** * Everything a remote launch needs is explicit in this payload — the usual @@ -58,7 +106,8 @@ function buildRemoteChildInitialPrompt( */ export async function handleIncomingPeerLaunch( deps: { pool: Pool; agentManager: AgentManager }, - payload: PeerLaunchPayload + payload: PeerLaunchPayload, + policy: { allowFullAccess: boolean } ): Promise { const agentType = payload.type; if ( @@ -76,6 +125,21 @@ export async function handleIncomingPeerLaunch( agentType as (typeof CLI_AGENT_TYPES)[number], payload.model ); + + // The repos we advertise on /peers/repos are the repos we accept launches + // into. Without this the advisory list is decorative and a linked peer can + // start an agent anywhere the server process can read. + const cwd = await assertLaunchableCwd(deps.pool, payload.cwd); + + // fullAccess disables the sandbox, so it needs its own grant — "may launch + // here" must not silently mean "may launch unsandboxed here". + const fullAccess = payload.fullAccess ?? false; + if (fullAccess && !policy.allowFullAccess) { + throw new Error( + "This peer is not allowed to launch full-access agents here (pair-time policy)." + ); + } + const worktreeLocation = await getWorktreeLocation(deps.pool); const cliSessionId = agentType === "claude" ? randomUUID() : undefined; @@ -83,8 +147,8 @@ export async function handleIncomingPeerLaunch( cliSessionId, name: payload.name, type: agentType as AgentType, - cwd: payload.cwd, - fullAccess: payload.fullAccess ?? false, + cwd, + fullAccess, model, useWorktree: payload.useWorktree ?? false, createNewBranch: payload.createNewBranch ?? false, @@ -119,6 +183,71 @@ export async function listLocalRepos(pool: Pool): Promise { })); } +export type PeerLocation = { + /** The label to pass as `location` — what THIS instance calls the peer. */ + name: string; + instanceId: string; + reachable: boolean; + canLaunch: boolean; +}; + +/** + * The locations an agent here can target, for the MCP tool descriptions. + * + * Tool descriptions are the only place a model learns what exists, so listing + * the real labels ("Cloud", "Studio") is the difference between the model + * guessing and the model knowing. `handleMcpRequest` registers tools per + * request, so this is read fresh each time rather than frozen at boot. + */ +export async function listPeerLocations(pool: Pool): Promise { + const result = await pool.query<{ + id: string; + name: string; + last_seen_at: Date | null; + allow_launch: boolean | null; + }>( + `SELECT p.id, p.name, p.last_seen_at, c.allow_launch + FROM peers p + LEFT JOIN LATERAL ( + SELECT allow_launch FROM peer_credentials + WHERE peer_id = p.id AND revoked_at IS NULL + ORDER BY created_at DESC LIMIT 1 + ) c ON true + WHERE p.revoked_at IS NULL + ORDER BY p.name` + ); + const staleAfter = Date.now() - 5 * 60 * 1000; + return result.rows.map((row) => ({ + name: row.name, + instanceId: row.id, + reachable: (row.last_seen_at?.getTime() ?? 0) > staleAfter, + canLaunch: row.allow_launch ?? false, + })); +} + +/** + * One line naming every linked instance, appended to the `location` parameter + * description. Empty string when nothing is linked, so the sentence reads + * naturally on the overwhelmingly common single-instance setup. + */ +export function describePeerLocations(locations: PeerLocation[]): string { + if (locations.length === 0) { + return " No instances are currently linked, so omit this and launch locally."; + } + const rendered = locations + .map((loc) => { + const notes = [ + loc.reachable ? null : "not responding recently", + loc.canLaunch ? null : "launching not permitted there", + ].filter(Boolean); + return notes.length > 0 + ? `"${loc.name}" (${notes.join("; ")})` + : `"${loc.name}"`; + }) + .join(", "); + return ` Linked instances: ${rendered}. Omit to launch on this machine.`; +} + export type ResolvedPeer = { id: string; name: string; @@ -207,6 +336,8 @@ export async function launchAgentOnPeer( pool: Pool; agentManager: AgentManager; fetchImpl?: typeof fetch; + /** Reconciles anything missed in the launch/insert window. */ + requestPeerResnapshot?: (peerId: string) => void; }, peer: ResolvedPeer, input: { @@ -253,15 +384,23 @@ export async function launchAgentOnPeer( } const result = (await response.json()) as PeerLaunchResult; + // Seed from the status the peer just reported, not a hardcoded "creating". + // The peer publishes its own agent.upsert before this HTTP call returns, so + // that event lands before the shadow exists and the mirror drops it — leaving + // a hardcoded "creating" to sit there until the agent's NEXT status change, + // which for a long-running agent may be never. const shadow = await deps.agentManager.createShadowAgent({ peerId: peer.id, remoteId: result.agentId, name: result.name, type: input.type as AgentType, cwd: input.cwd, - status: "creating", + status: asAgentStatus(result.status) ?? "creating", parentAgentId: input.parentAgentId, }); + // Belt and braces for the same race: ask the subscriber to re-snapshot this + // peer so any transition we missed between launch and insert is reconciled. + deps.requestPeerResnapshot?.(peer.id); return { shadowAgentId: shadow.id, remoteAgentId: result.agentId, diff --git a/apps/server/src/peers/messages.ts b/apps/server/src/peers/messages.ts index b468d28a..e592476a 100644 --- a/apps/server/src/peers/messages.ts +++ b/apps/server/src/peers/messages.ts @@ -7,6 +7,14 @@ const MAX_BACKOFF_MS = 15 * 60 * 1000; const BASE_BACKOFF_MS = 30 * 1000; const DRAIN_INTERVAL_MS = 30 * 1000; const RECEIPT_RETENTION_DAYS = 30; +/** + * Backoff saturates at 15 minutes, so ~40 attempts is roughly half a day of + * trying. Past that the peer is not "temporarily away", it is gone, and a row + * that retries forever costs a 30s connect timeout every drain. + */ +const MAX_DELIVERY_ATTEMPTS = 40; +/** Rows fetched per drain, per peer. Bounds one bad peer's share of the pass. */ +const DRAIN_BATCH_PER_PEER = 25; export type PeerMessageBody = { targetAgentId: string; @@ -99,8 +107,17 @@ export class PeerMessenger { return { delivered }; } - private async attempt(row: OutboxRow): Promise { - const peer = await loadPeerDialInfo(this.deps.pool, row.peer_id); + /** + * Deliver one row. `peer` is passed in when the caller already loaded it — + * draining a backlog of 100 rows for one peer should not re-read the same + * peer row 100 times. + */ + private async attempt( + row: OutboxRow, + prefetchedPeer?: PeerDialInfo + ): Promise { + const peer = + prefetchedPeer ?? (await loadPeerDialInfo(this.deps.pool, row.peer_id)); if (!peer) { // Peer was revoked with mail still queued — drop it, there is no one to // deliver to and retrying forever would hold the queue open. @@ -123,45 +140,96 @@ export class PeerMessenger { BASE_BACKOFF_MS * 2 ** Math.min(attempts, 20), MAX_BACKOFF_MS ); + const message = + error instanceof Error ? error.message.slice(0, 2_000) : "send failed"; + const exhausted = attempts >= MAX_DELIVERY_ATTEMPTS; await this.deps.pool.query( `UPDATE peer_outbox SET attempts = $2, next_attempt_at = now() + ($3 || ' milliseconds')::interval, - last_error = $4 + last_error = $4, + dead_lettered_at = CASE WHEN $5 THEN now() ELSE dead_lettered_at END WHERE id = $1`, - [ - row.id, - attempts, - String(backoff), - error instanceof Error - ? error.message.slice(0, 2_000) - : "send failed", - ] + [row.id, attempts, String(backoff), message, exhausted] ); + if (exhausted) { + this.deps.log.warn( + { peerId: row.peer_id, outboxId: row.id, attempts, err: message }, + "Peer message dead-lettered after exhausting delivery attempts" + ); + } return false; } } - /** Deliver every due message; called on a timer and after reconnects. */ + /** + * Deliver every due message; called on a timer and after reconnects. + * + * Peers drain CONCURRENTLY and each peer stops at its first transport + * failure. Serial delivery across peers meant one unreachable host — a closed + * laptop, the exact case the outbox exists for — held the mutex for a 30s + * timeout per row, starving every other peer's mail behind it. + */ async drain(): Promise { if (this.draining) return; this.draining = true; try { - const due = await this.deps.pool.query( - `SELECT id, peer_id, path, body, attempts - FROM peer_outbox - WHERE delivered_at IS NULL AND next_attempt_at <= now() - ORDER BY created_at - LIMIT 100` + const due = await this.deps.pool.query( + `SELECT o.id, o.peer_id, o.path, o.body, o.attempts, p.url, p.outbound_token + FROM peer_outbox o + JOIN peers p ON p.id = o.peer_id AND p.revoked_at IS NULL + WHERE o.delivered_at IS NULL + AND o.dead_lettered_at IS NULL + AND o.next_attempt_at <= now() + ORDER BY o.peer_id, o.created_at` ); + + const byPeer = new Map(); for (const row of due.rows) { - await this.attempt(row); + const queue = byPeer.get(row.peer_id); + if (queue) queue.push(row); + else byPeer.set(row.peer_id, [row]); } + + await Promise.all( + [...byPeer.values()].map(async (queue) => { + const peer: PeerDialInfo = { + url: queue[0].url, + outbound_token: queue[0].outbound_token, + }; + // Ordered within a peer, and abandoned on the first failure: if this + // host is not answering, the remaining rows will fail identically and + // each costs a full connect timeout. + for (const row of queue.slice(0, DRAIN_BATCH_PER_PEER)) { + const ok = await this.attempt(row, peer); + if (!ok) break; + } + }) + ); + + // The due query joins live peers only, so a revoked peer's queue would + // never be visited and never age out. Tombstone it here instead. + await this.deps.pool.query( + `UPDATE peer_outbox o + SET delivered_at = now(), last_error = 'peer revoked' + WHERE o.delivered_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM peers p + WHERE p.id = o.peer_id AND p.revoked_at IS NULL + )` + ); await this.deps.pool.query( `DELETE FROM peer_outbox WHERE delivered_at IS NOT NULL AND delivered_at < now() - interval '7 days'` ); + // Dead letters are kept longer than delivered rows: they are the record + // of mail that never arrived, which is the kind someone comes looking for. + await this.deps.pool.query( + `DELETE FROM peer_outbox + WHERE dead_lettered_at IS NOT NULL + AND dead_lettered_at < now() - interval '30 days'` + ); await this.deps.pool.query( `DELETE FROM peer_message_receipts WHERE received_at < now() - interval '${RECEIPT_RETENTION_DAYS} days'` diff --git a/apps/server/src/peers/pairing.ts b/apps/server/src/peers/pairing.ts index cef2db85..429950c4 100644 --- a/apps/server/src/peers/pairing.ts +++ b/apps/server/src/peers/pairing.ts @@ -17,15 +17,86 @@ export const PAIRING_TTL_MS = 10 * 60 * 1000; */ export const PEER_PROTOCOL_VERSION = 1; +/** + * What a pairing grants. Three separable powers, because "may launch here" and + * "may launch here with the sandbox off" are not the same permission, and + * messaging an agent is not launching one. + */ +export type PeerCapabilities = { + allowLaunch: boolean; + allowMessage: boolean; + allowFullAccess: boolean; +}; + +export const DEFAULT_CAPABILITIES: PeerCapabilities = { + allowLaunch: true, + allowMessage: true, + allowFullAccess: false, +}; + export type PeerRecord = { id: string; + /** What THIS instance calls the peer — the label agents pass as `location`. */ name: string; + /** What the peer calls itself. Kept so the UI can show a rename happened. */ + reportedName: string | null; url: string; tailnetStableId: string | null; createdAt: string; lastSeenAt: string | null; - allowLaunch: boolean; -}; +} & PeerCapabilities; + +/** + * Local labels are what agents type as `location`, so they must be unique and + * stable. Collisions get a numeric suffix rather than an error — pairing should + * not fail because two machines are both called "macbook". + */ +async function uniqueLocalLabel( + client: { query: Pool["query"] }, + desired: string, + selfPeerId: string +): Promise { + const base = desired.trim().slice(0, 120) || "instance"; + for (let n = 0; n < 50; n += 1) { + const candidate = n === 0 ? base : `${base}-${n + 1}`; + const clash = await client.query( + `SELECT 1 FROM peers + WHERE lower(name) = lower($1) AND id <> $2 AND revoked_at IS NULL`, + [candidate, selfPeerId] + ); + if (clash.rowCount === 0) return candidate; + } + return `${base}-${selfPeerId.slice(-6)}`; +} + +/** + * Guards the peers row against an identity takeover. `instanceId` is asserted by + * the caller and never proven, so an upsert keyed on it alone would let anyone + * holding a valid pairing code repoint an EXISTING peer's url and token at + * themselves. A live row pinned to a different tailnet node must be unlinked + * deliberately, by a human, before its slot can be reused. + */ +async function assertNotSlotTakeover( + client: { query: Pool["query"] }, + instanceId: string, + callerStableId: string | null +): Promise<{ ok: true } | { ok: false; status: number; error: string }> { + const existing = await client.query<{ tailnet_stable_id: string | null }>( + `SELECT tailnet_stable_id FROM peers WHERE id = $1 AND revoked_at IS NULL`, + [instanceId] + ); + const row = existing.rows[0]; + if (!row) return { ok: true }; + if (row.tailnet_stable_id && row.tailnet_stable_id !== callerStableId) { + return { + ok: false, + status: 409, + error: + "An instance with this id is already linked from a different machine. Unlink it here before pairing again.", + }; + } + return { ok: true }; +} function sha256(value: string): string { return crypto.createHash("sha256").update(value).digest("hex"); @@ -51,19 +122,26 @@ export async function cleanupExpiredPairings(pool: Pool): Promise { /** Acceptor side: create an offer whose code is displayed in THIS instance's UI. */ export async function createPairingOffer( pool: Pool, - input: { allowLaunch: boolean; requireTailnet: boolean } + input: Partial & { requireTailnet: boolean } ): Promise<{ pairingId: string; code: string; expiresAt: string }> { await cleanupExpiredPairings(pool); + // Defaulted here as well as at the route so a missing capability is never + // written as NULL — the columns are NOT NULL, and a caller that omits one + // means "the default", not "no policy". + const caps = { ...DEFAULT_CAPABILITIES, ...input }; const pairingId = crypto.randomUUID(); const code = pairingCode(); const expiresAt = new Date(Date.now() + PAIRING_TTL_MS); await pool.query( - `INSERT INTO peer_pairings (id, code_hash, allow_launch, require_tailnet, expires_at) - VALUES ($1, $2, $3, $4, $5)`, + `INSERT INTO peer_pairings + (id, code_hash, allow_launch, allow_message, allow_full_access, require_tailnet, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, [ pairingId, sha256(code), - input.allowLaunch, + caps.allowLaunch, + caps.allowMessage, + caps.allowFullAccess, input.requireTailnet, expiresAt, ] @@ -103,9 +181,11 @@ export async function claimPairing( id: string; code_hash: string; allow_launch: boolean; + allow_message: boolean; + allow_full_access: boolean; require_tailnet: boolean; }>( - `SELECT id, code_hash, allow_launch, require_tailnet + `SELECT id, code_hash, allow_launch, allow_message, allow_full_access, require_tailnet FROM peer_pairings WHERE expires_at > now() AND claimed_at IS NULL` ); @@ -143,14 +223,32 @@ export async function claimPairing( await client.query("ROLLBACK"); return { ok: false, status: 409, error: "Code was already used." }; } + const guard = await assertNotSlotTakeover( + client, + input.claimer.instanceId, + callerStableId + ); + if (!guard.ok) { + await client.query("ROLLBACK"); + return guard; + } + // The local label is ours to choose and is preserved across re-pairs: a peer + // renamed to "Cloud" here stays "Cloud" when it pairs again, even if its own + // hostname changed. Only reported_name follows the remote. + const label = await uniqueLocalLabel( + client, + input.claimer.name, + input.claimer.instanceId + ); await client.query( - `INSERT INTO peers (id, name, url, tailnet_stable_id, outbound_token, last_seen_at) - VALUES ($1, $2, $3, $4, $5, now()) + `INSERT INTO peers (id, name, reported_name, url, tailnet_stable_id, outbound_token, last_seen_at) + VALUES ($1, $2, $3, $4, $5, $6, now()) ON CONFLICT (id) DO UPDATE - SET name = $2, url = $3, tailnet_stable_id = $4, - outbound_token = $5, last_seen_at = now(), revoked_at = NULL`, + SET reported_name = $3, url = $4, tailnet_stable_id = $5, + outbound_token = $6, last_seen_at = now(), revoked_at = NULL`, [ input.claimer.instanceId, + label, input.claimer.name, input.claimer.url, callerStableId, @@ -164,14 +262,17 @@ export async function claimPairing( [input.claimer.instanceId] ); await client.query( - `INSERT INTO peer_credentials (id, peer_id, token_hash, tailnet_stable_id, allow_launch) - VALUES ($1, $2, $3, $4, $5)`, + `INSERT INTO peer_credentials + (id, peer_id, token_hash, tailnet_stable_id, allow_launch, allow_message, allow_full_access) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, [ crypto.randomUUID(), input.claimer.instanceId, sha256(inboundToken), callerStableId, offer.allow_launch, + offer.allow_message, + offer.allow_full_access, ] ); await client.query(`UPDATE peer_pairings SET peer_id = $2 WHERE id = $1`, [ @@ -190,12 +291,12 @@ export async function claimPairing( return { ok: true, instanceId, name: instanceName, token: inboundToken }; } -export type LinkInput = { +export type LinkInput = Partial & { /** Address of the accepting instance, e.g. cloud-vm.tailnet.ts.net:6767 */ address: string; code: string; - /** Whether the linked peer may launch agents HERE (the reverse policy). */ - allowLaunch: boolean; + /** Local label for the peer, e.g. "Cloud". Defaults to what it calls itself. */ + name?: string; /** Override for how the peer dials us back (needed off-tailnet). */ selfUrl?: string; }; @@ -239,6 +340,7 @@ export async function linkToPeer( ): Promise { const doFetch = deps.fetchImpl ?? fetch; const peerUrl = normalizePeerUrl(input.address); + const caps = { ...DEFAULT_CAPABILITIES, ...input }; let selfUrl = input.selfUrl ?? null; if (!selfUrl) { @@ -309,18 +411,35 @@ export async function linkToPeer( } const peerStableId = await whoisOfUrl(peerUrl); + const reportedName = body.name ?? new URL(peerUrl).hostname; + // The user may name the peer at link time ("Cloud"); otherwise adopt what it + // calls itself. + const desiredLabel = input.name?.trim() || reportedName; + let label = desiredLabel; + const client = await pool.connect(); try { await client.query("BEGIN"); + const guard = await assertNotSlotTakeover( + client, + body.instanceId, + peerStableId + ); + if (!guard.ok) { + await client.query("ROLLBACK"); + return guard; + } + label = await uniqueLocalLabel(client, desiredLabel, body.instanceId); await client.query( - `INSERT INTO peers (id, name, url, tailnet_stable_id, outbound_token, last_seen_at) - VALUES ($1, $2, $3, $4, $5, now()) + `INSERT INTO peers (id, name, reported_name, url, tailnet_stable_id, outbound_token, last_seen_at) + VALUES ($1, $2, $3, $4, $5, $6, now()) ON CONFLICT (id) DO UPDATE - SET name = $2, url = $3, tailnet_stable_id = $4, - outbound_token = $5, last_seen_at = now(), revoked_at = NULL`, + SET reported_name = $3, url = $4, tailnet_stable_id = $5, + outbound_token = $6, last_seen_at = now(), revoked_at = NULL`, [ body.instanceId, - body.name ?? new URL(peerUrl).hostname, + label, + reportedName, peerUrl, peerStableId, body.token, @@ -332,14 +451,17 @@ export async function linkToPeer( [body.instanceId] ); await client.query( - `INSERT INTO peer_credentials (id, peer_id, token_hash, tailnet_stable_id, allow_launch) - VALUES ($1, $2, $3, $4, $5)`, + `INSERT INTO peer_credentials + (id, peer_id, token_hash, tailnet_stable_id, allow_launch, allow_message, allow_full_access) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, [ crypto.randomUUID(), body.instanceId, sha256(reverseToken), peerStableId, - input.allowLaunch, + caps.allowLaunch, + caps.allowMessage, + caps.allowFullAccess, ] ); await client.query("COMMIT"); @@ -350,31 +472,65 @@ export async function linkToPeer( client.release(); } - return { - ok: true, - peer: { - id: body.instanceId, - name: body.name ?? new URL(peerUrl).hostname, - url: peerUrl, - }, - }; + return { ok: true, peer: { id: body.instanceId, name: label, url: peerUrl } }; +} + +/** + * Rename a linked peer locally. Purely a local label — the remote instance is + * never told, because "Cloud" is a statement about where it sits relative to + * THIS machine, not about what it is. + */ +export async function renamePeer( + pool: Pool, + peerId: string, + name: string +): Promise<{ ok: true; name: string } | { ok: false; status: number; error: string }> { + const trimmed = name.trim(); + if (!trimmed) { + return { ok: false, status: 400, error: "Name cannot be empty." }; + } + const clash = await pool.query( + `SELECT 1 FROM peers + WHERE lower(name) = lower($1) AND id <> $2 AND revoked_at IS NULL`, + [trimmed, peerId] + ); + if (clash.rowCount && clash.rowCount > 0) { + return { + ok: false, + status: 409, + error: `Another linked instance is already called "${trimmed}".`, + }; + } + const updated = await pool.query( + `UPDATE peers SET name = $2 WHERE id = $1 AND revoked_at IS NULL`, + [peerId, trimmed] + ); + if (updated.rowCount === 0) { + return { ok: false, status: 404, error: "Peer not found." }; + } + return { ok: true, name: trimmed }; } export async function listPeers(pool: Pool): Promise { const result = await pool.query<{ id: string; name: string; + reported_name: string | null; url: string; tailnet_stable_id: string | null; created_at: Date; last_seen_at: Date | null; allow_launch: boolean | null; + allow_message: boolean | null; + allow_full_access: boolean | null; }>( - `SELECT p.id, p.name, p.url, p.tailnet_stable_id, p.created_at, p.last_seen_at, - c.allow_launch + `SELECT p.id, p.name, p.reported_name, p.url, p.tailnet_stable_id, + p.created_at, p.last_seen_at, + c.allow_launch, c.allow_message, c.allow_full_access FROM peers p LEFT JOIN LATERAL ( - SELECT allow_launch FROM peer_credentials + SELECT allow_launch, allow_message, allow_full_access + FROM peer_credentials WHERE peer_id = p.id AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1 ) c ON true @@ -384,25 +540,57 @@ export async function listPeers(pool: Pool): Promise { return result.rows.map((row) => ({ id: row.id, name: row.name, + reportedName: row.reported_name, url: row.url, tailnetStableId: row.tailnet_stable_id, createdAt: row.created_at.toISOString(), lastSeenAt: row.last_seen_at?.toISOString() ?? null, allowLaunch: row.allow_launch ?? false, + allowMessage: row.allow_message ?? false, + allowFullAccess: row.allow_full_access ?? false, })); } -/** Revoke a peer locally: kill their inbound credentials and our outbound use. */ +/** + * Revoke a peer locally: kill their inbound credentials, stop our outbound use, + * and retire the shadow rows they were driving. Without that last step the + * shadows survive as agents nobody is mirroring — permanently frozen at + * whatever status they held when the link died. + */ export async function revokePeer(pool: Pool, peerId: string): Promise { - const result = await pool.query( - `UPDATE peers SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`, - [peerId] - ); - if (result.rowCount === 0) return false; - await pool.query( - `UPDATE peer_credentials SET revoked_at = now() - WHERE peer_id = $1 AND revoked_at IS NULL`, - [peerId] - ); - return true; + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const result = await client.query( + `UPDATE peers SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`, + [peerId] + ); + if (result.rowCount === 0) { + await client.query("ROLLBACK"); + return false; + } + await client.query( + `UPDATE peer_credentials SET revoked_at = now() + WHERE peer_id = $1 AND revoked_at IS NULL`, + [peerId] + ); + await client.query( + `UPDATE agents + SET status = 'stopped', + latest_event_type = 'idle', + latest_event_message = 'Linked instance was unlinked.', + latest_event_updated_at = now(), + updated_at = now() + WHERE peer_id = $1 AND deleted_at IS NULL + AND status IN ('creating', 'running', 'stopping')`, + [peerId] + ); + await client.query("COMMIT"); + return true; + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + client.release(); + } } diff --git a/apps/server/src/peers/peer-auth.ts b/apps/server/src/peers/peer-auth.ts index 5babf14f..55139d0f 100644 --- a/apps/server/src/peers/peer-auth.ts +++ b/apps/server/src/peers/peer-auth.ts @@ -3,15 +3,55 @@ import crypto from "node:crypto"; import type { FastifyReply, FastifyRequest } from "fastify"; import type { Pool } from "pg"; -import { getSetting } from "../db/settings.js"; import { tailscaleWhois } from "./tailscale.js"; export type PeerAuth = { peerId: string; credentialId: string; allowLaunch: boolean; + allowMessage: boolean; + allowFullAccess: boolean; }; +/** + * A node's StableID cannot change between two requests from the same address, + * but resolving it costs a `tailscale whois` subprocess with a 10s timeout — + * once per authenticated request, on routes rate-limited at 120/min. Cache the + * positive answers briefly. + * + * Negative results are deliberately NOT cached: an unidentifiable caller is a + * hard deny, and caching that would let a transient tailscaled hiccup lock out + * a legitimate peer for the whole TTL. + */ +const WHOIS_TTL_MS = 30_000; +const WHOIS_CACHE_MAX = 256; +const whoisCache = new Map(); + +async function cachedWhoisStableId(addr: string): Promise { + const hit = whoisCache.get(addr); + if (hit && hit.expiresAt > Date.now()) return hit.stableId; + if (hit) whoisCache.delete(addr); + + const whois = await tailscaleWhois(addr); + if (!whois) return null; + + // Cheap bound: the map is insertion-ordered, so the first key is the oldest. + if (whoisCache.size >= WHOIS_CACHE_MAX) { + const oldest = whoisCache.keys().next().value; + if (oldest !== undefined) whoisCache.delete(oldest); + } + whoisCache.set(addr, { + stableId: whois.stableId, + expiresAt: Date.now() + WHOIS_TTL_MS, + }); + return whois.stableId; +} + +/** Test-only: forget cached whois answers. */ +export function resetPeerWhoisCache(): void { + whoisCache.clear(); +} + declare module "fastify" { interface FastifyContextConfig { /** Route authenticates with its own peer bearer token in a preHandler. */ @@ -46,30 +86,29 @@ export async function requirePeerAuth( request: FastifyRequest, reply: FastifyReply ): Promise { - // Fail closed if the password was cleared after pairing: first-run open - // mode must never extend to peers, even ones holding a valid token. - if ((await getSetting(pool, "password_hash")) === null) { - await reply - .code(403) - .send({ - error: "This instance has no password set — peer access is disabled.", - }); - return; - } - const token = bearerToken(request); if (!token) { await reply.code(401).send({ error: "Peer authentication required." }); return; } + // One query does the credential lookup AND the fail-closed password check: + // first-run open mode must never extend to peers, even ones holding a valid + // token, so a cleared password denies here rather than falling through. const result = await pool.query<{ id: string; peer_id: string; tailnet_stable_id: string | null; allow_launch: boolean; + allow_message: boolean; + allow_full_access: boolean; + password_set: boolean; }>( - `SELECT c.id, c.peer_id, c.tailnet_stable_id, c.allow_launch + `SELECT c.id, c.peer_id, c.tailnet_stable_id, + c.allow_launch, c.allow_message, c.allow_full_access, + EXISTS ( + SELECT 1 FROM settings WHERE key = 'password_hash' AND value <> '' + ) AS password_set FROM peer_credentials c JOIN peers p ON p.id = c.peer_id AND p.revoked_at IS NULL WHERE c.token_hash = $1 AND c.revoked_at IS NULL`, @@ -80,14 +119,20 @@ export async function requirePeerAuth( await reply.code(401).send({ error: "Invalid or revoked peer token." }); return; } + if (!row.password_set) { + await reply.code(403).send({ + error: "This instance has no password set — peer access is disabled.", + }); + return; + } if (row.tailnet_stable_id) { const remote = request.socket.remoteAddress; const port = request.socket.remotePort; - const whois = remote - ? await tailscaleWhois(`${stripMapped(remote)}:${port ?? 0}`) + const stableId = remote + ? await cachedWhoisStableId(`${stripMapped(remote)}:${port ?? 0}`) : null; - if (!whois || whois.stableId !== row.tailnet_stable_id) { + if (!stableId || stableId !== row.tailnet_stable_id) { await reply .code(403) .send({ error: "Caller does not match the paired tailnet node." }); @@ -95,17 +140,32 @@ export async function requirePeerAuth( } } - await pool.query( - `UPDATE peer_credentials SET last_used_at = now() WHERE id = $1`, - [row.id] - ); - await pool.query(`UPDATE peers SET last_seen_at = now() WHERE id = $1`, [ - row.peer_id, - ]); + // Last-seen timestamps are telemetry, not correctness — nothing reads them to + // make a decision. Writing them per request costs two row writes on the + // hottest peer path, so only refresh once a minute. + void pool + .query( + `UPDATE peer_credentials SET last_used_at = now() + WHERE id = $1 + AND (last_used_at IS NULL OR last_used_at < now() - interval '1 minute')`, + [row.id] + ) + .catch(() => undefined); + void pool + .query( + `UPDATE peers SET last_seen_at = now() + WHERE id = $1 + AND (last_seen_at IS NULL OR last_seen_at < now() - interval '1 minute')`, + [row.peer_id] + ) + .catch(() => undefined); + request.peerAuth = { peerId: row.peer_id, credentialId: row.id, allowLaunch: row.allow_launch, + allowMessage: row.allow_message, + allowFullAccess: row.allow_full_access, }; } diff --git a/apps/server/src/peers/status.ts b/apps/server/src/peers/status.ts new file mode 100644 index 00000000..9a890ac8 --- /dev/null +++ b/apps/server/src/peers/status.ts @@ -0,0 +1,23 @@ +import type { AgentStatus } from "../agents/types.js"; + +const AGENT_STATUSES = [ + "creating", + "running", + "stopping", + "stopped", + "archiving", + "error", + "unknown", +] as const satisfies readonly AgentStatus[]; + +/** + * Narrow a status string that came off the wire. A peer runs its own build and + * may know statuses this one does not, so an unrecognized value is dropped + * rather than written through — a shadow with a bogus status would break every + * consumer that switches on it. + */ +export function asAgentStatus(value: string | undefined): AgentStatus | undefined { + return AGENT_STATUSES.includes(value as (typeof AGENT_STATUSES)[number]) + ? (value as AgentStatus) + : undefined; +} diff --git a/apps/server/src/routes/mcp.ts b/apps/server/src/routes/mcp.ts index ff89ad64..fad05570 100644 --- a/apps/server/src/routes/mcp.ts +++ b/apps/server/src/routes/mcp.ts @@ -15,6 +15,10 @@ import { resolveRepoRoot, resolveWorktreeRoot, } from "../shared/git/git-context.js"; +import { + describePeerLocations, + listPeerLocations, +} from "../peers/launch.js"; import type { CrudToolCallbacks } from "../shared/mcp/crud-tools.js"; import { handleMcpRequest } from "../shared/mcp/server.js"; @@ -190,8 +194,16 @@ export async function registerMcpRoutes( } : undefined; + // Read fresh per request: tool descriptions are the only place a model + // learns which machines it can target, and a linked instance can be added, + // renamed, or unlinked between two calls. + const peerLocationHint = describePeerLocations( + await listPeerLocations(deps.pool).catch(() => []) + ); + reply.hijack(); await handleMcpRequest(request.raw, reply.raw, request.body, { + peerLocationHint, agent: { id: agent.id, cwd: agent.cwd, @@ -284,8 +296,16 @@ export async function registerMcpRoutes( worktreeRoot = await resolveWorktreeRoot(agent.cwd); } catch {} + // Read fresh per request: tool descriptions are the only place a model + // learns which machines it can target, and a linked instance can be added, + // renamed, or unlinked between two calls. + const peerLocationHint = describePeerLocations( + await listPeerLocations(deps.pool).catch(() => []) + ); + reply.hijack(); await handleMcpRequest(request.raw, reply.raw, request.body, { + peerLocationHint, agent: { id: agent.id, cwd: agent.cwd, diff --git a/apps/server/src/routes/peers.ts b/apps/server/src/routes/peers.ts index 8e9b92a3..dc6282f6 100644 --- a/apps/server/src/routes/peers.ts +++ b/apps/server/src/routes/peers.ts @@ -15,6 +15,7 @@ import { linkToPeer, listPeers, PEER_PROTOCOL_VERSION, + renamePeer, revokePeer, } from "../peers/pairing.js"; import { setTailnetBindEnabled } from "../peers/peer-settings.js"; @@ -27,9 +28,15 @@ const TailnetBindBodySchema = z.object({ const PairingOfferBodySchema = z.object({ allowLaunch: z.boolean().default(true), + allowMessage: z.boolean().default(true), + allowFullAccess: z.boolean().default(false), requireTailnet: z.boolean().default(true), }); +const PeerRenameBodySchema = z.object({ + name: z.string().trim().min(1).max(120), +}); + const ClaimBodySchema = z.object({ protocolVersion: z.number().int().optional(), code: z.string().trim().min(6).max(12), @@ -44,7 +51,10 @@ const ClaimBodySchema = z.object({ const LinkBodySchema = z.object({ address: z.string().trim().min(1).max(4_096), code: z.string().trim().min(6).max(12), + name: z.string().trim().min(1).max(120).optional(), allowLaunch: z.boolean().default(true), + allowMessage: z.boolean().default(true), + allowFullAccess: z.boolean().default(false), selfUrl: z.string().trim().max(4_096).optional(), }); @@ -100,7 +110,13 @@ export async function registerPeerRoutes( deps: PeerRouteDeps ): Promise { app.get("/api/v1/peers/self", async () => { - return await deps.peerRuntime.selfStatus(); + const [status, name] = await Promise.all([ + deps.peerRuntime.selfStatus(), + instanceDisplayName(deps.pool), + ]); + // The name a peer will adopt as its local label when it pairs with us, so + // the pairing card can show what the other side is about to see. + return { ...status, name }; }); app.post("/api/v1/peers/settings/tailnet-bind", async (request, reply) => { @@ -243,7 +259,8 @@ export async function registerPeerRoutes( try { const result = await handleIncomingPeerLaunch( { pool: deps.pool, agentManager: deps.agentManager }, - input + input, + { allowFullAccess: request.peerAuth!.allowFullAccess } ); const agent = await deps.agentManager.getAgent(result.agentId); if (agent) { @@ -276,6 +293,15 @@ export async function registerPeerRoutes( async (request, reply) => { const input = parseInput(PeerMessageBodySchema, request.body, reply); if (!input) return; + // Messaging is its own capability. Injecting a prompt into a full-access + // agent is code execution by a slower route, so a peer told it may not + // launch here must not reach agents here either. + if (!request.peerAuth!.allowMessage) { + return reply.code(403).send({ + error: + "This peer is not allowed to message agents here (pair-time policy).", + }); + } const result = await receivePeerMessage( { pool: deps.pool, injectAgentPrompt: deps.injectAgentPrompt }, request.peerAuth!.peerId, @@ -397,6 +423,21 @@ export async function registerPeerRoutes( } ); + // Rename a linked instance locally. "Cloud" is a statement about where the + // peer sits relative to THIS machine, so the remote is never told — and the + // label is what agents pass as `location`. + app.patch("/api/v1/peers/:id", async (request, reply) => { + const params = parseInput(PeerParamsSchema, request.params, reply); + if (!params) return; + const body = parseInput(PeerRenameBodySchema, request.body, reply); + if (!body) return; + const result = await renamePeer(deps.pool, params.id, body.name); + if (!result.ok) { + return reply.code(result.status).send({ error: result.error }); + } + return { peer: { id: params.id, name: result.name } }; + }); + app.delete("/api/v1/peers/:id", async (request, reply) => { const params = parseInput(PeerParamsSchema, request.params, reply); if (!params) return; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 5e555f23..e9a3aadc 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -459,6 +459,8 @@ const mcpHandlers = createMcpHandlers({ appLog: app.log, sendPeerPrompt: (peerId, targetAgentId, prompt) => peerMessenger.sendPrompt(peerId, { targetAgentId, prompt }), + requestPeerResnapshot: (peerId) => + peerEventSubscriber.requestResnapshot(peerId), }); const jobTerminalStatuses = new Set([ "completed", diff --git a/apps/server/src/server/mcp-handlers.ts b/apps/server/src/server/mcp-handlers.ts index c0e0f9fc..bc9f9e72 100644 --- a/apps/server/src/server/mcp-handlers.ts +++ b/apps/server/src/server/mcp-handlers.ts @@ -106,6 +106,8 @@ type CreateMcpHandlersDeps = { targetAgentId: string, prompt: string ) => Promise<{ delivered: boolean }>; + /** Re-snapshot a peer after minting a shadow, to close the launch race. */ + requestPeerResnapshot?: (peerId: string) => void; }; function normalizePersonalityDuplicateName(error: unknown): never { @@ -598,7 +600,11 @@ async function handleLaunchAgentOnPeer( } const result = await launchAgentOnPeer( - { pool: deps.pool, agentManager: deps.agentManager }, + { + pool: deps.pool, + agentManager: deps.agentManager, + requestPeerResnapshot: deps.requestPeerResnapshot, + }, resolved.peer, { name: input.name, @@ -796,10 +802,17 @@ async function handleSendMessage( // Qualified address (":") — an agent on a linked instance // that has no shadow row here. Forward through the peer outbox; the peer's // own injector delivers it. - if (!input.target.startsWith("agt_") && input.target.includes(":")) { - const colon = input.target.indexOf(":"); - const location = input.target.slice(0, colon); - const remoteAgentId = input.target.slice(colon + 1); + // A qualified address is ":". Recognising it on the mere + // presence of a colon was wrong: targets may be agent NAMES, and this repo + // names agents things like "fix: sse race", which then failed with + // `Unknown location "fix"`. Both SIDES have to look right — either an + // explicit inst_ prefix, or a label followed by something agent-id shaped. + const qualified = + /^(inst_[0-9a-f]+):(.+)$/i.exec(input.target) ?? + /^([^:]+):(agt_[0-9a-z]+)$/i.exec(input.target); + if (qualified) { + const location = qualified[1]; + const remoteAgentId = qualified[2]; const resolved = await resolvePeerLocation(deps.pool, location); if (!resolved.ok) throw new Error(resolved.error); if (!deps.sendPeerPrompt) { diff --git a/apps/server/src/shared/mcp/agent-launch-tools.ts b/apps/server/src/shared/mcp/agent-launch-tools.ts index 5b344067..c9e967f7 100644 --- a/apps/server/src/shared/mcp/agent-launch-tools.ts +++ b/apps/server/src/shared/mcp/agent-launch-tools.ts @@ -34,6 +34,11 @@ export type AgentLaunchToolsContext = { agentId: string, input: LaunchAgentInput ) => Promise; + /** + * Pre-rendered sentence naming the linked instances, appended to `location`. + * Computed per request by the caller — see describePeerLocations. + */ + peerLocationHint?: string; }; export function registerAgentLaunchTools( @@ -130,7 +135,9 @@ export function registerAgentLaunchTools( .string() .optional() .describe( - "Name (or instance id) of a linked Dispatch instance to launch the agent on. Omit to launch locally. Requires an explicit cwd; calling without cwd returns the repos available there. Templates are not supported remotely. IMPORTANT: only the prompt you write crosses instances — the remote agent does not see your transcript, messages, or files, so write the prompt as a complete self-contained briefing." + "Which machine to run the agent on, by the name shown in Linked Instances." + + (context.peerLocationHint ?? "") + + " Requires an explicit cwd (paths cannot be inherited across machines); calling with a location but no cwd returns the repos available there. Templates are not supported remotely. IMPORTANT: only the prompt you write crosses instances — the remote agent does not see your transcript, messages, or files, so write the prompt as a complete self-contained briefing." ), }, }, diff --git a/apps/server/src/shared/mcp/server.ts b/apps/server/src/shared/mcp/server.ts index 32998363..89b5e542 100644 --- a/apps/server/src/shared/mcp/server.ts +++ b/apps/server/src/shared/mcp/server.ts @@ -369,6 +369,11 @@ export type McpRequestContext = { deletePersonality?: (id: string) => Promise; setActivePersonality?: (id: string) => Promise; clearActivePersonality?: () => Promise; + /** + * Sentence naming the linked instances, spliced into the `location` parameter + * description so a model can see what "Cloud" or "Studio" actually resolve to. + */ + peerLocationHint?: string; launchAgent?: ( agentId: string, input: { @@ -627,6 +632,7 @@ async function createDispatchMcpServer( registerAgentLaunchTools(server, allowed, { agentId: context.agent.id, launchAgent: context.launchAgent, + peerLocationHint: context.peerLocationHint, }); } From 8ddfce4805e85148ae0447d79e22c74c3f536cd5 Mon Sep 17 00:00:00 2001 From: Luke Brevoort Date: Mon, 17 Aug 2026 10:46:20 -0600 Subject: [PATCH 05/14] feat(peers): name linked instances and surface the pairing policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The location an agent targets is a name someone has to choose, so make it choosable and make the tool descriptions say what the choices are. - Peers carry a LOCAL label (what this machine calls them) alongside reported_name (what they call themselves). Rename inline from Linked Instances; the remote is never told, because "Cloud" describes where a peer sits relative to here. Labels are unique, so `location` is never ambiguous, and they survive a re-pair. - Name this instance from the same panel — it seeds the label peers adopt. - dispatch_launch_agent's `location` description now lists the actual linked instances, with "not responding recently" / "launching not permitted" noted inline. Computed per MCP request, since a peer can be added or renamed between two calls; tool descriptions are the only place a model learns what exists. - The pairing cards expose the three capabilities as switches instead of hardcoding allowLaunch: true, and peer rows show what was granted. - The wildcard-host bind state finally renders, as a neutral note rather than a blocker — the server already serves the tailnet in that case. Adds coverage for the newly gated paths: capability independence, slot takeover, label uniqueness and rename, cwd containment (including traversal), and the full-access gate. --- apps/server/test/peers-policy.test.ts | 252 ++++++++++++++ .../app/linked-instances-settings.tsx | 313 ++++++++++++++++-- 2 files changed, 534 insertions(+), 31 deletions(-) create mode 100644 apps/server/test/peers-policy.test.ts diff --git a/apps/server/test/peers-policy.test.ts b/apps/server/test/peers-policy.test.ts new file mode 100644 index 00000000..35504044 --- /dev/null +++ b/apps/server/test/peers-policy.test.ts @@ -0,0 +1,252 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { beforeAll, afterAll, describe, expect, it } from "vitest"; +import type { Pool } from "pg"; + +import { handleIncomingPeerLaunch } from "../src/peers/launch.js"; +import { + claimPairing, + createPairingOffer, + listPeers, + renamePeer, +} from "../src/peers/pairing.js"; +import { setupTestDb, teardownTestDb, runTestMigrations } from "./db/setup.js"; + +let pool: Pool; + +beforeAll(async () => { + pool = await setupTestDb(); + await runTestMigrations(); +}); + +afterAll(async () => { + await teardownTestDb(); +}); + +function claimer(id: string, name = `Peer ${id}`) { + return { + instanceId: id, + name, + url: `http://${id}.example:6767`, + token: `reverse-token-${id}-${"x".repeat(24)}`, + }; +} + +async function pair( + id: string, + caps: Partial<{ + allowLaunch: boolean; + allowMessage: boolean; + allowFullAccess: boolean; + }>, + name?: string +) { + const offer = await createPairingOffer(pool, { + ...caps, + requireTailnet: false, + }); + const result = await claimPairing( + pool, + { code: offer.code, claimer: claimer(id, name), callerAddr: null }, + "acceptor" + ); + if (!result.ok) throw new Error(`pairing failed: ${result.error}`); + return result; +} + +describe("peer capabilities", () => { + it("records each capability independently rather than deriving them from one flag", async () => { + await pair("inst_caps", { allowLaunch: true, allowMessage: false }); + + const peer = (await listPeers(pool)).find((p) => p.id === "inst_caps"); + expect(peer).toMatchObject({ + allowLaunch: true, + allowMessage: false, + // Never granted implicitly by allowLaunch — it disables the sandbox. + allowFullAccess: false, + }); + }); + + it("defaults full access off even when everything else is granted", async () => { + await pair("inst_default", { allowLaunch: true, allowMessage: true }); + const peer = (await listPeers(pool)).find((p) => p.id === "inst_default"); + expect(peer?.allowFullAccess).toBe(false); + }); +}); + +describe("peer slot takeover", () => { + it("refuses a claim that reuses a live peer id from a different tailnet node", async () => { + // First pairing pins the node. + const offer1 = await createPairingOffer(pool, { requireTailnet: false }); + const first = await claimPairing( + pool, + { + code: offer1.code, + claimer: claimer("inst_pinned"), + callerAddr: null, + }, + "acceptor" + ); + expect(first.ok).toBe(true); + await pool.query( + `UPDATE peers SET tailnet_stable_id = 'nodeAAA' WHERE id = 'inst_pinned'` + ); + + // A second claimer asserts the same instance id from elsewhere. + const offer2 = await createPairingOffer(pool, { requireTailnet: false }); + const second = await claimPairing( + pool, + { + code: offer2.code, + claimer: { + ...claimer("inst_pinned"), + url: "http://attacker.example:6767", + token: `attacker-token-${"z".repeat(24)}`, + }, + callerAddr: null, + }, + "acceptor" + ); + + expect(second.ok).toBe(false); + if (second.ok) return; + expect(second.status).toBe(409); + + // The original dial info survives untouched. + const peer = (await listPeers(pool)).find((p) => p.id === "inst_pinned"); + expect(peer?.url).toBe("http://inst_pinned.example:6767"); + }); +}); + +describe("local peer labels", () => { + it("keeps labels unique so `location` is never ambiguous", async () => { + await pair("inst_dup1", {}, "Cloud"); + await pair("inst_dup2", {}, "Cloud"); + + const peers = await listPeers(pool); + const names = [ + peers.find((p) => p.id === "inst_dup1")?.name, + peers.find((p) => p.id === "inst_dup2")?.name, + ]; + expect(names[0]).toBe("Cloud"); + expect(names[1]).toBe("Cloud-2"); + }); + + it("renames locally and keeps what the peer calls itself", async () => { + await pair("inst_rename", {}, "vm-847ab.internal"); + + const renamed = await renamePeer(pool, "inst_rename", "Cloud Box"); + expect(renamed).toMatchObject({ ok: true, name: "Cloud Box" }); + + const peer = (await listPeers(pool)).find((p) => p.id === "inst_rename"); + expect(peer?.name).toBe("Cloud Box"); + expect(peer?.reportedName).toBe("vm-847ab.internal"); + }); + + it("refuses a rename that collides with another linked instance", async () => { + await pair("inst_clash_a", {}, "Studio"); + await pair("inst_clash_b", {}, "Laptop"); + + const result = await renamePeer(pool, "inst_clash_b", "studio"); + expect(result).toMatchObject({ ok: false, status: 409 }); + }); +}); + +describe("incoming launch policy", () => { + const agentManager = { + createAgent: async (input: { name: string }) => ({ + id: "agt_test", + name: input.name, + status: "creating", + }), + } as never; + + async function seedRepo(root: string) { + await pool.query( + `INSERT INTO agents (id, name, type, role, status, cwd, codex_args, updated_at) + VALUES ($1, 'seed', 'claude', 'standard', 'stopped', $2, '[]'::jsonb, NOW())`, + [`agt_seed_${Math.random().toString(36).slice(2, 8)}`, root] + ); + } + + it("rejects a cwd outside every advertised repo root", async () => { + const allowed = await fs.mkdtemp(path.join(os.tmpdir(), "peer-allowed-")); + const outside = await fs.mkdtemp(path.join(os.tmpdir(), "peer-outside-")); + await seedRepo(allowed); + + await expect( + handleIncomingPeerLaunch( + { pool, agentManager }, + { + name: "remote", + prompt: "do a thing", + type: "claude", + cwd: outside, + parentAddress: "inst_x:agt_y", + }, + { allowFullAccess: true } + ) + ).rejects.toThrow(/not inside a repository this instance shares/); + }); + + it("rejects a traversal that resolves outside an advertised root", async () => { + const allowed = await fs.mkdtemp(path.join(os.tmpdir(), "peer-trav-")); + await seedRepo(allowed); + + await expect( + handleIncomingPeerLaunch( + { pool, agentManager }, + { + name: "remote", + prompt: "do a thing", + type: "claude", + cwd: path.join(allowed, "..", ".."), + parentAddress: "inst_x:agt_y", + }, + { allowFullAccess: true } + ) + ).rejects.toThrow(/not inside a repository this instance shares/); + }); + + it("accepts a subdirectory of an advertised root", async () => { + const allowed = await fs.mkdtemp(path.join(os.tmpdir(), "peer-ok-")); + const nested = path.join(allowed, "packages", "web"); + await fs.mkdir(nested, { recursive: true }); + await seedRepo(allowed); + + const result = await handleIncomingPeerLaunch( + { pool, agentManager }, + { + name: "remote", + prompt: "do a thing", + type: "claude", + cwd: nested, + parentAddress: "inst_x:agt_y", + }, + { allowFullAccess: false } + ); + expect(result.name).toBe("remote"); + }); + + it("refuses full access when the pairing did not grant it", async () => { + const allowed = await fs.mkdtemp(path.join(os.tmpdir(), "peer-fa-")); + await seedRepo(allowed); + + await expect( + handleIncomingPeerLaunch( + { pool, agentManager }, + { + name: "remote", + prompt: "do a thing", + type: "claude", + cwd: allowed, + fullAccess: true, + parentAddress: "inst_x:agt_y", + }, + { allowFullAccess: false } + ) + ).rejects.toThrow(/not allowed to launch full-access agents/); + }); +}); diff --git a/apps/web/src/components/app/linked-instances-settings.tsx b/apps/web/src/components/app/linked-instances-settings.tsx index 1bd2fa44..d22413a7 100644 --- a/apps/web/src/components/app/linked-instances-settings.tsx +++ b/apps/web/src/components/app/linked-instances-settings.tsx @@ -1,5 +1,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Link2, Loader2, MonitorSmartphone, Trash2 } from "lucide-react"; +import { + Link2, + Loader2, + MonitorSmartphone, + Pencil, + Trash2, +} from "lucide-react"; import { useState } from "react"; import { Badge } from "@/components/ui/badge"; @@ -17,24 +23,36 @@ import { api } from "@/lib/api"; type PeerSelf = { instanceId: string; + /** What peers adopt as their label for this instance when they pair. */ + name: string; passwordSet: boolean; tailscale: { dnsName: string; stableId: string } | null; bind: { enabled: boolean; active: boolean; address: string | null; - blockedReason: "no-password" | "no-tailscale" | null; + blockedReason: + | "no-password" + | "no-tailscale" + | "wildcard-host" + | null; }; }; -type Peer = { +type Capabilities = { + allowLaunch: boolean; + allowMessage: boolean; + allowFullAccess: boolean; +}; + +type Peer = Capabilities & { id: string; name: string; + reportedName: string | null; url: string; tailnetStableId: string | null; createdAt: string; lastSeenAt: string | null; - allowLaunch: boolean; }; type PairingOffer = { @@ -44,6 +62,72 @@ type PairingOffer = { address: string | null; }; +const DEFAULT_CAPABILITIES: Capabilities = { + allowLaunch: true, + allowMessage: true, + allowFullAccess: false, +}; + +const CAPABILITY_LABELS: { + key: keyof Capabilities; + label: string; + hint: string; +}[] = [ + { + key: "allowLaunch", + label: "Launch agents here", + hint: "Start new agents on this machine.", + }, + { + key: "allowMessage", + label: "Message agents here", + hint: "Send prompts to agents already running on this machine.", + }, + { + key: "allowFullAccess", + label: "Allow full access", + hint: "Let launched agents run with the sandbox off.", + }, +]; + +/** The three switches shown on both the accept and connect cards. */ +function CapabilitySwitches({ + value, + onChange, + idPrefix, +}: { + value: Capabilities; + onChange: (next: Capabilities) => void; + idPrefix: string; +}): JSX.Element { + return ( +

+

What the other instance may do here

+ {CAPABILITY_LABELS.map(({ key, label, hint }) => ( +
+
+ +

{hint}

+
+ + onChange({ ...value, [key]: checked }) + } + aria-label={label} + /> +
+ ))} +
+ ); +} + const selfQueryKey = ["peers", "self"] as const; const peersQueryKey = ["peers", "list"] as const; @@ -67,16 +151,21 @@ export function LinkedInstancesSettings(): JSX.Element { onSettled: () => queryClient.invalidateQueries({ queryKey: selfQueryKey }), }); + const [offerCaps, setOfferCaps] = useState( + DEFAULT_CAPABILITIES + ); const offerMutation = useMutation({ mutationFn: () => api("/api/v1/peers/pairings", { method: "POST", - body: JSON.stringify({ allowLaunch: true, requireTailnet: true }), + body: JSON.stringify({ ...offerCaps, requireTailnet: true }), }), }); const [linkAddress, setLinkAddress] = useState(""); const [linkCode, setLinkCode] = useState(""); + const [linkName, setLinkName] = useState(""); + const [linkCaps, setLinkCaps] = useState(DEFAULT_CAPABILITIES); const linkMutation = useMutation({ mutationFn: () => api<{ peer: Peer }>("/api/v1/peers/link", { @@ -84,12 +173,14 @@ export function LinkedInstancesSettings(): JSX.Element { body: JSON.stringify({ address: linkAddress.trim(), code: linkCode.trim(), - allowLaunch: true, + ...(linkName.trim() ? { name: linkName.trim() } : {}), + ...linkCaps, }), }), onSuccess: () => { setLinkAddress(""); setLinkCode(""); + setLinkName(""); void queryClient.invalidateQueries({ queryKey: peersQueryKey }); }, }); @@ -101,6 +192,35 @@ export function LinkedInstancesSettings(): JSX.Element { void queryClient.invalidateQueries({ queryKey: peersQueryKey }), }); + // Local label only — the remote is never told. "Cloud" describes where the + // peer sits relative to THIS machine. + const [renamingId, setRenamingId] = useState(null); + const [renameDraft, setRenameDraft] = useState(""); + const renameMutation = useMutation({ + mutationFn: ({ id, name }: { id: string; name: string }) => + api(`/api/v1/peers/${id}`, { + method: "PATCH", + body: JSON.stringify({ name }), + }), + onSuccess: () => { + setRenamingId(null); + void queryClient.invalidateQueries({ queryKey: peersQueryKey }); + }, + }); + + const [selfNameDraft, setSelfNameDraft] = useState(null); + const selfNameMutation = useMutation({ + mutationFn: (instanceName: string) => + api("/api/v1/agents/settings", { + method: "POST", + body: JSON.stringify({ instanceName }), + }), + onSuccess: () => { + setSelfNameDraft(null); + void queryClient.invalidateQueries({ queryKey: selfQueryKey }); + }, + }); + const self = selfQuery.data; const peers = peersQuery.data ?? []; const offer = offerMutation.data; @@ -129,7 +249,47 @@ export function LinkedInstancesSettings(): JSX.Element { )} - + +
+ +

+ What other instances will call this one when they link to it, and + what agents there pass as the launch location. Something + positional reads best — "Cloud", "Studio", "Laptop". +

+
{ + event.preventDefault(); + if (selfNameDraft !== null) { + selfNameMutation.mutate(selfNameDraft.trim()); + } + }} + > + setSelfNameDraft(e.target.value)} + placeholder="Cloud" + className="sm:flex-1" + /> + +
+
+

Accept tailnet connections

@@ -160,6 +320,14 @@ export function LinkedInstancesSettings(): JSX.Element { Blocked: tailscale is not running on this machine.

)} + {/* Not a failure: the server binds all interfaces, so the tailnet is + already served and a second listener would collide on the port. */} + {self?.bind.enabled && self.bind.blockedReason === "wildcard-host" && ( +

+ Already reachable on the tailnet — this server binds all + interfaces, so no separate listener is needed. +

+ )} {self?.bind.active && self.bind.address && (

Listening on {self.bind.address} @@ -194,14 +362,21 @@ export function LinkedInstancesSettings(): JSX.Element {

) : ( - + <> + + + )} {self && !self.passwordSet && (

@@ -249,6 +424,13 @@ export function LinkedInstancesSettings(): JSX.Element { aria-label="Pairing code" className="sm:w-28" /> + setLinkName(e.target.value)} + placeholder="Call it…" + aria-label="Name for this instance" + className="sm:w-36" + /> + + + ) : ( +

+ {peer.name} + {peer.allowLaunch && can launch here} + {peer.allowMessage && ( + can message here + )} + {peer.allowFullAccess && ( + full access + )} +

+ )}

{peer.url} {peer.tailnetStableId ? " · tailnet-pinned" : ""} + {peer.reportedName && peer.reportedName !== peer.name + ? ` · calls itself ${peer.reportedName}` + : ""}

- +
+ + +
))} From 9b4342c3dbfc82793e6e31f2cd4e8267c9591ee9 Mon Sep 17 00:00:00 2001 From: Luke Brevoort Date: Mon, 17 Aug 2026 10:54:38 -0600 Subject: [PATCH 06/14] fix(peers): surface rename errors and fix badge nesting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Badge renders a div, so wrapping the peer name row in a

tripped validateDOMNesting. The rename form also swallowed the server's 409 on a duplicate label — the user pressed Save and nothing happened. --- .../src/components/app/linked-instances-settings.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/app/linked-instances-settings.tsx b/apps/web/src/components/app/linked-instances-settings.tsx index d22413a7..5c426ffd 100644 --- a/apps/web/src/components/app/linked-instances-settings.tsx +++ b/apps/web/src/components/app/linked-instances-settings.tsx @@ -527,7 +527,9 @@ export function LinkedInstancesSettings(): JSX.Element { ) : ( -

+

+ {/* div, not p: Badge renders a div, which cannot + nest inside a paragraph. */} {peer.name} {peer.allowLaunch && can launch here} {peer.allowMessage && ( @@ -536,6 +538,11 @@ export function LinkedInstancesSettings(): JSX.Element { {peer.allowFullAccess && ( full access )} +
+ )} + {renamingId === peer.id && renameMutation.isError && ( +

+ {(renameMutation.error as Error).message}

)}

@@ -552,6 +559,7 @@ export function LinkedInstancesSettings(): JSX.Element { size="icon" aria-label={`Rename ${peer.name}`} onClick={() => { + renameMutation.reset(); setRenamingId(peer.id); setRenameDraft(peer.name); }} From 558b2d58cd3dc2648a2ba008584f3f334380d694 Mon Sep 17 00:00:00 2001 From: Luke Brevoort Date: Mon, 17 Aug 2026 11:11:36 -0600 Subject: [PATCH 07/14] build: move shamefully-hoist and onlyBuiltDependencies to pnpm-workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm 11 no longer reads shamefully-hoist from .npmrc or the pnpm field in package.json — it warns about the latter on every command and silently ignores the former. Same values, new home. The hoist is load-bearing: e2e/ sits at the repo root outside any workspace package, so its `import pg` only resolves against a flattened node_modules. Without it every spec fails to collect with "Cannot find package 'pg'". --- pnpm-workspace.yaml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 06b60519..0ba16ace 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,17 @@ packages: - "apps/*" + +# pnpm 11 stopped reading both of the places these settings used to live: the +# `shamefully-hoist=true` in .npmrc and the `pnpm.onlyBuiltDependencies` block +# in package.json are now silently ignored (pnpm warns about the latter on +# every command). This file is their new home — the values are unchanged. +# +# The hoist is load-bearing, not cosmetic: the Playwright suite in e2e/ lives +# at the repo root, outside any workspace package, so `import pg` there only +# resolves against a flattened node_modules. Without this, `pnpm test:e2e` +# fails to collect every spec with "Cannot find package 'pg'". +shamefullyHoist: true +allowBuilds: + esbuild: true + sharp: true + workerd: true From c17c75c7f18acbc5ed59aa5ce214deab8c1a859b Mon Sep 17 00:00:00 2001 From: Luke Brevoort Date: Mon, 17 Aug 2026 11:24:35 -0600 Subject: [PATCH 08/14] fix(peers): gate first-run open mode on loopback, not the listener address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With DISPATCH_HOST=0.0.0.0 the secondary tailnet listener never starts — the wildcard bind already serves the tailnet — so isBoundAddress() returned false for tailnet callers and passed them into first-run open mode. A passwordless instance in that configuration served its entire API to every node on the tailnet, which is exactly what the comment above the check said it prevented. Gate on "did this arrive on loopback" instead, which is the actual invariant and also covers LAN and any other interface a wildcard bind picks up. Found by trying to stand this up on a tailnet VM. --- apps/server/src/peers/tailnet-listener.ts | 16 ++++++++++++++++ apps/server/src/server.ts | 15 ++++++++++----- .../server/test/peers-tailnet-listener.test.ts | 18 ++++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/apps/server/src/peers/tailnet-listener.ts b/apps/server/src/peers/tailnet-listener.ts index a39ab955..50985152 100644 --- a/apps/server/src/peers/tailnet-listener.ts +++ b/apps/server/src/peers/tailnet-listener.ts @@ -33,6 +33,22 @@ export class TailnetListener { } /** True when `localAddress` is the tailnet interface this listener bound. */ + /** + * Whether a connection arrived on a loopback interface. First-run open mode + * is gated on this: a request from anywhere else is a remote caller, and + * "no password set" must never mean "answer them". + */ + static isLoopback(localAddress: string | undefined): boolean { + if (!localAddress) return false; + // Node reports IPv4 as ::ffff:127.0.0.1 on dual-stack sockets. + const address = localAddress.startsWith("::ffff:") + ? localAddress.slice(7) + : localAddress; + return ( + address === "::1" || address === "127.0.0.1" || address.startsWith("127.") + ); + } + isBoundAddress(localAddress: string | undefined): boolean { if (!this.boundAddress || !localAddress) return false; // Node reports IPv4 as ::ffff:100.x.y.z on dual-stack sockets. diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e9a3aadc..79953241 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -573,12 +573,17 @@ async function registerRoutes() { // session cookie or bearer token. if (url === "/api/v1/release/assisted/phase") return; - // If no password is set, all routes are open (first-run mode) — except on - // the tailnet interface, where open mode would expose the API to every - // node on the tailnet. Belt-and-braces: the listener refuses to start - // without a password, but the password can be cleared while it runs. + // If no password is set, all routes are open (first-run mode) — but only + // to loopback. Anything arriving on another interface is a remote caller, + // and first-run mode must never answer one. + // + // The invariant is deliberately "did this arrive on loopback", not "is + // this the tailnet listener's address": with DISPATCH_HOST=0.0.0.0 the + // secondary listener never starts (the wildcard bind already serves the + // tailnet), so a listener-address test passes every tailnet and LAN + // caller straight into open mode. if (!(await authRuntime.isPasswordSetCached())) { - if (tailnetListener.isBoundAddress(request.socket.localAddress)) { + if (!TailnetListener.isLoopback(request.socket.localAddress)) { return reply.code(401).send({ error: "Authentication required." }); } return; diff --git a/apps/server/test/peers-tailnet-listener.test.ts b/apps/server/test/peers-tailnet-listener.test.ts index 7adeea47..9dd5fd61 100644 --- a/apps/server/test/peers-tailnet-listener.test.ts +++ b/apps/server/test/peers-tailnet-listener.test.ts @@ -87,3 +87,21 @@ describe("TailnetListener", () => { expect(listener.isBoundAddress("127.0.0.1")).toBe(false); }); }); + +describe("first-run open mode", () => { + it("treats only loopback as safe for passwordless access", () => { + // Loopback: first-run mode is fine here. + expect(TailnetListener.isLoopback("127.0.0.1")).toBe(true); + expect(TailnetListener.isLoopback("::1")).toBe(true); + expect(TailnetListener.isLoopback("::ffff:127.0.0.1")).toBe(true); + + // Everything else is a remote caller. The tailnet case is the one that + // matters: with DISPATCH_HOST=0.0.0.0 no secondary listener starts, so a + // bound-address test would have let these through into open mode. + expect(TailnetListener.isLoopback("100.83.166.101")).toBe(false); + expect(TailnetListener.isLoopback("::ffff:100.83.166.101")).toBe(false); + expect(TailnetListener.isLoopback("192.168.1.10")).toBe(false); + expect(TailnetListener.isLoopback("0.0.0.0")).toBe(false); + expect(TailnetListener.isLoopback(undefined)).toBe(false); + }); +}); From 51a4e5d907594e502c2419a1b9ac66e618a3a8d6 Mon Sep 17 00:00:00 2001 From: Luke Brevoort Date: Mon, 17 Aug 2026 11:57:57 -0600 Subject: [PATCH 09/14] fix(peers): stamp last_seen_at when our own event stream connects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listPeerLocations decides 'reachable' from last_seen_at, but only requirePeerAuth wrote it — i.e. only when a peer calls US. A peer we only ever dial (the normal shape for a cloud box that just answers) therefore went stale and got advertised to the model as "not responding recently" while it was perfectly healthy. A connected outbound event stream is proof of life, so stamp there too. Caught on a live tailnet pairing: the launch tool description read `Linked instances: "Cloud" (not responding recently)` for a VM that was answering every request. --- apps/server/src/peers/events.ts | Bin 11888 -> 12846 bytes apps/server/test/peers-policy.test.ts | 37 +++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/apps/server/src/peers/events.ts b/apps/server/src/peers/events.ts index 75ac09be5a4212853b522c5e754a4c9080befac7..633e8a373a55dd13ff017faae0d04711f2ef526e 100644 GIT binary patch delta 771 zcmY+C-)d7q5XSGTMdD4N1f7P~oRs{rx20Z08zB(V(6oq9i0j!&c4f1B+?~@9q{K%M zj<4W_HzM>od#1o9}R zpaW#yxiRo(^cd?Ptc0=iARt5ZFhg+G%(q~*7{&k(QVRo`B7hXaS-LeOw2^pHj_s8Y!AxDzL_?ueRZbRwIqfYDa5Y91mUNShQg|Q%2JkaRxZdl6zteZ9A zB?fBXmE9-+6Ld+8b^E E1Ej0|zyJUM delta 21 dcmZ3N@*!qJt>ES;;WqxsvXXq8&#T1q0sv={2s;1( diff --git a/apps/server/test/peers-policy.test.ts b/apps/server/test/peers-policy.test.ts index 35504044..3b283f7f 100644 --- a/apps/server/test/peers-policy.test.ts +++ b/apps/server/test/peers-policy.test.ts @@ -5,7 +5,11 @@ import path from "node:path"; import { beforeAll, afterAll, describe, expect, it } from "vitest"; import type { Pool } from "pg"; -import { handleIncomingPeerLaunch } from "../src/peers/launch.js"; +import { + describePeerLocations, + handleIncomingPeerLaunch, + listPeerLocations, +} from "../src/peers/launch.js"; import { claimPairing, createPairingOffer, @@ -250,3 +254,34 @@ describe("incoming launch policy", () => { ).rejects.toThrow(/not allowed to launch full-access agents/); }); }); + +describe("peer locations for the launch tool description", () => { + it("reports a recently-seen peer as reachable and a stale one as not", async () => { + await pair("inst_live", { allowLaunch: true }, "LiveBox"); + await pair("inst_stale", { allowLaunch: false }, "StaleBox"); + + await pool.query( + `UPDATE peers SET last_seen_at = now() WHERE id = 'inst_live'` + ); + await pool.query( + `UPDATE peers SET last_seen_at = now() - interval '2 hours' WHERE id = 'inst_stale'` + ); + + const locations = await listPeerLocations(pool); + const live = locations.find((l) => l.name === "LiveBox"); + const stale = locations.find((l) => l.name === "StaleBox"); + + expect(live).toMatchObject({ reachable: true, canLaunch: true }); + expect(stale).toMatchObject({ reachable: false, canLaunch: false }); + + // This string is what a model actually reads, so assert it directly. + const described = describePeerLocations([live!, stale!]); + expect(described).toContain('"LiveBox"'); + expect(described).toContain("not responding recently"); + expect(described).toContain("launching not permitted there"); + }); + + it("tells the model to launch locally when nothing is linked", () => { + expect(describePeerLocations([])).toContain("No instances are currently linked"); + }); +}); From 514b0cef013360bfc2434f73e88134b4e298755b Mon Sep 17 00:00:00 2001 From: Luke Brevoort Date: Tue, 18 Aug 2026 13:17:23 -0600 Subject: [PATCH 10/14] feat(peers): gate shared event text behind an allow_events capability Adds an allow_events capability to pairing (protocol v2), mirrors remote latest events with their original timestamps, exposes per-agent event history at /api/v1/agents/:id/events, and renders shadow agents with a peer badge and a remote activity pane in place of the terminal. Co-Authored-By: Claude Fable 5 --- apps/server/src/agents/events.ts | 66 ++-- apps/server/src/db/migrations/0041_peers.sql | 18 +- apps/server/src/peers/events.ts | Bin 12846 -> 16938 bytes apps/server/src/peers/pairing.ts | 49 +-- apps/server/src/peers/peer-auth.ts | 5 +- .../server/src/routes/agents/events-routes.ts | 34 ++ apps/server/src/routes/peers.ts | 17 +- apps/server/test/peers-event-mirror.test.ts | 217 ++++++++++++ apps/server/test/peers-pairing.test.ts | 3 +- .../src/components/app/agent-card-header.tsx | 30 ++ apps/web/src/components/app/agent-card.tsx | 28 +- .../src/components/app/agents-view-header.tsx | 3 + apps/web/src/components/app/agents-view.tsx | 33 +- .../src/components/app/center-pane-split.tsx | 11 +- .../components/app/center-pane-tab-bar.tsx | 11 +- .../app/linked-instances-settings.tsx | 36 +- .../components/app/remote-activity-pane.tsx | 333 ++++++++++++++++++ apps/web/src/hooks/use-agent-actions.ts | 15 +- apps/web/src/hooks/use-peers.ts | 34 ++ apps/web/src/hooks/use-terminal.ts | 14 + 20 files changed, 850 insertions(+), 107 deletions(-) create mode 100644 apps/server/test/peers-event-mirror.test.ts create mode 100644 apps/web/src/components/app/remote-activity-pane.tsx create mode 100644 apps/web/src/hooks/use-peers.ts diff --git a/apps/server/src/agents/events.ts b/apps/server/src/agents/events.ts index 70145433..7f78bf22 100644 --- a/apps/server/src/agents/events.ts +++ b/apps/server/src/agents/events.ts @@ -8,6 +8,47 @@ import type { AgentRecord, } from "./types.js"; +/** + * Append one row to the `agent_events` history. Fire-and-forget by design: + * losing a history row must never block the live status indicator. + * + * `at` exists for MIRRORED peer events, which already happened on another + * machine — their history row keeps the originating timestamp so the timeline + * reads in the order the remote agent actually worked in. It doubles as the + * dedupe identity: a peer snapshot replays the whole agent list after every + * reconnect, so the insert is skipped when this agent already has a row at that + * exact instant. Locally-written events omit it and get `now()`. + */ +export function appendAgentEventHistory( + pool: Pool, + logger: FastifyBaseLogger, + id: string, + input: AgentLatestEventInput, + at?: string +): void { + pool + .query( + `INSERT INTO agent_events (agent_id, event_type, message, metadata, agent_type, agent_name, project_dir, created_at) + SELECT $1, $2, $3, $4::jsonb, type, name, COALESCE(git_context->>'repoRoot', cwd), + COALESCE($5::timestamptz, now()) + FROM agents WHERE id = $1 + AND ($5::timestamptz IS NULL OR NOT EXISTS ( + SELECT 1 FROM agent_events + WHERE agent_id = $1 AND created_at = $5::timestamptz + ))`, + [ + id, + input.type, + input.message, + JSON.stringify(input.metadata ?? {}), + at ?? null, + ] + ) + .catch((err) => + logger.warn({ err }, "Failed to insert agent event history") + ); +} + /** * Persist a latest-event update for `id`. Two writes: * 1. UPDATE the agent's `latest_event_*` columns synchronously. Throws @@ -52,19 +93,7 @@ export async function writeLatestEvent( throw new AgentError("Agent not found.", 404); } - // Append to event history (fire-and-forget — keeping this off the critical - // path means the agent status indicator updates even if the history table - // is briefly unavailable). - pool - .query( - `INSERT INTO agent_events (agent_id, event_type, message, metadata, agent_type, agent_name, project_dir) - SELECT $1, $2, $3, $4::jsonb, type, name, COALESCE(git_context->>'repoRoot', cwd) - FROM agents WHERE id = $1`, - [id, input.type, message, JSON.stringify(input.metadata ?? {})] - ) - .catch((err) => - logger.warn({ err }, "Failed to insert agent event history") - ); + appendAgentEventHistory(pool, logger, id, { ...input, message }); } /** @@ -113,16 +142,7 @@ export async function writeLatestEventIfCurrent( return false; } - pool - .query( - `INSERT INTO agent_events (agent_id, event_type, message, metadata, agent_type, agent_name, project_dir) - SELECT $1, $2, $3, $4::jsonb, type, name, COALESCE(git_context->>'repoRoot', cwd) - FROM agents WHERE id = $1`, - [id, input.type, message, JSON.stringify(input.metadata ?? {})] - ) - .catch((err) => - logger.warn({ err }, "Failed to insert agent event history") - ); + appendAgentEventHistory(pool, logger, id, { ...input, message }); return true; } diff --git a/apps/server/src/db/migrations/0041_peers.sql b/apps/server/src/db/migrations/0041_peers.sql index 36ec3154..0044e21a 100644 --- a/apps/server/src/db/migrations/0041_peers.sql +++ b/apps/server/src/db/migrations/0041_peers.sql @@ -29,11 +29,11 @@ CREATE UNIQUE INDEX IF NOT EXISTS peers_active_name_idx -- Bearer tokens THEY present to US, plus the standing pair-time policy. -- --- Capabilities are a SET, not a flag. Pairing grants three separable things — --- run code here, inject prompts into agents here, and do either with the --- sandbox off — and a single boolean cannot describe them. Each route gates on --- its own column, so a launch-only CI box or a message-only observer is a --- policy row rather than a protocol version. +-- Capabilities are a SET, not a flag. Pairing grants separable things — run +-- code here, inject prompts into agents here, do either with the sandbox off, +-- and see what agents here are doing — and a single boolean cannot describe +-- them. Each route gates on its own column, so a launch-only CI box or a +-- message-only observer is a policy row rather than a protocol version. CREATE TABLE IF NOT EXISTS peer_credentials ( id uuid PRIMARY KEY, peer_id text NOT NULL REFERENCES peers(id) ON DELETE CASCADE, @@ -45,6 +45,13 @@ CREATE TABLE IF NOT EXISTS peer_credentials ( -- sandbox, and "may launch here" should not silently mean "may launch -- unsandboxed here". Opt in per pairing. allow_full_access boolean NOT NULL DEFAULT false, + -- Gates the LATEST-EVENT payload on the peer event feed, not the feed itself. + -- Id/name/type/status always cross — without them a shadow row could never + -- track its remote agent at all. What this grant adds is the event text an + -- agent writes about itself ("Refactoring auth middleware"), which says far + -- more about what this machine is doing than a status enum does. On by + -- default: a shadow whose progress never moves is the thing it exists to fix. + allow_events boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), last_used_at timestamptz, revoked_at timestamptz @@ -61,6 +68,7 @@ CREATE TABLE IF NOT EXISTS peer_pairings ( allow_launch boolean NOT NULL DEFAULT true, allow_message boolean NOT NULL DEFAULT true, allow_full_access boolean NOT NULL DEFAULT false, + allow_events boolean NOT NULL DEFAULT true, require_tailnet boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz NOT NULL, diff --git a/apps/server/src/peers/events.ts b/apps/server/src/peers/events.ts index 633e8a373a55dd13ff017faae0d04711f2ef526e..5b7483f347ae40004459537d21ed620606bdc48a 100644 GIT binary patch delta 3903 zcma)9&u<&Y6^4u$0oWEv;5KN}1bHTem=xHhoaRSM#S!S5sFSF)A<}h!8S;=j6en8l zE@o!MGPb}TdTkqHdngJNJrxCdXix|}=MTuO_wbRieEf^IACO2>8He3N8Qb15{@hf?CVD|9G||b7>Os&JJZajJr%mvosqZ}6 z??1mMtTcYlaFNQ^qd)&_+1}cs!Bl7&(r7_;D#=Vmn9Q#jiqtXQtgx9O5r=Maq@_3_ zWhs*4SQ=8X8@6gDBNfYlw%RrM>on1p#z|}}-R|}F5AO8$d)*)1>|WpN9sHy>7<7N! zqiZ#y`d)s0>-&$s{Ajs$G`dC)FlTYX-46U~6(8hy&fCO(hzqFyY{isnY04q z0)zW2?9d=w}1_+5HPaTY~8wUeD%4W9D*8}BcP5DYk?E$WU8B1iM7^!H1p z)D)_a;>6OS7MQy)q9~ak_z>T3VXYcZNg4d`aXgZXE5@5kwS>K~g)nJKt}2=>=+>Dg%;A*y+K4D`ABf2$wX>*RIQL)O8j|!4gnUUI$gh?#@FBL}o>s}Lybm>F#5M#bz5w*D_1e4|}0zwZtYIBZNixTZX z5K!+}>V;jpD}rf;pWpMl2lR< zXVX9w6E>7##+gfEAOXajWbxQ47|M(oMRMOo373y;12JE^WHV)+Ku4|Fp$CqS6&*`5 zt`B(5tww|`e?yr(-yE?$APP1E1;uU}f8z?^q1Y~{03588EDyaARJyQ`@tbqc8{&V&8%-OpwtsRL#D zqTdiUIFxpX)Ha%Msint;FY$OFJ&m-AKdB(Uc1y}Q5>yQ zH`?8$2PaLxhl7n{*yO#zt$~xwhMR%Doj~F8Pc}Zk@T=|9-(C3fa{l|)8!vebM#9(w zBV~Lbyt3dZr~lgg!^I1mujilAtNCBrwbQ?D*^OoETp_>bEDAsT+YHf~G}b z(#gD{i~s_Um4!qWB?DTQ%z;-rRz?Ap&zK$1}9Wf8i6JJoNiscVlV)M8oYh;nuhe&96sqx* zb)rM|qJ}D+N-r=Yv^l@OGzuhp!E88qlUPRU<7LhIT@!D&J8t0z{|9OTnng6Z*YJ2m|PGc45KW`$qm> z`g;EN_A8gq9`1*o4$F`eL;F#AoM*oK6^;z6U(1v2Z{fsmynNR3)$LB_L)7e1W%TsZ z_U^_96%5I-?7eWEl?4nz`L$&@95>()d+u1*W^%gv&E?uVUH5_aWumAH3|aC3+YMad zJZp$tZr3e2K<=U@yHq^IcWH4S)$l0>tNp%^on3OKXn6WUxUc0OT&;5}-lp=qCX2&D z;0HImH*VeKPZQUNvNpQhK`tDQSNy;l^!Y1BRB3k27I#m6CwFgxs}*nc{SqC?C8Vkt zGqFffp{&=fg$!3V#l+%VxGTQBG`=%jaeyww%zJcRP(2hUA1e03oxfkH;i=>oH#xXB z2<4*Y%Er4sZRggFHF!KxLflL)HZMJfjK>kRs!Qy~*AwU414I~d-$n| OsW89S>gWHyy7)iJe%gru delta 223 zcmZ40!niJFLe#{WZWF%-P5#SRHu)Em6H~4AWGRjZj0~IqaJ*AAt5!%YDzX9*`9(Sk z1*xe;o+%2oItof|iJ3X6DGDX|3dJRfxdjS2iNz)H#i^-z@rfl$lWXMVCa=?B-yEx* z#mcBY`6#2wWKcCkD%`MwS3WL+D% z&CNDejFS_LIP{8AbMs45^)gd*K$eu07Ax4>E98~t0Ji^9PXGV_ diff --git a/apps/server/src/peers/pairing.ts b/apps/server/src/peers/pairing.ts index 429950c4..f21b5f45 100644 --- a/apps/server/src/peers/pairing.ts +++ b/apps/server/src/peers/pairing.ts @@ -15,23 +15,26 @@ export const PAIRING_TTL_MS = 10 * 60 * 1000; * with a clear error instead of failing ambiguously on a later payload. * Bump on any incompatible change to the peer routes. */ -export const PEER_PROTOCOL_VERSION = 1; +export const PEER_PROTOCOL_VERSION = 2; /** - * What a pairing grants. Three separable powers, because "may launch here" and - * "may launch here with the sandbox off" are not the same permission, and - * messaging an agent is not launching one. + * What a pairing grants. Separable powers, because "may launch here" and "may + * launch here with the sandbox off" are not the same permission, messaging an + * agent is not launching one, and watching what agents here are doing is + * neither. */ export type PeerCapabilities = { allowLaunch: boolean; allowMessage: boolean; allowFullAccess: boolean; + allowEvents: boolean; }; export const DEFAULT_CAPABILITIES: PeerCapabilities = { allowLaunch: true, allowMessage: true, allowFullAccess: false, + allowEvents: true, }; export type PeerRecord = { @@ -134,14 +137,15 @@ export async function createPairingOffer( const expiresAt = new Date(Date.now() + PAIRING_TTL_MS); await pool.query( `INSERT INTO peer_pairings - (id, code_hash, allow_launch, allow_message, allow_full_access, require_tailnet, expires_at) - VALUES ($1, $2, $3, $4, $5, $6, $7)`, + (id, code_hash, allow_launch, allow_message, allow_full_access, allow_events, require_tailnet, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [ pairingId, sha256(code), caps.allowLaunch, caps.allowMessage, caps.allowFullAccess, + caps.allowEvents, input.requireTailnet, expiresAt, ] @@ -183,9 +187,11 @@ export async function claimPairing( allow_launch: boolean; allow_message: boolean; allow_full_access: boolean; + allow_events: boolean; require_tailnet: boolean; }>( - `SELECT id, code_hash, allow_launch, allow_message, allow_full_access, require_tailnet + `SELECT id, code_hash, allow_launch, allow_message, allow_full_access, + allow_events, require_tailnet FROM peer_pairings WHERE expires_at > now() AND claimed_at IS NULL` ); @@ -263,8 +269,8 @@ export async function claimPairing( ); await client.query( `INSERT INTO peer_credentials - (id, peer_id, token_hash, tailnet_stable_id, allow_launch, allow_message, allow_full_access) - VALUES ($1, $2, $3, $4, $5, $6, $7)`, + (id, peer_id, token_hash, tailnet_stable_id, allow_launch, allow_message, allow_full_access, allow_events) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [ crypto.randomUUID(), input.claimer.instanceId, @@ -273,6 +279,7 @@ export async function claimPairing( offer.allow_launch, offer.allow_message, offer.allow_full_access, + offer.allow_events, ] ); await client.query(`UPDATE peer_pairings SET peer_id = $2 WHERE id = $1`, [ @@ -436,14 +443,7 @@ export async function linkToPeer( ON CONFLICT (id) DO UPDATE SET reported_name = $3, url = $4, tailnet_stable_id = $5, outbound_token = $6, last_seen_at = now(), revoked_at = NULL`, - [ - body.instanceId, - label, - reportedName, - peerUrl, - peerStableId, - body.token, - ] + [body.instanceId, label, reportedName, peerUrl, peerStableId, body.token] ); await client.query( `UPDATE peer_credentials SET revoked_at = now() @@ -452,8 +452,8 @@ export async function linkToPeer( ); await client.query( `INSERT INTO peer_credentials - (id, peer_id, token_hash, tailnet_stable_id, allow_launch, allow_message, allow_full_access) - VALUES ($1, $2, $3, $4, $5, $6, $7)`, + (id, peer_id, token_hash, tailnet_stable_id, allow_launch, allow_message, allow_full_access, allow_events) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [ crypto.randomUUID(), body.instanceId, @@ -462,6 +462,7 @@ export async function linkToPeer( caps.allowLaunch, caps.allowMessage, caps.allowFullAccess, + caps.allowEvents, ] ); await client.query("COMMIT"); @@ -484,7 +485,9 @@ export async function renamePeer( pool: Pool, peerId: string, name: string -): Promise<{ ok: true; name: string } | { ok: false; status: number; error: string }> { +): Promise< + { ok: true; name: string } | { ok: false; status: number; error: string } +> { const trimmed = name.trim(); if (!trimmed) { return { ok: false, status: 400, error: "Name cannot be empty." }; @@ -523,13 +526,14 @@ export async function listPeers(pool: Pool): Promise { allow_launch: boolean | null; allow_message: boolean | null; allow_full_access: boolean | null; + allow_events: boolean | null; }>( `SELECT p.id, p.name, p.reported_name, p.url, p.tailnet_stable_id, p.created_at, p.last_seen_at, - c.allow_launch, c.allow_message, c.allow_full_access + c.allow_launch, c.allow_message, c.allow_full_access, c.allow_events FROM peers p LEFT JOIN LATERAL ( - SELECT allow_launch, allow_message, allow_full_access + SELECT allow_launch, allow_message, allow_full_access, allow_events FROM peer_credentials WHERE peer_id = p.id AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1 @@ -548,6 +552,7 @@ export async function listPeers(pool: Pool): Promise { allowLaunch: row.allow_launch ?? false, allowMessage: row.allow_message ?? false, allowFullAccess: row.allow_full_access ?? false, + allowEvents: row.allow_events ?? false, })); } diff --git a/apps/server/src/peers/peer-auth.ts b/apps/server/src/peers/peer-auth.ts index 55139d0f..4b4ba178 100644 --- a/apps/server/src/peers/peer-auth.ts +++ b/apps/server/src/peers/peer-auth.ts @@ -11,6 +11,7 @@ export type PeerAuth = { allowLaunch: boolean; allowMessage: boolean; allowFullAccess: boolean; + allowEvents: boolean; }; /** @@ -102,10 +103,11 @@ export async function requirePeerAuth( allow_launch: boolean; allow_message: boolean; allow_full_access: boolean; + allow_events: boolean; password_set: boolean; }>( `SELECT c.id, c.peer_id, c.tailnet_stable_id, - c.allow_launch, c.allow_message, c.allow_full_access, + c.allow_launch, c.allow_message, c.allow_full_access, c.allow_events, EXISTS ( SELECT 1 FROM settings WHERE key = 'password_hash' AND value <> '' ) AS password_set @@ -166,6 +168,7 @@ export async function requirePeerAuth( allowLaunch: row.allow_launch, allowMessage: row.allow_message, allowFullAccess: row.allow_full_access, + allowEvents: row.allow_events, }; } diff --git a/apps/server/src/routes/agents/events-routes.ts b/apps/server/src/routes/agents/events-routes.ts index a3dc7506..275ca217 100644 --- a/apps/server/src/routes/agents/events-routes.ts +++ b/apps/server/src/routes/agents/events-routes.ts @@ -51,6 +51,40 @@ export async function registerAgentEventRoutes( } }); + // One agent's event history, newest first. The live SSE feed only ever + // carries the LATEST event, so a pane that renders a timeline needs this to + // see anything that happened before it was opened. + app.get("/api/v1/agents/:id/events", async (request, reply) => { + const { id } = request.params as { id?: string }; + if (!id) return reply.code(400).send({ error: "Agent id is required." }); + + const rawLimit = Number((request.query as { limit?: string }).limit); + const limit = Number.isFinite(rawLimit) + ? Math.min(Math.max(Math.trunc(rawLimit), 1), 500) + : 200; + + const agent = await deps.agentManager.getAgent(id); + if (!agent) return reply.code(404).send({ error: "Agent not found." }); + + const result = await deps.pool.query<{ + id: number; + type: string; + message: string; + metadata: Record | null; + createdAt: string; + }>( + `SELECT id, event_type AS "type", message, + COALESCE(metadata, '{}'::jsonb) AS metadata, + created_at AS "createdAt" + FROM agent_events + WHERE agent_id = $1 + ORDER BY created_at DESC, id DESC + LIMIT $2`, + [id, limit] + ); + return { events: result.rows }; + }); + app.post("/api/v1/notifications/ack", async (request, reply) => { const body = request.body as { notificationId?: unknown }; if (typeof body?.notificationId !== "string") { diff --git a/apps/server/src/routes/peers.ts b/apps/server/src/routes/peers.ts index dc6282f6..b28bec2f 100644 --- a/apps/server/src/routes/peers.ts +++ b/apps/server/src/routes/peers.ts @@ -30,6 +30,7 @@ const PairingOfferBodySchema = z.object({ allowLaunch: z.boolean().default(true), allowMessage: z.boolean().default(true), allowFullAccess: z.boolean().default(false), + allowEvents: z.boolean().default(true), requireTailnet: z.boolean().default(true), }); @@ -55,6 +56,7 @@ const LinkBodySchema = z.object({ allowLaunch: z.boolean().default(true), allowMessage: z.boolean().default(true), allowFullAccess: z.boolean().default(false), + allowEvents: z.boolean().default(true), selfUrl: z.string().trim().max(4_096).optional(), }); @@ -331,19 +333,28 @@ export async function registerPeerRoutes( const stream = reply.raw; // Peers get a scoped view, not the full UI event firehose: only - // agent.upsert, only local (non-shadow) agents, only the status fields - // the mirror consumes. Live events buffer until the snapshot is written - // so a reconnect can never regress a shadow with an older snapshot. + // agent.upsert, only local (non-shadow) agents, only the fields the + // mirror consumes. Live events buffer until the snapshot is written so a + // reconnect can never regress a shadow with an older snapshot. + // + // latestEvent is its own capability: identity and status are what a + // shadow row needs to exist at all, but the event text is whatever the + // agent wrote about itself, and that says far more about this machine. + // Omitted entirely (not nulled) when ungranted, so the mirror can tell + // "not shared" from "no event yet" and leave the shadow's own state be. + const shareEvents = request.peerAuth!.allowEvents; const slim = (agent: { id: string; name: string; type: string; status: string; + latestEvent?: AgentRecord["latestEvent"]; }) => ({ id: agent.id, name: agent.name, type: agent.type, status: agent.status, + ...(shareEvents ? { latestEvent: agent.latestEvent ?? null } : {}), }); let snapshotSent = false; const pending: string[] = []; diff --git a/apps/server/test/peers-event-mirror.test.ts b/apps/server/test/peers-event-mirror.test.ts new file mode 100644 index 00000000..e4a579e7 --- /dev/null +++ b/apps/server/test/peers-event-mirror.test.ts @@ -0,0 +1,217 @@ +import { beforeAll, afterAll, beforeEach, describe, expect, it } from "vitest"; +import type { Pool } from "pg"; + +import { PeerEventSubscriber } from "../src/peers/events.js"; +import { setupTestDb, teardownTestDb, runTestMigrations } from "./db/setup.js"; + +let pool: Pool; + +const PEER_ID = "inst_mirror_src"; +const REMOTE_ID = "agt_remote_1"; +const SHADOW_ID = "agt_shadow_1"; + +beforeAll(async () => { + pool = await setupTestDb(); + await runTestMigrations(); +}); + +afterAll(async () => { + await teardownTestDb(); +}); + +beforeEach(async () => { + await pool.query("DELETE FROM agent_events WHERE agent_id = $1", [SHADOW_ID]); + await pool.query("DELETE FROM agents WHERE id = $1", [SHADOW_ID]); + await pool.query("DELETE FROM peers WHERE id = $1", [PEER_ID]); + await pool.query( + `INSERT INTO peers (id, name, url, outbound_token) + VALUES ($1, 'Cloud', 'http://cloud.example:6767', 'tok')`, + [PEER_ID] + ); + await pool.query( + `INSERT INTO agents (id, name, type, status, cwd, peer_id, remote_id) + VALUES ($1, 'remote agent', 'claude', 'creating', '/tmp/repo', $2, $3)`, + [SHADOW_ID, PEER_ID, REMOTE_ID] + ); +}); + +const log = { + debug: () => {}, + warn: () => {}, + info: () => {}, + error: () => {}, +} as never; + +/** + * Drive the subscriber against a scripted SSE body. Returns once the frames + * have been consumed and the stream closed, so assertions see settled writes. + */ +async function mirror(frames: unknown[]): Promise { + const body = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + for (const frame of frames) { + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(frame)}\n\n`) + ); + } + controller.close(); + }, + }); + let resolveDone: () => void; + const done = new Promise((resolve) => { + resolveDone = resolve; + }); + const subscriber = new PeerEventSubscriber({ + pool, + agentManager: { + getAgent: async () => null, + listAgents: async () => [], + } as never, + publishUiEvent: () => {}, + withStreamFlag: ((agent: unknown) => agent) as never, + log, + fetchImpl: (async () => { + // One connection's worth of frames; the reconnect after it resolves the + // test rather than looping forever. + queueMicrotask(() => setTimeout(() => resolveDone(), 50)); + return new Response(body, { status: 200 }); + }) as typeof fetch, + }); + subscriber.start(); + await done; + subscriber.stop(); + // The history append is fire-and-forget; let it land. + await new Promise((resolve) => setTimeout(resolve, 100)); +} + +async function shadowRow() { + const result = await pool.query( + `SELECT status, name, latest_event_type, latest_event_message, + latest_event_updated_at, latest_event_metadata + FROM agents WHERE id = $1`, + [SHADOW_ID] + ); + return result.rows[0]; +} + +async function historyCount(): Promise { + const result = await pool.query<{ count: string }>( + "SELECT count(*) FROM agent_events WHERE agent_id = $1", + [SHADOW_ID] + ); + return Number(result.rows[0].count); +} + +const AT = "2026-08-17T10:00:00.000Z"; + +function upsert(latestEvent: unknown, status = "running") { + return { + type: "agent.upsert", + agent: { + id: REMOTE_ID, + name: "remote agent", + type: "claude", + status, + latestEvent, + }, + }; +} + +describe("mirroring remote events onto shadow rows", () => { + it("writes the remote event onto the shadow and into its history", async () => { + await mirror([ + upsert({ + type: "working", + message: "Surveying the VM", + updatedAt: AT, + metadata: { source: "agent" }, + }), + ]); + + const row = await shadowRow(); + expect(row.status).toBe("running"); + expect(row.latest_event_type).toBe("working"); + expect(row.latest_event_message).toBe("Surveying the VM"); + expect(new Date(row.latest_event_updated_at).toISOString()).toBe(AT); + expect(row.latest_event_metadata).toMatchObject({ source: "agent" }); + expect(await historyCount()).toBe(1); + }); + + it("does not duplicate history when a snapshot replays the same event", async () => { + const event = { + type: "working", + message: "Surveying the VM", + updatedAt: AT, + metadata: {}, + }; + await mirror([upsert(event)]); + // A reconnect replays the whole agent list; the remote timestamp is the + // dedupe identity, so the second pass must add nothing. + await mirror([ + { + type: "snapshot", + agents: [{ id: REMOTE_ID, status: "running", latestEvent: event }], + }, + ]); + + expect(await historyCount()).toBe(1); + }); + + it("appends a second row for a genuinely newer remote event", async () => { + await mirror([ + upsert({ + type: "working", + message: "First", + updatedAt: AT, + metadata: {}, + }), + ]); + await mirror([ + upsert({ + type: "blocked", + message: "Second", + updatedAt: "2026-08-17T10:05:00.000Z", + metadata: {}, + }), + ]); + + expect(await historyCount()).toBe(2); + expect((await shadowRow()).latest_event_type).toBe("blocked"); + }); + + it("leaves the shadow's own event alone when the peer does not share events", async () => { + await pool.query( + `UPDATE agents SET latest_event_type = 'idle', + latest_event_message = 'local stamp', + latest_event_updated_at = now() + WHERE id = $1`, + [SHADOW_ID] + ); + // No latestEvent key at all — what a peer without allow_events sends. + await mirror([ + { + type: "agent.upsert", + agent: { id: REMOTE_ID, name: "remote agent", status: "running" }, + }, + ]); + + const row = await shadowRow(); + expect(row.status).toBe("running"); + expect(row.latest_event_message).toBe("local stamp"); + expect(await historyCount()).toBe(0); + }); + + it("rejects a remote event with an unknown type or unparseable timestamp", async () => { + await mirror([ + upsert({ type: "on_fire", message: "nope", updatedAt: AT, metadata: {} }), + ]); + await mirror([ + upsert({ type: "working", message: "nope", updatedAt: "not-a-date" }), + ]); + + const row = await shadowRow(); + expect(row.latest_event_type).toBeNull(); + expect(await historyCount()).toBe(0); + }); +}); diff --git a/apps/server/test/peers-pairing.test.ts b/apps/server/test/peers-pairing.test.ts index 220bf6af..f1c6621b 100644 --- a/apps/server/test/peers-pairing.test.ts +++ b/apps/server/test/peers-pairing.test.ts @@ -6,6 +6,7 @@ import { createPairingOffer, linkToPeer, listPeers, + PEER_PROTOCOL_VERSION, revokePeer, } from "../src/peers/pairing.js"; import { requirePeerAuth } from "../src/peers/peer-auth.js"; @@ -110,7 +111,7 @@ describe("linkToPeer", () => { claimedBody = JSON.parse(String(init?.body)); return new Response( JSON.stringify({ - protocolVersion: 1, + protocolVersion: PEER_PROTOCOL_VERSION, instanceId: "inst_remote", name: "cloud-vm", token: remoteToken, diff --git a/apps/web/src/components/app/agent-card-header.tsx b/apps/web/src/components/app/agent-card-header.tsx index 803c9602..e51e809e 100644 --- a/apps/web/src/components/app/agent-card-header.tsx +++ b/apps/web/src/components/app/agent-card-header.tsx @@ -4,6 +4,7 @@ import { ArrowDownToLine, ChevronDown, Play, + Radio, Tag, } from "lucide-react"; import { toast } from "sonner"; @@ -17,6 +18,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { usePeerName } from "@/hooks/use-peers"; import { api } from "@/lib/api"; import { cn } from "@/lib/utils"; @@ -61,6 +63,9 @@ export function AgentCardHeader({ toggleAgentDetails, }: AgentCardHeaderProps): JSX.Element { const [renamePromptPending, setRenamePromptPending] = React.useState(false); + // Which machine this agent is on has to be legible without expanding the + // card — a collapsed sidebar is the state people actually read. + const peerName = usePeerName(agent.peerId); const needsAttention = agent.status === "error"; const isJobAgent = agent.name.startsWith("job-"); const isAssistedUpdateAgent = agent.role === "assisted_update"; @@ -162,6 +167,31 @@ export function AgentCardHeader({ ) : null} + {peerName ? ( + + + + + {peerName} + + + + Runs on {peerName} +
+ + Linked instance — this card mirrors it + +
+
+ ) : null} + {needsAttention ? ( - ( - await api<{ peers: Array<{ id: string; name: string }> }>( - "/api/v1/peers" - ) - ).peers, - enabled: Boolean(agent.peerId), - staleTime: 60_000, - }); - const peerDisplayName = - linkedPeers?.find((peer) => peer.id === agent.peerId)?.name ?? agent.peerId; const isTerminalAgent = agent.type === "terminal"; const { diffStats, refresh: refreshDiffStats } = useAgentDiffStats( agent.id, @@ -212,16 +196,8 @@ export function AgentCard({ {agent.lastError ? ( ) : null} - {agent.peerId ? ( -

- - Location - - - {peerDisplayName} - -
- ) : null} + {/* Location is not repeated here — the header badge carries it, + and it has to be legible collapsed anyway. */} {agent.persona ? (
diff --git a/apps/web/src/components/app/agents-view-header.tsx b/apps/web/src/components/app/agents-view-header.tsx index 35634774..017c0a34 100644 --- a/apps/web/src/components/app/agents-view-header.tsx +++ b/apps/web/src/components/app/agents-view-header.tsx @@ -32,6 +32,7 @@ type AgentsViewHeaderProps = { setMediaOpen: (open: boolean) => void; unseenMediaCount: number; unreadMessageCount: number; + terminalTabLabel?: string; }; export function AgentsViewHeader({ @@ -55,6 +56,7 @@ export function AgentsViewHeader({ setMediaOpen, unseenMediaCount, unreadMessageCount, + terminalTabLabel, }: AgentsViewHeaderProps): JSX.Element { return (
@@ -117,6 +119,7 @@ export function AgentsViewHeader({ onTabChange(tab); }} whiteboardAgentDrew={whiteboardAgentDrew} + terminalTabLabel={terminalTabLabel} isSplit={isSplit} splitState={splitState} isMobile={isMobile} diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index 130e8ad5..78721f96 100644 --- a/apps/web/src/components/app/agents-view.tsx +++ b/apps/web/src/components/app/agents-view.tsx @@ -31,6 +31,7 @@ import { BottomBar } from "@/components/app/bottom-bar"; import { TerminalCopyModeBannerLayer } from "@/components/app/terminal-copy-mode-banner"; import { MobileTerminalToolbar } from "@/components/app/mobile-terminal-toolbar"; import { SidebarShell, type NavSection } from "@/components/app/sidebar-shell"; +import { RemoteActivityPane } from "@/components/app/remote-activity-pane"; import { TerminalPane } from "@/components/app/terminal-pane"; import { type Agent, @@ -205,11 +206,18 @@ export function AgentsView({ resortAgents(); }, [connectedAgentId, resortAgents]); - const focusedAgentId = resyncing - ? validatedSelectedAgentId - : connState === "connected" || connState === "reconnecting" - ? (connectedAgentId ?? validatedSelectedAgentId) - : null; + // A shadow agent is focused by selection alone: it never holds a terminal, so + // gating focus on connection state would leave its activity pane unreachable. + const selectedIsShadow = Boolean( + validatedSelectedAgentId && + agents.find((agent) => agent.id === validatedSelectedAgentId)?.peerId + ); + const focusedAgentId = + resyncing || selectedIsShadow + ? validatedSelectedAgentId + : connState === "connected" || connState === "reconnecting" + ? (connectedAgentId ?? validatedSelectedAgentId) + : null; const focusedAgent = focusedAgentId ? (agents.find((agent) => agent.id === focusedAgentId) ?? null) : null; @@ -454,7 +462,12 @@ export function AgentsView({ const isAttached = connState === "connected" && Boolean(connectedAgentId); const hasActiveAgent = Boolean(validatedSelectedAgentId); - const terminalElement = ( + // A shadow agent's pane is its event timeline, not a terminal: the tmux + // session is on another machine, and its events are already structured data + // that a relayed terminal would have flattened back into text. + const terminalElement = focusedAgent?.peerId ? ( + + ) : (
) : ( <> @@ -666,7 +683,9 @@ export function AgentsView({ ) : null}
- {isMobile ? ( + {/* No keyboard for a machine we have no shell on — the toolbar + would sit disabled under the timeline eating vertical space. */} + {isMobile && !focusedAgent?.peerId ? ( ) => void; onExitSplit: () => void; + /** Mirrors the tab bar's override so both surfaces name the pane alike. */ + terminalTabLabel?: string; }; /** @@ -42,7 +44,12 @@ export function CenterPaneSplit({ isMobile, onLayoutChange, onExitSplit, + terminalTabLabel, }: CenterPaneSplitProps): JSX.Element { + const labelFor = (tab: CenterTab): string => + tab === "terminal" + ? (terminalTabLabel ?? TAB_LABELS.terminal) + : TAB_LABELS[tab]; return (
- {TAB_LABELS[splitState.left]} + {labelFor(splitState.left)} {splitState.left === "changes" && !isMobile ? ( @@ -84,7 +91,7 @@ export function CenterPaneSplit({
- {TAB_LABELS[splitState.right]} + {labelFor(splitState.right)} {splitState.right === "changes" && !isMobile ? ( diff --git a/apps/web/src/components/app/center-pane-tab-bar.tsx b/apps/web/src/components/app/center-pane-tab-bar.tsx index 617a8e82..16aa7432 100644 --- a/apps/web/src/components/app/center-pane-tab-bar.tsx +++ b/apps/web/src/components/app/center-pane-tab-bar.tsx @@ -33,6 +33,12 @@ type CenterPaneTabBarProps = { isSplit: boolean; splitState: SplitPaneState; isMobile: boolean; + /** + * Shadow agents open to an event timeline instead of a terminal — the tmux + * pane is on another machine. Same tab id, so split/routing state is + * untouched; only what it is called changes. + */ + terminalTabLabel?: string; }; export const CenterPaneTabBar = memo(function CenterPaneTabBar({ @@ -42,6 +48,7 @@ export const CenterPaneTabBar = memo(function CenterPaneTabBar({ isSplit, splitState, isMobile, + terminalTabLabel, }: CenterPaneTabBarProps): JSX.Element { const splitTabs = isSplit ? new Set([splitState.left, splitState.right]) @@ -86,7 +93,9 @@ export const CenterPaneTabBar = memo(function CenterPaneTabBar({ }} > - {tab.label} + {tab.id === "terminal" + ? (terminalTabLabel ?? tab.label) + : tab.label} {tab.id === "whiteboard" && whiteboardAgentDrew && activeTab !== "whiteboard" ? ( diff --git a/apps/web/src/components/app/linked-instances-settings.tsx b/apps/web/src/components/app/linked-instances-settings.tsx index 5c426ffd..e1af49aa 100644 --- a/apps/web/src/components/app/linked-instances-settings.tsx +++ b/apps/web/src/components/app/linked-instances-settings.tsx @@ -31,11 +31,7 @@ type PeerSelf = { enabled: boolean; active: boolean; address: string | null; - blockedReason: - | "no-password" - | "no-tailscale" - | "wildcard-host" - | null; + blockedReason: "no-password" | "no-tailscale" | "wildcard-host" | null; }; }; @@ -43,6 +39,7 @@ type Capabilities = { allowLaunch: boolean; allowMessage: boolean; allowFullAccess: boolean; + allowEvents: boolean; }; type Peer = Capabilities & { @@ -66,6 +63,7 @@ const DEFAULT_CAPABILITIES: Capabilities = { allowLaunch: true, allowMessage: true, allowFullAccess: false, + allowEvents: true, }; const CAPABILITY_LABELS: { @@ -88,9 +86,14 @@ const CAPABILITY_LABELS: { label: "Allow full access", hint: "Let launched agents run with the sandbox off.", }, + { + key: "allowEvents", + label: "Share agent activity", + hint: "Send what agents here are doing — the status messages they write, not just whether they are running.", + }, ]; -/** The three switches shown on both the accept and connect cards. */ +/** The capability switches shown on both the accept and connect cards. */ function CapabilitySwitches({ value, onChange, @@ -151,9 +154,8 @@ export function LinkedInstancesSettings(): JSX.Element { onSettled: () => queryClient.invalidateQueries({ queryKey: selfQueryKey }), }); - const [offerCaps, setOfferCaps] = useState( - DEFAULT_CAPABILITIES - ); + const [offerCaps, setOfferCaps] = + useState(DEFAULT_CAPABILITIES); const offerMutation = useMutation({ mutationFn: () => api("/api/v1/peers/pairings", { @@ -322,12 +324,13 @@ export function LinkedInstancesSettings(): JSX.Element { )} {/* Not a failure: the server binds all interfaces, so the tailnet is already served and a second listener would collide on the port. */} - {self?.bind.enabled && self.bind.blockedReason === "wildcard-host" && ( -

- Already reachable on the tailnet — this server binds all - interfaces, so no separate listener is needed. -

- )} + {self?.bind.enabled && + self.bind.blockedReason === "wildcard-host" && ( +

+ Already reachable on the tailnet — this server binds all + interfaces, so no separate listener is needed. +

+ )} {self?.bind.active && self.bind.address && (

Listening on {self.bind.address} @@ -538,6 +541,9 @@ export function LinkedInstancesSettings(): JSX.Element { {peer.allowFullAccess && ( full access )} + {peer.allowEvents && ( + sees activity here + )}

)} {renamingId === peer.id && renameMutation.isError && ( diff --git a/apps/web/src/components/app/remote-activity-pane.tsx b/apps/web/src/components/app/remote-activity-pane.tsx new file mode 100644 index 00000000..bf7492f6 --- /dev/null +++ b/apps/web/src/components/app/remote-activity-pane.tsx @@ -0,0 +1,333 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { + AlertOctagon, + CheckCircle2, + CircleDashed, + HelpCircle, + Radio, + RotateCw, + Unplug, +} from "lucide-react"; + +import { latestEventLabel } from "@/components/app/agent-event-utils"; +import { type Agent } from "@/components/app/types"; +import { ActivityBars } from "@/components/ui/activity-bars"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { usePeerName } from "@/hooks/use-peers"; +import { api } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { formatDateTime, formatRelativeTime } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +/** Mirrors the endpoint's default cap, so the pane can say when it truncated. */ +const EVENT_LIMIT = 200; + +type EventType = NonNullable["type"]; + +type AgentEvent = { + id: number; + type: EventType; + message: string; + metadata?: Record | null; + createdAt: string; +}; + +/** + * Per-type presentation. `blocked` and `waiting_user` are what a person scans + * a timeline for, so they get a filled marker and a tinted rail; `working` is + * the overwhelming majority of entries and stays deliberately quiet. + */ +const EVENT_STYLES: Record< + EventType, + { + icon: typeof AlertOctagon; + dot: string; + text: string; + row: string; + loud: boolean; + } +> = { + blocked: { + icon: AlertOctagon, + dot: "border-status-blocked bg-status-blocked/20 text-status-blocked", + text: "text-status-blocked", + row: "border-status-blocked/35 bg-status-blocked/[0.07]", + loud: true, + }, + waiting_user: { + icon: HelpCircle, + dot: "border-status-waiting bg-status-waiting/20 text-status-waiting", + text: "text-status-waiting", + row: "border-status-waiting/35 bg-status-waiting/[0.07]", + loud: true, + }, + done: { + icon: CheckCircle2, + dot: "border-status-done bg-status-done/20 text-status-done", + text: "text-status-done", + row: "border-transparent", + loud: false, + }, + working: { + icon: CircleDashed, + dot: "border-status-working/60 bg-status-working/10 text-status-working", + text: "text-status-working", + row: "border-transparent", + loud: false, + }, + idle: { + icon: CircleDashed, + dot: "border-border bg-muted text-muted-foreground", + text: "text-muted-foreground", + row: "border-transparent", + loud: false, + }, +}; + +/** + * The system marker `markPeerUnreachable` stamps on a shadow when the link + * drops. Rendered as a visible break rather than an ordinary entry — the gap it + * announces is the honest part. + */ +function isLinkBreak(event: AgentEvent): boolean { + return event.metadata?.peerUnreachable === true; +} + +function LinkBreakRow({ + event, + peerName, +}: { + event: AgentEvent; + peerName: string | null; +}): JSX.Element { + return ( +
  • + + + + +
  • + ); +} + +function EventRow({ + event, + isLast, +}: { + event: AgentEvent; + isLast: boolean; +}): JSX.Element { + const style = EVENT_STYLES[event.type] ?? EVENT_STYLES.idle; + const Icon = style.icon; + + return ( +
  • + {/* Rail: marker plus the connector to the next entry. */} +
    + + + {/* No connector past the final marker — it would dangle into nothing. */} + {isLast ? null : } +
    + +
    +
    + + {latestEventLabel(event.type)} + + {/* Relative time is the only stamp in the pane; keep the exact + value reachable on hover and to assistive tech. */} + +
    +

    + {event.message} +

    +
    +
  • + ); +} + +/** + * What a shadow agent opens to instead of a terminal. Its tmux pane lives on + * another machine, but its events are already structured data — type, message, + * timestamp — so a timeline says more than a relayed terminal would, and says + * it without granting anyone a shell on the other box. + */ +export function RemoteActivityPane({ agent }: { agent: Agent }): JSX.Element { + const peerName = usePeerName(agent.peerId); + // Keyed on the latest event's timestamp so an arriving SSE upsert refetches + // the history — the live feed only ever carries the newest event, never the + // entries that preceded it. + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ["agent-events", agent.id, agent.latestEvent?.updatedAt ?? null], + queryFn: async () => + ( + await api<{ events: AgentEvent[] }>( + `/api/v1/agents/${agent.id}/events?limit=${EVENT_LIMIT}` + ) + ).events, + staleTime: 5_000, + // The key changes on every arriving event, so without this the list + // unmounts to a loading line and the scroll position resets each time — + // continuous blinking on a chatty remote agent. + placeholderData: keepPreviousData, + // One cache entry per event timestamp would otherwise accumulate for the + // lifetime of the tab. + gcTime: 30_000, + }); + + const events = data ?? []; + const stale = agent.latestEvent?.metadata?.peerUnreachable === true; + + return ( +
    +
    + + + {peerName ?? "Linked instance"} + + + {agent.name} + + + {stale ? ( + <> + + Link down — last known state + + ) : agent.status === "running" || agent.status === "creating" ? ( + <> + {/* Decorative here — the adjacent text carries the state, and + ActivityBars hardcodes role="status" aria-label="Loading". */} + + Mirroring live + + ) : ( + "Not running" + )} + + {/* The rail reads like a downward chronological log; it isn't. */} + + Newest first + +
    + + +
    + {isError ? ( +
    + +

    + Couldn't load activity from this instance. +

    +

    + This is a local problem reading the mirrored history — it says + nothing about whether the remote agent is working. +

    + +
    + ) : isLoading ? ( +

    Loading activity…

    + ) : events.length === 0 ? ( +
    + +

    + Nothing reported yet from {peerName ?? "the linked instance"}. +

    +

    + This agent's terminal runs on another machine. Its status events + appear here as it reports them. +

    +
    + ) : ( +
      + {events.map((event) => + isLinkBreak(event) ? ( + + ) : ( + + ) + )} + {/* A full page means older entries fell off the end. Distinct + from a link drop, which the footer's caveat covers. */} + {events.length >= EVENT_LIMIT ? ( +
    1. + + + Showing the last {EVENT_LIMIT} events + + +
    2. + ) : null} +
    + )} +
    +
    + + {/* Status is state, not content, in this design: nothing is replayed + after a drop. Saying so beats implying a complete log. */} +

    + Mirrored from {peerName ?? "the linked instance"} — status events only, + best effort. Entries can be missing where the link was down. +

    +
    + ); +} diff --git a/apps/web/src/hooks/use-agent-actions.ts b/apps/web/src/hooks/use-agent-actions.ts index b318aa98..aa24987f 100644 --- a/apps/web/src/hooks/use-agent-actions.ts +++ b/apps/web/src/hooks/use-agent-actions.ts @@ -49,9 +49,22 @@ export function useAgentActions({ navigate(agentRoute(agent.id)); ensureAuxExpanded(agent.parentAgentId ?? agent.id); refreshMedia(agent.id); + // A shadow agent has no tmux session on this machine — attaching would + // sit in "Connecting…" forever. Drop any terminal we still hold instead, + // so its chrome doesn't linger over the activity timeline. + if (agent.peerId) { + detachTerminal(); + return; + } await ensureTerminalConnected(true, true, agent.id); }, - [ensureAuxExpanded, ensureTerminalConnected, navigate, refreshMedia] + [ + detachTerminal, + ensureAuxExpanded, + ensureTerminalConnected, + navigate, + refreshMedia, + ] ); const startAgent = useCallback( diff --git a/apps/web/src/hooks/use-peers.ts b/apps/web/src/hooks/use-peers.ts new file mode 100644 index 00000000..366b9e24 --- /dev/null +++ b/apps/web/src/hooks/use-peers.ts @@ -0,0 +1,34 @@ +import { useQuery } from "@tanstack/react-query"; + +import { api } from "@/lib/api"; + +export type LinkedPeer = { + id: string; + name: string; +}; + +/** + * The linked instances this Dispatch knows about. Shared query key so the + * sidebar's per-card lookups and the settings pane resolve from one fetch — + * every agent card on screen would otherwise ask independently. + */ +export function useLinkedPeers(enabled = true) { + return useQuery({ + queryKey: ["peers", "list"], + queryFn: async () => + (await api<{ peers: LinkedPeer[] }>("/api/v1/peers")).peers, + enabled, + staleTime: 60_000, + }); +} + +/** + * What THIS machine calls the instance an agent runs on, or null for local + * agents. Falls back to the raw instance id until the peer list resolves — + * "inst_9f2c62a1b0" is unhelpful, but it beats a flash of nothing. + */ +export function usePeerName(peerId: string | null | undefined): string | null { + const { data } = useLinkedPeers(Boolean(peerId)); + if (!peerId) return null; + return data?.find((peer) => peer.id === peerId)?.name ?? peerId; +} diff --git a/apps/web/src/hooks/use-terminal.ts b/apps/web/src/hooks/use-terminal.ts index f93c09a7..8ad200db 100644 --- a/apps/web/src/hooks/use-terminal.ts +++ b/apps/web/src/hooks/use-terminal.ts @@ -374,6 +374,20 @@ export function useTerminal(args: { if (!isCurrentAttempt() || !agent) return; + // Shadow rows have no tmux session on this machine. Bail before any + // connect attempt — every entry point lands here (route auto-attach, + // visibility/online reconnect), so gating callers individually would + // leave a permanent "Connecting…" in the status live region. + if (agent.peerId) { + shouldKeepAttachedRef.current = false; + clearReconnectTimer(); + closeSocket(false); + resetTerminalSurface(); + setConnState("disconnected"); + setStatusMessage(""); + return; + } + if (agent.status !== "running" && agent.status !== "creating") { shouldKeepAttachedRef.current = false; clearReconnectTimer(); From 00c9dfd0cb92d54deba557134205a83ddf06a2ff Mon Sep 17 00:00:00 2001 From: Luke Brevoort Date: Tue, 18 Aug 2026 13:21:31 -0600 Subject: [PATCH 11/14] fix(auth): fall back to remoteAddress for the first-run loopback gate Injected test requests have no socket.localAddress, so the loopback gate 401'd every passwordless inject() suite. The fallback still requires a loopback address, so the first-run invariant is unchanged. Co-Authored-By: Claude Fable 5 --- apps/server/src/server.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 79953241..bd5d4f0e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -583,7 +583,11 @@ async function registerRoutes() { // tailnet), so a listener-address test passes every tailnet and LAN // caller straight into open mode. if (!(await authRuntime.isPasswordSetCached())) { - if (!TailnetListener.isLoopback(request.socket.localAddress)) { + // Injected requests (tests) have no localAddress; their fake socket + // reports a loopback remoteAddress, and the loopback bar still applies. + const arrivedOn = + request.socket.localAddress ?? request.socket.remoteAddress; + if (!TailnetListener.isLoopback(arrivedOn)) { return reply.code(401).send({ error: "Authentication required." }); } return; From 203ef2882cfc52aa9369fca9e7f78abbcb3ebe21 Mon Sep 17 00:00:00 2001 From: Luke Brevoort Date: Tue, 18 Aug 2026 13:36:44 -0600 Subject: [PATCH 12/14] feat(peers): fold cloud sessions into the v0.35 sub-agent standard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remote launches now honor the child contract: a peer-launched shadow nests under the launcher's Sub Agents section (child: false stays top-level), and launched_by_agent_id records provenance either way. The sub agent row is shadow-aware — peer badge, View activity instead of terminal attach, and only Archive in the overflow menu since pause/resume/settings act on a tmux session this machine doesn't have. Co-Authored-By: Claude Fable 5 --- apps/server/src/agents/manager.ts | 6 +- apps/server/src/peers/launch.ts | 11 ++- apps/server/src/server/mcp-handlers.ts | 6 +- .../components/app/child-agent-row.test.tsx | 90 ++++++++++++++----- .../src/components/app/child-agent-row.tsx | 62 ++++++++++--- 5 files changed, 138 insertions(+), 37 deletions(-) diff --git a/apps/server/src/agents/manager.ts b/apps/server/src/agents/manager.ts index 5a72dfc6..e148577e 100644 --- a/apps/server/src/agents/manager.ts +++ b/apps/server/src/agents/manager.ts @@ -599,11 +599,12 @@ export class AgentManager { cwd: string; status?: AgentStatus; parentAgentId?: string; + launchedByAgentId?: string; }): Promise { const id = this.newAgentId(); await this.pool.query( - `INSERT INTO agents (id, name, type, role, status, cwd, peer_id, remote_id, parent_agent_id, codex_args, updated_at) - VALUES ($1, $2, $3, 'standard', $4, $5, $6, $7, $8, '[]'::jsonb, NOW())`, + `INSERT INTO agents (id, name, type, role, status, cwd, peer_id, remote_id, parent_agent_id, launched_by_agent_id, codex_args, updated_at) + VALUES ($1, $2, $3, 'standard', $4, $5, $6, $7, $8, $9, '[]'::jsonb, NOW())`, [ id, input.name, @@ -613,6 +614,7 @@ export class AgentManager { input.peerId, input.remoteId, input.parentAgentId ?? null, + input.launchedByAgentId ?? input.parentAgentId ?? null, ] ); return await this.getRequiredAgent(id); diff --git a/apps/server/src/peers/launch.ts b/apps/server/src/peers/launch.ts index fa4de7d6..d5c56577 100644 --- a/apps/server/src/peers/launch.ts +++ b/apps/server/src/peers/launch.ts @@ -37,7 +37,9 @@ async function assertLaunchableCwd( try { resolved = await fs.realpath(path.resolve(requested)); } catch { - throw new Error(`Directory "${requested}" does not exist on this instance.`); + throw new Error( + `Directory "${requested}" does not exist on this instance.` + ); } for (const root of roots) { @@ -347,7 +349,9 @@ export async function launchAgentOnPeer( model?: string; cwd: string; fullAccess?: boolean; - parentAgentId: string; + /** Unset for `child: false` launches — the shadow stays top-level. */ + parentAgentId?: string; + launchedByAgentId: string; } ): Promise<{ shadowAgentId: string; remoteAgentId: string; name: string }> { const fetchImpl = deps.fetchImpl ?? fetch; @@ -359,7 +363,7 @@ export async function launchAgentOnPeer( model: input.model, cwd: input.cwd, fullAccess: input.fullAccess, - parentAddress: `${instanceId}:${input.parentAgentId}`, + parentAddress: `${instanceId}:${input.launchedByAgentId}`, }; let response: Response; try { @@ -397,6 +401,7 @@ export async function launchAgentOnPeer( cwd: input.cwd, status: asAgentStatus(result.status) ?? "creating", parentAgentId: input.parentAgentId, + launchedByAgentId: input.launchedByAgentId, }); // Belt and braces for the same race: ask the subscriber to re-snapshot this // peer so any transition we missed between launch and insert is reconciled. diff --git a/apps/server/src/server/mcp-handlers.ts b/apps/server/src/server/mcp-handlers.ts index 8d1db356..f28e15b1 100644 --- a/apps/server/src/server/mcp-handlers.ts +++ b/apps/server/src/server/mcp-handlers.ts @@ -636,6 +636,7 @@ async function handleLaunchAgentOnPeer( templateId?: string; cwd?: string; location?: string; + child?: boolean; } ): Promise<{ agentId: string; name: string; note?: string }> { if (input.templateId) { @@ -671,7 +672,10 @@ async function handleLaunchAgentOnPeer( model: input.model, cwd: input.cwd, fullAccess: input.fullAccess, - parentAgentId: agentId, + // Same lineage contract as a local launch: `child: false` keeps the + // shadow out of the launcher's card, but the launcher stays recorded. + parentAgentId: input.child !== false ? agentId : undefined, + launchedByAgentId: agentId, } ); const shadow = await deps.agentManager.getAgent(result.shadowAgentId); diff --git a/apps/web/src/components/app/child-agent-row.test.tsx b/apps/web/src/components/app/child-agent-row.test.tsx index 20c480bc..3bbd2679 100644 --- a/apps/web/src/components/app/child-agent-row.test.tsx +++ b/apps/web/src/components/app/child-agent-row.test.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import type { ComponentProps } from "react"; import { MemoryRouter } from "react-router-dom"; @@ -48,27 +49,33 @@ function renderRow( const setDeleteTarget = vi.fn(); const setDeleteConfirmOpen = vi.fn(); const onEditSettings = vi.fn(); + // usePeerName resolves peer display names through react-query. + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, enabled: false } }, + }); render( - - - - - + + + + + + + ); return { attachToAgent, @@ -198,6 +205,33 @@ describe("ChildAgentRow", () => { expect(row.className).not.toContain("child-agent-review-active-row"); }); + it("routes a shadow row to remote activity instead of a terminal", () => { + const shadow = { + ...baseAgent, + role: "standard" as const, + persona: null, + peerId: "inst_remote", + }; + const { attachToAgent, startAgent } = renderRow(shadow, { + state: "stopped", + }); + + // Peer badge names the machine (raw id until the peer list resolves). + expect( + screen.getByTestId("child-agent-peer-badge-agt_child").textContent + ).toContain("inst_remote"); + // No resume even when stopped — there is no local session to start. + expect(screen.queryByTestId("child-agent-resume-agt_child")).toBeNull(); + + const activityButton = screen.getByTestId("child-agent-attach-agt_child"); + expect(activityButton.getAttribute("aria-label")).toContain( + "View activity" + ); + fireEvent.click(activityButton); + expect(attachToAgent).toHaveBeenCalledWith(shadow); + expect(startAgent).not.toHaveBeenCalled(); + }); + it("detaches to the detached state without attaching another agent", () => { const { attachToAgent, detachTerminal } = renderRow(baseAgent, { state: "active", @@ -259,6 +293,22 @@ describe("ChildAgentRow", () => { expect(onEditSettings).toHaveBeenCalledWith(baseAgent); }); + it("offers only archive for a shadow of a remote agent", () => { + const shadow = { + ...baseAgent, + role: "standard" as const, + persona: null, + peerId: "inst_remote", + }; + const { setDeleteTarget } = renderRow(shadow); + + openMenu(); + expect(screen.queryByTestId("child-agent-pause-agt_child")).toBeNull(); + expect(screen.queryByTestId("child-agent-settings-agt_child")).toBeNull(); + fireEvent.click(screen.getByTestId("child-agent-archive-agt_child")); + expect(setDeleteTarget).toHaveBeenCalledWith(shadow); + }); + it("disables archive while the sub agent is already archiving", () => { renderRow({ ...baseAgent, status: "archiving" }); diff --git a/apps/web/src/components/app/child-agent-row.tsx b/apps/web/src/components/app/child-agent-row.tsx index 04ca749d..45f0907d 100644 --- a/apps/web/src/components/app/child-agent-row.tsx +++ b/apps/web/src/components/app/child-agent-row.tsx @@ -4,6 +4,7 @@ import { Pause, Pencil, Play, + Radio, Terminal, Unplug, } from "lucide-react"; @@ -28,6 +29,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { usePeerName } from "@/hooks/use-peers"; import { formatRelativeTime } from "@/lib/format"; import { cn } from "@/lib/utils"; @@ -68,6 +70,10 @@ export function ChildAgentRow({ }: ChildAgentRowProps): JSX.Element { const isStopped = state === "stopped"; const isArchiving = agent.status === "archiving"; + // A shadow of an agent on a linked instance: no tmux session here, so the + // row offers the activity timeline instead of terminal/pause/resume. + const isShadow = Boolean(agent.peerId); + const peerName = usePeerName(agent.peerId); // The shared DropdownMenuItem is a plain block styled for destructive items; // these need inline icons and the normal foreground colour. const menuItemClass = @@ -142,6 +148,16 @@ export function ChildAgentRow({ > {displayName} + {isShadow && peerName ? ( + + + {peerName} + + ) : null} {isReviewAgent ? (
    - {isStopped ? ( + {isShadow ? ( + + + + + View remote activity + + ) : isStopped ? (