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/agents/manager.ts b/apps/server/src/agents/manager.ts index 77274d12..e148577e 100644 --- a/apps/server/src/agents/manager.ts +++ b/apps/server/src/agents/manager.ts @@ -587,6 +587,55 @@ 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; + 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, launched_by_agent_id, codex_args, updated_at) + VALUES ($1, $2, $3, 'standard', $4, $5, $6, $7, $8, $9, '[]'::jsonb, NOW())`, + [ + id, + input.name, + input.type, + input.status ?? "creating", + input.cwd, + input.peerId, + input.remoteId, + input.parentAgentId ?? null, + input.launchedByAgentId ?? 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; @@ -982,6 +1031,20 @@ 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. + // 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 "${label}" — its terminal is not available here.`, + }; + } if (agent.status !== "running" && agent.status !== "creating") { throw new AgentError("Agent is not running.", 409); } @@ -1437,6 +1500,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/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/agents/types.ts b/apps/server/src/agents/types.ts index 324f302c..45813590 100644 --- a/apps/server/src/agents/types.ts +++ b/apps/server/src/agents/types.ts @@ -130,6 +130,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/0042_peers.sql b/apps/server/src/db/migrations/0042_peers.sql new file mode 100644 index 00000000..0044e21a --- /dev/null +++ b/apps/server/src/db/migrations/0042_peers.sql @@ -0,0 +1,125 @@ +-- 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_*) + -- 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 + created_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz, + 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 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, + 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, + -- 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 +); + +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, + 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, + 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; + +-- 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 +-- 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, + -- 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 (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, + 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..5b7483f3 --- /dev/null +++ b/apps/server/src/peers/events.ts @@ -0,0 +1,482 @@ +import type { FastifyBaseLogger } from "fastify"; +import type { Pool } from "pg"; + +import { appendAgentEventHistory } from "../agents/events.js"; +import type { AgentManager } from "../agents/manager.js"; +import type { AgentLatestEventType, AgentRecord } from "../agents/types.js"; +import { asAgentStatus } from "./status.js"; + +/** Shared by the shadow's live status and the break it leaves in the timeline. */ +export const PEER_UNREACHABLE_MESSAGE = + "Linked instance unreachable — status may be stale."; + +const RECONNECT_BASE_MS = 5_000; +const RECONNECT_MAX_MS = 60_000; +const PEER_RESCAN_INTERVAL_MS = 60_000; + +type RemoteLatestEvent = { + type?: string; + message?: string; + updatedAt?: string; + metadata?: Record | null; +}; + +type RemoteAgentShape = { + id?: string; + name?: string; + status?: string; + /** + * Absent when the peer did not grant `allow_events`, which is why the key's + * presence is read rather than its value — "not shared" and "no event yet" + * lead to different writes on the shadow. + */ + latestEvent?: RemoteLatestEvent | null; +}; + +/** Latest-event types the local column accepts; anything else is dropped. */ +const LATEST_EVENT_TYPES = new Set([ + "working", + "blocked", + "waiting_user", + "done", + "idle", +]); + +/** + * A remote event we are willing to write, or null. Everything is asserted by + * the other instance, so the type is checked against the local enum and the + * timestamp must parse — a bad value would otherwise poison the column that + * the whole timeline sorts and dedupes on. + */ +function usableRemoteEvent( + event: RemoteLatestEvent +): { type: string; message: string; metadata: string; at: string } | null { + const message = event.message?.trim(); + if (!message || !event.type || !LATEST_EVENT_TYPES.has(event.type)) { + return null; + } + const at = event.updatedAt ? new Date(event.updatedAt) : null; + if (!at || Number.isNaN(at.getTime())) return null; + return { + type: event.type, + message, + metadata: JSON.stringify(event.metadata ?? {}), + at: at.toISOString(), + }; +} + +type PeerRow = { id: string; url: string; outbound_token: string }; + +/** Identity of a subscription's dial parameters, so a rotation is detectable. */ +function dialFingerprint(peer: PeerRow): string { + return `${peer.url}\0${peer.outbound_token}`; +} + +/** + * 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< + string, + { controller: AbortController; fingerprint: string } + >(); + 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 entry of this.controllers.values()) entry.controller.abort(); + this.controllers.clear(); + } + + /** + * Drop this peer's stream so the loop reconnects and re-snapshots. Used after + * minting a shadow, to reconcile any transition that landed in the window + * between the remote launch and the local insert. + */ + requestResnapshot(peerId: string): void { + const entry = this.controllers.get(peerId); + if (entry) entry.controller.abort(); + this.controllers.delete(peerId); + void this.rescan(); + } + + /** 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 Map(peers.map((p) => [p.id, p])); + for (const [peerId, entry] of this.controllers) { + const peer = wanted.get(peerId); + // Revoked, or re-paired onto a new url/token. The id survives a re-pair, + // so without the fingerprint check the loop would keep presenting the + // OLD token forever and 401 on every reconnect. + if (!peer || dialFingerprint(peer) !== entry.fingerprint) { + entry.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, + fingerprint: dialFingerprint(peer), + }); + 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; + // A connected event stream is proof of life, and for a peer we only + // ever dial it is the ONLY proof we get: requirePeerAuth stamps + // last_seen_at when they call us, which never happens for a peer that + // just answers. Without this, listPeerLocations reports a perfectly + // healthy instance as "not responding recently" — straight into the + // launch tool description a model reads. + await this.markPeerSeen(peer.id); + 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" + ); + await this.markPeerUnreachable(peer.id); + } + 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) { + const changed = await this.mirrorRemoteAgent(peerId, event.agent); + if (changed) await this.publishOne(changed); + return; + } + if (event.type === "snapshot" && Array.isArray(event.agents)) { + const agents = event.agents.filter( + (agent): agent is RemoteAgentShape & { id: string } => + typeof agent?.id === "string" + ); + // Collect every id the snapshot touched, then broadcast in ONE pass. A + // snapshot replays after every reconnect, so publishing per agent would + // re-read the whole agent list once per remote agent. + const changed: string[] = []; + for (const agent of agents) { + const id = await this.mirrorRemoteAgent(peerId, agent); + if (id) changed.push(id); + } + changed.push( + ...(await this.reapMissingShadows( + peerId, + agents.map((a) => a.id) + )) + ); + await this.publishMany(changed); + } + } + + /** + * The snapshot is the peer's complete list of local agents, so any live + * shadow it does NOT mention is gone over there — deleted, or belonging to a + * link that no longer exists. Without this they sit in the sidebar forever at + * whatever status they held when the peer last spoke. + */ + private async reapMissingShadows( + peerId: string, + presentRemoteIds: string[] + ): Promise { + try { + const orphaned = await this.deps.pool.query<{ id: string }>( + `UPDATE agents + SET status = 'stopped', + latest_event_type = 'idle', + latest_event_message = 'Agent no longer exists on the linked instance.', + latest_event_updated_at = now(), + updated_at = now() + WHERE peer_id = $1 AND deleted_at IS NULL + AND status IN ('creating', 'running', 'stopping') + AND NOT (remote_id = ANY($2::text[])) + RETURNING id`, + [peerId, presentRemoteIds] + ); + return orphaned.rows.map((row) => row.id); + } catch (error) { + this.deps.log.warn( + { err: error, peerId }, + "Failed to reap shadows missing from peer snapshot" + ); + return []; + } + } + + /** Record that we reached this peer just now. Best-effort telemetry. */ + private async markPeerSeen(peerId: string): Promise { + try { + await this.deps.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')`, + [peerId] + ); + } catch (error) { + this.deps.log.debug( + { err: error, peerId }, + "Failed to stamp last_seen_at" + ); + } + } + + private async publishOne(id: string): Promise { + const agent = await this.deps.agentManager.getAgent(id); + if (agent) { + this.deps.publishUiEvent({ + type: "agent.upsert", + agent: this.deps.withStreamFlag(agent), + }); + } + } + + /** Rebroadcast a set of agents from one list read, not one read per id. */ + private async publishMany(ids: string[]): Promise { + if (ids.length === 0) return; + if (ids.length === 1) return await this.publishOne(ids[0]); + const wanted = new Set(ids); + const agents = await this.deps.agentManager.listAgents(); + for (const agent of agents) { + if (wanted.has(agent.id)) { + this.deps.publishUiEvent({ + type: "agent.upsert", + agent: this.deps.withStreamFlag(agent), + }); + } + } + } + + /** + * 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 }>( + // 'idle', not 'working': a peer that has been gone for days is not an + // agent making progress, and the status indicator reads the type, not + // the message. + `UPDATE agents + SET latest_event_type = 'idle', + latest_event_message = $2, + 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') + AND latest_event_metadata->>'peerUnreachable' IS DISTINCT FROM 'true' + RETURNING id`, + [peerId, PEER_UNREACHABLE_MESSAGE] + ); + // Also a history row, so the activity timeline shows a visible break + // rather than silently missing whatever the remote did while we were + // deaf. The UPDATE's marker guard means this fires once per disconnect. + for (const row of shadows.rows) { + appendAgentEventHistory(this.deps.pool, this.deps.log, row.id, { + type: "idle", + message: PEER_UNREACHABLE_MESSAGE, + metadata: { source: "system", peerUnreachable: true }, + }); + } + await this.publishMany(shadows.rows.map((row) => row.id)); + } catch (error) { + this.deps.log.warn( + { err: error, peerId }, + "Failed to mark shadows for unreachable peer" + ); + } + } + + private async mirrorRemoteAgent( + peerId: string, + remote: RemoteAgentShape + ): Promise { + try { + // Resolve and write in one statement — the old SELECT-then-UPDATE cost + // two round-trips per agent on a path that replays a whole snapshot after + // every reconnect. Clearing the unreachable stamp here is what makes a + // recovered peer's rows stop reading as stale; mirroring an event clears + // it implicitly, by replacing the metadata the marker lived in. + const status = asAgentStatus(remote.status); + const shared = "latestEvent" in remote; + const event = remote.latestEvent + ? usableRemoteEvent(remote.latestEvent) + : null; + // Three cases, and they write differently: not shared (leave the shadow's + // event columns alone), shared but empty (the remote agent genuinely has + // no event — mirror the emptiness), shared with an event (mirror it). + const clear = shared && !event; + const updated = await this.deps.pool.query<{ id: string }>( + `UPDATE agents + SET status = COALESCE($3, status), + name = COALESCE($4, name), + latest_event_type = + CASE WHEN $5::boolean THEN $6 WHEN $7::boolean THEN NULL + ELSE latest_event_type END, + latest_event_message = + CASE WHEN $5::boolean THEN $8 WHEN $7::boolean THEN NULL + ELSE latest_event_message END, + latest_event_updated_at = + CASE WHEN $5::boolean THEN $9::timestamptz + WHEN $7::boolean THEN NULL + ELSE latest_event_updated_at END, + latest_event_metadata = + CASE WHEN $5::boolean THEN $10::jsonb + WHEN latest_event_metadata->>'peerUnreachable' = 'true' + THEN latest_event_metadata - 'peerUnreachable' + ELSE latest_event_metadata END, + updated_at = now() + WHERE peer_id = $1 AND remote_id = $2 AND deleted_at IS NULL + RETURNING id`, + [ + peerId, + remote.id, + status ?? null, + remote.name ?? null, + Boolean(event), + event?.type ?? null, + clear, + event?.message ?? null, + event?.at ?? null, + event?.metadata ?? null, + ] + ); + const id = updated.rows[0]?.id ?? null; + // History under the SHADOW's local id, stamped with the remote's own + // timestamp — that stamp is also the dedupe key, so the snapshot replayed + // after every reconnect cannot duplicate events already recorded. + if (id && event) { + appendAgentEventHistory( + this.deps.pool, + this.deps.log, + id, + { + type: event.type as AgentLatestEventType, + message: event.message, + metadata: JSON.parse(event.metadata) as Record, + }, + event.at + ); + } + return id; + } catch (error) { + this.deps.log.warn( + { err: error, peerId, remoteId: remote.id }, + "Failed to mirror remote agent onto shadow row" + ); + return null; + } + } +} 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..d5c56577 --- /dev/null +++ b/apps/server/src/peers/launch.ts @@ -0,0 +1,414 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +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"; +import { asAgentStatus } from "./status.js"; + +/** + * Resolve a peer-supplied cwd and prove it sits inside a repo root this + * instance advertises. Both sides are fully resolved first (realpath follows + * symlinks, so `/tmp/link-to-etc` cannot masquerade as an allowed root), and + * the containment test is segment-wise — a plain `startsWith` would accept + * `/repos/app-evil` for the root `/repos/app`. + */ +async function assertLaunchableCwd( + pool: Pool, + requested: string +): Promise { + 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 + * 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.", + "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"); +} + +/** + * 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, + policy: { allowFullAccess: boolean } +): 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 + ); + + // 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; + + const agent = await deps.agentManager.createAgent({ + cliSessionId, + name: payload.name, + type: agentType as AgentType, + cwd, + fullAccess, + 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 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; + 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; + /** Reconciles anything missed in the launch/insert window. */ + requestPeerResnapshot?: (peerId: string) => void; + }, + peer: ResolvedPeer, + input: { + name: string; + prompt: string; + type: string; + model?: string; + cwd: string; + fullAccess?: boolean; + /** 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; + 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.launchedByAgentId}`, + }; + 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; + + // 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: 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. + deps.requestPeerResnapshot?.(peer.id); + 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..e592476a --- /dev/null +++ b/apps/server/src/peers/messages.ts @@ -0,0 +1,312 @@ +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; +/** + * 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; + 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 }; + } + + /** + * 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. + 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 + ); + 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, + dead_lettered_at = CASE WHEN $5 THEN now() ELSE dead_lettered_at END + WHERE id = $1`, + [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. + * + * 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 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) { + 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'` + ); + } 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..f21b5f45 --- /dev/null +++ b/apps/server/src/peers/pairing.ts @@ -0,0 +1,601 @@ +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; + +/** + * 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 = 2; + +/** + * 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 = { + 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; +} & 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"); +} + +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: 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, 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, + ] + ); + 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; + allow_message: boolean; + allow_full_access: boolean; + allow_events: boolean; + require_tailnet: boolean; + }>( + `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` + ); + 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." }; + } + 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, 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 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, + 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, allow_message, allow_full_access, allow_events) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + crypto.randomUUID(), + input.claimer.instanceId, + sha256(inboundToken), + callerStableId, + 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`, [ + 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 = Partial & { + /** Address of the accepting instance, e.g. cloud-vm.tailnet.ts.net:6767 */ + address: string; + code: string; + /** 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; +}; + +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); + const caps = { ...DEFAULT_CAPABILITIES, ...input }; + + 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({ + protocolVersion: PEER_PROTOCOL_VERSION, + 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; + 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 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, 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 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] + ); + 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, allow_message, allow_full_access, allow_events) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + crypto.randomUUID(), + body.instanceId, + sha256(reverseToken), + peerStableId, + caps.allowLaunch, + caps.allowMessage, + caps.allowFullAccess, + caps.allowEvents, + ] + ); + 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: 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; + 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_events + FROM peers p + LEFT JOIN LATERAL ( + 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 + ) 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, + 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, + allowEvents: row.allow_events ?? false, + })); +} + +/** + * 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 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 new file mode 100644 index 00000000..4b4ba178 --- /dev/null +++ b/apps/server/src/peers/peer-auth.ts @@ -0,0 +1,178 @@ +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; + allowMessage: boolean; + allowFullAccess: boolean; + allowEvents: 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. */ + 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; + } + + // 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; + 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_events, + 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`, + [sha256(token)] + ); + const row = result.rows[0]; + if (!row) { + 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 stableId = remote + ? await cachedWhoisStableId(`${stripMapped(remote)}:${port ?? 0}`) + : null; + if (!stableId || stableId !== row.tailnet_stable_id) { + await reply + .code(403) + .send({ error: "Caller does not match the paired tailnet node." }); + return; + } + } + + // 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, + allowEvents: row.allow_events, + }; +} + +/** 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..33a2aee6 --- /dev/null +++ b/apps/server/src/peers/runtime.ts @@ -0,0 +1,132 @@ +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" | "wildcard-host" | null; +}; + +export type PeerSelfStatus = { + instanceId: string; + passwordSet: boolean; + tailscale: TailscaleSelf | null; + bind: TailnetBindStatus; +}; + +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 — + * 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 (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( + "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 + : primaryBindsAllInterfaces(this.deps.primaryHost) + ? "wildcard-host" + : !passwordSet + ? "no-password" + : !tailscale + ? "no-tailscale" + : null, + }, + }; + } + + async shutdown(): Promise { + await this.deps.listener.stop(); + } +} diff --git a/apps/server/src/peers/status.ts b/apps/server/src/peers/status.ts new file mode 100644 index 00000000..7abd25e0 --- /dev/null +++ b/apps/server/src/peers/status.ts @@ -0,0 +1,25 @@ +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/peers/tailnet-listener.ts b/apps/server/src/peers/tailnet-listener.ts new file mode 100644 index 00000000..50985152 --- /dev/null +++ b/apps/server/src/peers/tailnet-listener.ts @@ -0,0 +1,109 @@ +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; + // 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) {} + + get address(): string | null { + return this.boundAddress; + } + + /** 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. + 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); + }); + 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); + 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?.(); + for (const socket of this.sockets) socket.destroy(); + this.sockets.clear(); + }); + 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/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/mcp.ts b/apps/server/src/routes/mcp.ts index 692e209f..87cac74e 100644 --- a/apps/server/src/routes/mcp.ts +++ b/apps/server/src/routes/mcp.ts @@ -15,6 +15,7 @@ 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"; @@ -206,11 +207,19 @@ 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(); // Captured before the transport writes anything, so an archive that stops // the calling session can wait for its own response to be delivered. const responseFinished = onceResponseFinished(reply.raw); await handleMcpRequest(request.raw, reply.raw, request.body, { + peerLocationHint, whenResponseFinished: () => responseFinished, agent: { id: agent.id, @@ -304,11 +313,19 @@ 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(); // Captured before the transport writes anything, so an archive that stops // the calling session can wait for its own response to be delivered. const responseFinished = onceResponseFinished(reply.raw); await handleMcpRequest(request.raw, reply.raw, request.body, { + peerLocationHint, whenResponseFinished: () => responseFinished, agent: { id: agent.id, diff --git a/apps/server/src/routes/peers.ts b/apps/server/src/routes/peers.ts new file mode 100644 index 00000000..b28bec2f --- /dev/null +++ b/apps/server/src/routes/peers.ts @@ -0,0 +1,461 @@ +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, + PEER_PROTOCOL_VERSION, + renamePeer, + 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), + allowMessage: z.boolean().default(true), + allowFullAccess: z.boolean().default(false), + allowEvents: z.boolean().default(true), + 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), + 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), + name: z.string().trim().min(1).max(120).optional(), + 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(), +}); + +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; +}; + +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 () => { + 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) => { + 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 (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) + .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 { + protocolVersion: PEER_PROTOCOL_VERSION, + 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, + { allowFullAccess: request.peerAuth!.allowFullAccess } + ); + 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; + // 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, + 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; + // Peers get a scoped view, not the full UI event firehose: only + // 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[] = []; + 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); + 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) { + 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 { + // Without a snapshot, buffered delivery would hold events forever — + // drop the connection so the peer reconnects and re-snapshots. + cleanup(); + stream.destroy(); + } + } + ); + + // 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) }; + } + ); + + // 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; + 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 91edbd1c..7a73675d 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,28 @@ 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(), + primaryHost: config.host, + 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 +457,10 @@ const mcpHandlers = createMcpHandlers({ withStreamFlag, sendAgentPrompt: injectAgentPrompt, appLog: app.log, + sendPeerPrompt: (peerId, targetAgentId, prompt) => + peerMessenger.sendPrompt(peerId, { targetAgentId, prompt }), + requestPeerResnapshot: (peerId) => + peerEventSubscriber.requestResnapshot(peerId), beginBackgroundArchive: (agentId, cleanupWorktree, opts) => agentLifecycleRuntime.beginBackgroundArchive( agentId, @@ -531,6 +569,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 @@ -538,8 +579,25 @@ 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) — 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())) { + // 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; + } // Bearer token is accepted on all API routes (for MCP agents, scripts, etc.) const authHeader = request.headers.authorization; @@ -653,6 +711,18 @@ 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), + }); + await registerSystemRoutes(app, { pool, appLog: app.log, @@ -866,6 +936,8 @@ export async function initializeApp(options?: { agentLifecycleRuntime.startReconcileLoop(); authRuntime.startSessionCleanupTimer(); autoCheckRuntime.startScheduler(); + peerMessenger.start(); + peerEventSubscriber.start(); } if (!routesRegistered) { await registerRoutes(); @@ -891,6 +963,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 { @@ -914,6 +992,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 0c49af7b..f28e15b1 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 buildLaunchedAgentInitialPrompt( launcherAgentId: string, @@ -103,6 +109,14 @@ 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 }>; + /** Re-snapshot a peer after minting a shadow, to close the launch race. */ + requestPeerResnapshot?: (peerId: string) => void; /** * Claim an archive and run its teardown in the background. Archiving cannot * be awaited here: the target may be the caller, whose session the teardown @@ -459,6 +473,7 @@ async function handleLaunchAgent( templateId?: string; templateArgs?: Record; cwd?: string; + location?: string; child?: boolean; } ): Promise<{ agentId: string; name: string; note?: string }> { @@ -488,6 +503,10 @@ async function handleLaunchAgent( ); } + 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]) @@ -598,6 +617,81 @@ 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; + child?: boolean; + } +): 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, + requestPeerResnapshot: deps.requestPeerResnapshot, + }, + resolved.peer, + { + name: input.name, + prompt: input.prompt, + type: input.type ?? parent.type ?? "claude", + model: input.model, + cwd: input.cwd, + fullAccess: input.fullAccess, + // 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); + 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}).`, + }; +} + /** * Grace period after the response is on the wire before teardown begins, * covering the gap between the kernel accepting the bytes and the agent @@ -791,6 +885,45 @@ 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. + // 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) { + 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); @@ -847,15 +980,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 f502f09a..9b6ced5f 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; child?: boolean; }; @@ -34,6 +35,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( @@ -126,7 +132,15 @@ 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( + "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." ), child: z .boolean() @@ -159,6 +173,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; if (args.child !== undefined) input.child = args.child; const result = await launchAgent(agentId, input); diff --git a/apps/server/src/shared/mcp/server.ts b/apps/server/src/shared/mcp/server.ts index e053ca86..79044db5 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: { @@ -638,6 +643,7 @@ async function createDispatchMcpServer( registerAgentLaunchTools(server, allowed, { agentId: context.agent.id, launchAgent: context.launchAgent, + peerLocationHint: context.peerLocationHint, }); } 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 new file mode 100644 index 00000000..f1c6621b --- /dev/null +++ b/apps/server/test/peers-pairing.test.ts @@ -0,0 +1,278 @@ +import { beforeAll, afterAll, describe, expect, it } from "vitest"; +import type { Pool } from "pg"; + +import { + claimPairing, + createPairingOffer, + linkToPeer, + listPeers, + PEER_PROTOCOL_VERSION, + 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({ + protocolVersion: PEER_PROTOCOL_VERSION, + 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", () => { + 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 { + 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-policy.test.ts b/apps/server/test/peers-policy.test.ts new file mode 100644 index 00000000..529d602c --- /dev/null +++ b/apps/server/test/peers-policy.test.ts @@ -0,0 +1,289 @@ +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 { + describePeerLocations, + handleIncomingPeerLaunch, + listPeerLocations, +} 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/); + }); +}); + +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" + ); + }); +}); 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..9dd5fd61 --- /dev/null +++ b/apps/server/test/peers-tailnet-listener.test.ts @@ -0,0 +1,107 @@ +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); + }); +}); + +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); + }); +}); 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-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 ? ( ) : 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/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 ? ( + +
+ +
+
+

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. +

+ )} + {/* 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} +

+ )} + + + + + + + Accept a connection from another instance + + + 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. + + + + {offer ? ( +
+

+ {offer.code} +

+

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

+
+ ) : ( + <> + + + + )} + {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. + + + +
{ + 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" + /> + setLinkName(e.target.value)} + placeholder="Call it…" + aria-label="Name for this instance" + className="sm:w-36" + /> + +
+

+ The name is yours alone — it's what agents here pass as the launch + location, and the other instance is never told. Leave it blank to + use whatever that instance calls itself. +

+ + {self && !self.passwordSet && ( +

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

+ )} + {linkMutation.isError && ( +

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

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

+ Could not load linked instances. +

+ ) : ( +
    + {peers.map((peer) => ( +
  • +
    + {renamingId === peer.id ? ( +
    { + event.preventDefault(); + renameMutation.mutate({ + id: peer.id, + name: renameDraft.trim(), + }); + }} + > + setRenameDraft(e.target.value)} + aria-label={`New name for ${peer.name}`} + autoFocus + className="h-8" + /> + + +
    + ) : ( +
    + {/* div, not p: Badge renders a div, which cannot + nest inside a paragraph. */} + {peer.name} + {peer.allowLaunch && can launch here} + {peer.allowMessage && ( + can message here + )} + {peer.allowFullAccess && ( + full access + )} + {peer.allowEvents && ( + sees activity here + )} +
    + )} + {renamingId === peer.id && renameMutation.isError && ( +

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

    + )} +

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

    +
    +
    + + +
    +
  • + ))} +
+ )} +
+
+ )} +
+ ); +} 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/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; 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(); 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