Skip to content

Commit bb792f7

Browse files
arul28claude
andauthored
feat(remote): connect paired desktops over sync (#766)
* fix(remote): disk preflight, structured connect errors, extract cleanup, OS-aware peers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(remote): paired runtime rpc channel + port-forward over sync websocket (transport layer) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(remote): paired-first connect orchestration, pairing IPC, route status, connection doctor Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(remote): machines panel redesign — status sections, pair tab, share card, doctor UI, discovery self-filter+merge Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(remote): gate runtime channel to desktop peers, cap per-peer channels/forwards, SSH fallback on paired compat, document relay trust Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(remote): harden paired fallback and machine lifecycle * ship: iteration 1 — grant runtime pairing, persist relay routes * ship: iteration 2 — gate desktop grants, fix pairing IPC test * ship: iteration 3 — close paired transports, skip CRDT backlog * ship: iteration 4 — expose desktop pairing through local RPC * ship: iteration 5 — finalize remote reliability sweep --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent d328ab4 commit bb792f7

73 files changed

Lines changed: 10437 additions & 1070 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/ade-cli/src/cli.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14097,6 +14097,7 @@ async function runServe(
1409714097
{ createBrainProjectActionsSyncHandler },
1409814098
{ buildRosterSnapshot, createForeignChatTranscriptResolver },
1409914099
{ createSyncCloudRelayStore },
14100+
{ setSyncRuntimeRpcHandlerFactory },
1410014101
] = await Promise.all([
1410114102
import("./services/projects/machineLayout"),
1410214103
import("./services/projects/projectRegistry"),
@@ -14108,6 +14109,7 @@ async function runServe(
1410814109
import("./services/sync/brainProjectActionsSyncHandler"),
1410914110
import("./services/sync/rosterBuilder"),
1411014111
import("./services/sync/syncCloudRelayStore"),
14112+
import("./services/sync/syncPairedChannelService"),
1411114113
]);
1411214114

1411314115
const layout = resolveMachineAdeLayout();
@@ -14496,6 +14498,7 @@ async function runServe(
1449614498
},
1449714499
});
1449814500
const previousRole = process.env.ADE_DEFAULT_ROLE;
14501+
let clearSyncRuntimeRpcHandlerFactory: (() => void) | null = null;
1449914502
process.env.ADE_DEFAULT_ROLE = options.role;
1450014503
try {
1450114504

@@ -14518,6 +14521,7 @@ async function runServe(
1451814521
disposeScopesOnDispose: false,
1451914522
onShutdown: finish,
1452014523
});
14524+
clearSyncRuntimeRpcHandlerFactory = setSyncRuntimeRpcHandlerFactory(createHandler);
1452114525
const startSyncHost = async () => {
1452214526
if (preferredSyncProjectId) {
1452314527
return await scopeRegistry.switchSyncHost(preferredSyncProjectId);
@@ -14554,6 +14558,8 @@ async function runServe(
1455414558
if (sharedSyncListener) {
1455514559
await sharedSyncListener.close().catch(() => {});
1455614560
}
14561+
clearSyncRuntimeRpcHandlerFactory?.();
14562+
clearSyncRuntimeRpcHandlerFactory = null;
1455714563
};
1455814564

1455914565
const listen = async (
@@ -14689,6 +14695,7 @@ async function runServe(
1468914695
}
1469014696
return null;
1469114697
} finally {
14698+
clearSyncRuntimeRpcHandlerFactory?.();
1469214699
removeRuntimeProcessErrorBoundary();
1469314700
if (previousRole == null) delete process.env.ADE_DEFAULT_ROLE;
1469414701
else process.env.ADE_DEFAULT_ROLE = previousRole;

apps/ade-cli/src/multiProjectRpcServer.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,60 @@ describe("multi-project RPC server", () => {
365365
handler.dispose();
366366
});
367367

368+
it("dispatches desktop pairing info through the daemon's trusted local sync surface", async () => {
369+
const { projectRoot, registry } = createRegistry();
370+
const added = registry.add(projectRoot);
371+
const pairingInfo = {
372+
pairingUrl: "https://app.ade-app.dev/pair#desktop-grant",
373+
code: "123456",
374+
pinConfigured: true,
375+
machineName: "Mac Studio",
376+
relayEnabled: true,
377+
hasRelayCandidate: true,
378+
};
379+
const executeRemoteCommand = vi.fn(async () => pairingInfo);
380+
const scopeRegistry = {
381+
get: vi.fn(),
382+
ensureSyncHost: vi.fn(),
383+
switchSyncHost: vi.fn(),
384+
resolveActiveSyncHost: vi.fn(async () => ({
385+
registryProjectId: added.projectId,
386+
record: added,
387+
runtime: { syncService: { executeRemoteCommand } },
388+
dispose: vi.fn(),
389+
})),
390+
dispose: vi.fn(),
391+
disposeAll: vi.fn(),
392+
} as unknown as ProjectScopeRegistry;
393+
const handler = createMultiProjectRpcRequestHandler({
394+
serverVersion: "test",
395+
projectRegistry: registry,
396+
scopeRegistry,
397+
});
398+
399+
await handler({
400+
jsonrpc: "2.0",
401+
id: 1,
402+
method: "ade/initialize",
403+
params: {},
404+
});
405+
406+
await expect(handler({
407+
jsonrpc: "2.0",
408+
id: 2,
409+
method: "sync.getDesktopPairingInfo",
410+
params: {},
411+
})).resolves.toEqual(pairingInfo);
412+
expect(executeRemoteCommand).toHaveBeenCalledWith({
413+
commandId: expect.stringMatching(/^local-runtime-/),
414+
action: "sync.getDesktopPairingInfo",
415+
args: {},
416+
});
417+
expect(scopeRegistry.resolveActiveSyncHost).toHaveBeenCalledTimes(1);
418+
419+
handler.dispose();
420+
});
421+
368422
it("does not switch the active sync host for read-only sync polls with a projectId", async () => {
369423
const { root, projectRoot, registry } = createRegistry();
370424
const active = registry.add(projectRoot);

apps/ade-cli/src/multiProjectRpcServer.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createAdeRpcRequestHandler } from "./adeRpcServer";
2-
import { createHash } from "node:crypto";
2+
import { createHash, randomUUID } from "node:crypto";
33
import fs from "node:fs";
44
import os from "node:os";
55
import path from "node:path";
@@ -96,6 +96,7 @@ const RUNTIME_METHODS = new Set([
9696
"sync.getRuntimeName",
9797
"sync.setRuntimeName",
9898
"sync.clearRuntimeName",
99+
"sync.getDesktopPairingInfo",
99100
"sync.setActiveLanePresence",
100101
"sync.getCloudRelayStatus",
101102
"sync.setCloudRelayEnabled",
@@ -774,6 +775,18 @@ export function createMultiProjectRpcRequestHandler(
774775
return await (await getSyncService()).clearRuntimeName();
775776
}
776777

778+
if (method === "sync.getDesktopPairingInfo") {
779+
const syncService = await getSyncService();
780+
// This daemon socket is the trusted desktop-local surface. The paired
781+
// command path still consults the descriptor's viewerAllowed=false
782+
// policy before it can reach the same handler.
783+
return await syncService.executeRemoteCommand({
784+
commandId: `local-runtime-${randomUUID()}`,
785+
action: "sync.getDesktopPairingInfo",
786+
args: {},
787+
});
788+
}
789+
777790
if (method === "sync.getCloudRelayStatus") {
778791
return (await getSyncService()).getCloudRelayStatus();
779792
}

apps/ade-cli/src/services/sync/brainProjectActionsSyncHandler.ts

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,12 @@ import { nowIso } from "../../../../desktop/src/main/services/shared/utils";
3232
import type { SharedSyncListenerConnectionHandler } from "./sharedSyncListener";
3333
import { SYNC_HOST_BIND_LOOPBACK_ONLY } from "./sharedSyncListener";
3434
import type { SyncCredentialStore } from "../credentials/credentialStore";
35-
import { createSyncPairingStore } from "./syncPairingStore";
35+
import { createSyncPairingStore, type SyncPairingRecord } from "./syncPairingStore";
3636
import { createSyncDpopNonceCache, evaluatePairedHelloDpop } from "./syncDpop";
37+
import {
38+
createSyncPairedChannelService,
39+
isPairedRuntimeEnvelopeType,
40+
} from "./syncPairedChannelService";
3741
import { createSyncPinStore } from "./syncPinStore";
3842
import { createSyncSecurityStore } from "./syncSecurityStore";
3943
import {
@@ -46,6 +50,7 @@ import {
4650
import {
4751
buildSyncHostHelloOkPayload,
4852
buildSyncProjectCatalogMessages,
53+
isRuntimeHostPairingRecord,
4954
type SyncProjectCatalogProvider,
5055
} from "./syncHostService";
5156
import { resolveDeviceDisplayName } from "./deviceRegistryService";
@@ -71,9 +76,11 @@ type BrainProjectActionsSyncHandlerArgs = {
7176
type BrainPeerState = {
7277
ws: WebSocket;
7378
authenticated: boolean;
79+
authKind: "bootstrap" | "paired" | null;
7480
authTimeout: ReturnType<typeof setTimeout> | null;
7581
metadata: SyncPeerMetadata | null;
7682
personalChatSubscriptions: Map<string, { transcriptPath: string; offset: number }>;
83+
pairingRecord: SyncPairingRecord | null;
7784
};
7885

7986
const WS_OPEN = 1;
@@ -222,22 +229,33 @@ function parsePairingRequestPayload(payload: unknown): SyncPairingRequestPayload
222229
const code = optionalString(record.code);
223230
const peer = normalizePeerMetadata(record.peer);
224231
const dpopPublicKey = optionalString(record.dpopPublicKey);
225-
return code && peer ? { code, peer, ...(dpopPublicKey ? { dpopPublicKey } : {}) } : null;
232+
const runtimeHostGrant = optionalString(record.runtimeHostGrant);
233+
return code && peer ? {
234+
code,
235+
peer,
236+
...(dpopPublicKey ? { dpopPublicKey } : {}),
237+
...(runtimeHostGrant ? { runtimeHostGrant } : {}),
238+
} : null;
226239
}
227240

228241
function send(
229242
ws: WebSocket,
230243
type: SyncEnvelope["type"],
231244
payload: unknown,
232245
requestId?: string | null,
233-
): void {
234-
if (ws.readyState !== WS_OPEN) return;
235-
ws.send(encodeSyncEnvelope({
236-
type,
237-
requestId,
238-
payload,
239-
compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES,
240-
}));
246+
): boolean {
247+
if (ws.readyState !== WS_OPEN) return false;
248+
try {
249+
ws.send(encodeSyncEnvelope({
250+
type,
251+
requestId,
252+
payload,
253+
compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES,
254+
}));
255+
return true;
256+
} catch {
257+
return false;
258+
}
241259
}
242260

243261
function sendProjectCatalog(
@@ -416,6 +434,11 @@ export function createBrainProjectActionsSyncHandler(
416434
const pollIntervalMs = Math.max(100, Math.floor(args.pollIntervalMs ?? 1_500));
417435
const authTimeoutMs = Math.max(1_000, Math.floor(args.authTimeoutMs ?? BRAIN_SYNC_AUTH_TIMEOUT_MS));
418436
const pairFailures = createPairFailureTracker();
437+
const pairedChannelService = createSyncPairedChannelService<WebSocket>({
438+
logger: args.logger,
439+
getBufferedAmount: (ws) => ws.bufferedAmount,
440+
send: (ws, type, payload) => send(ws, type, payload),
441+
});
419442

420443
const brainMetadata = (): SyncPeerMetadata => ({
421444
deviceId: localDeviceId,
@@ -451,6 +474,17 @@ export function createBrainProjectActionsSyncHandler(
451474
};
452475

453476
const handleAuthenticatedEnvelope = async (peer: BrainPeerState, envelope: ReturnType<typeof parseSyncEnvelope>): Promise<void> => {
477+
if (isPairedRuntimeEnvelopeType(envelope.type)) {
478+
await pairedChannelService.handleEnvelope(
479+
peer.ws,
480+
envelope.type,
481+
envelope.payload,
482+
peer.authKind === "paired",
483+
// Runtime RPC channel + port-forward are desktop-runtime-host only.
484+
peer.authKind === "paired" && isRuntimeHostPairingRecord(peer.pairingRecord),
485+
);
486+
return;
487+
}
454488
switch (envelope.type) {
455489
case "project_catalog_request": {
456490
sendProjectCatalog(peer.ws, await projectCatalog(args.projectCatalogProvider, args.logger), envelope.requestId);
@@ -724,9 +758,11 @@ export function createBrainProjectActionsSyncHandler(
724758
const peer: BrainPeerState = {
725759
ws,
726760
authenticated: false,
761+
authKind: null,
727762
authTimeout: null,
728763
metadata: null,
729764
personalChatSubscriptions: new Map(),
765+
pairingRecord: null,
730766
};
731767
let personalChatPumpRunning = false;
732768
const personalChatPump = setInterval(() => {
@@ -809,6 +845,7 @@ export function createBrainProjectActionsSyncHandler(
809845
try {
810846
const paired = pairingStore.pairPeer(payload.peer, payload.code, {
811847
dpopPublicKey: payload.dpopPublicKey ?? null,
848+
runtimeHostGrant: payload.runtimeHostGrant ?? null,
812849
});
813850
pairFailures.clearAfterSuccess(remoteAddress ?? null);
814851
send(ws, "pairing_result", { ok: true, deviceId: paired.deviceId, secret: paired.secret }, envelope.requestId);
@@ -862,14 +899,15 @@ export function createBrainProjectActionsSyncHandler(
862899
return;
863900
}
864901
const auth = hello.auth;
902+
let authenticatedPairingRecord: SyncPairingRecord | null = null;
865903
const authFailed = (() => {
866904
if (auth?.kind === "paired") {
867905
if (auth.deviceId !== hello.peer.deviceId) return true;
868906
if (!pairingStore.authenticate(auth.deviceId, auth.secret)) return true;
869-
const record = pairingStore.getPairingRecord(auth.deviceId);
870-
if (!record) return true;
907+
authenticatedPairingRecord = pairingStore.getPairingRecord(auth.deviceId);
908+
if (!authenticatedPairingRecord) return true;
871909
const dpopFailure = evaluatePairedHelloDpop({
872-
storedPublicKey: record.dpopPublicKey,
910+
storedPublicKey: authenticatedPairingRecord.dpopPublicKey,
873911
deviceId: auth.deviceId,
874912
secret: auth.secret,
875913
proof: auth.dpop ?? null,
@@ -920,8 +958,10 @@ export function createBrainProjectActionsSyncHandler(
920958
return;
921959
}
922960
peer.authenticated = true;
961+
peer.authKind = auth?.kind ?? null;
923962
clearAuthTimeout();
924963
peer.metadata = hello.peer;
964+
peer.pairingRecord = auth?.kind === "paired" ? authenticatedPairingRecord : null;
925965
const catalog = await projectCatalog(args.projectCatalogProvider, args.logger);
926966
const brain = brainMetadata();
927967
const personalDescriptors = personalChatCommandDescriptors(args.personalChatScope);
@@ -940,6 +980,10 @@ export function createBrainProjectActionsSyncHandler(
940980
localCommandDescriptors: [],
941981
compressionThresholdBytes: DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES,
942982
cloudRelayWssUrl: args.getCloudRelayWssUrl?.() ?? null,
983+
// Advertise the runtime RPC channel + port-forward only to paired
984+
// desktop runtime-hosts (phones/browsers stay on the allowlist).
985+
runtimeChannelEnabled:
986+
auth?.kind === "paired" && isRuntimeHostPairingRecord(authenticatedPairingRecord),
943987
}), envelope.requestId);
944988
return;
945989
}
@@ -962,6 +1006,7 @@ export function createBrainProjectActionsSyncHandler(
9621006
clearAuthTimeout();
9631007
clearInterval(personalChatPump);
9641008
peer.personalChatSubscriptions.clear();
1009+
pairedChannelService.closePeer(ws, "Sync socket closed.", false);
9651010
});
9661011
};
9671012
}

0 commit comments

Comments
 (0)