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