pwragent 0.0.0 → 1.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +494 -2
  2. package/bin/pwragent.js +10 -1
  3. package/dist/THIRD_PARTY_LICENSES +165 -0
  4. package/dist/federation-core.js +19569 -0
  5. package/dist/ws-LICENSE +20 -0
  6. package/examples/pwragent-headless.service +25 -0
  7. package/licenses/xterm-LICENSE +26 -0
  8. package/node_modules/node-addon-api/LICENSE.md +9 -0
  9. package/node_modules/node-addon-api/README.md +319 -0
  10. package/node_modules/node-addon-api/common.gypi +20 -0
  11. package/node_modules/node-addon-api/except.gypi +25 -0
  12. package/node_modules/node-addon-api/index.js +12 -0
  13. package/node_modules/node-addon-api/napi-inl.deprecated.h +186 -0
  14. package/node_modules/node-addon-api/napi-inl.h +6607 -0
  15. package/node_modules/node-addon-api/napi.h +3201 -0
  16. package/node_modules/node-addon-api/node_addon_api.gyp +32 -0
  17. package/node_modules/node-addon-api/node_api.gyp +9 -0
  18. package/node_modules/node-addon-api/noexcept.gypi +26 -0
  19. package/node_modules/node-addon-api/nothing.c +0 -0
  20. package/node_modules/node-addon-api/package-support.json +21 -0
  21. package/node_modules/node-addon-api/package.json +480 -0
  22. package/node_modules/node-addon-api/tools/README.md +73 -0
  23. package/node_modules/node-addon-api/tools/check-napi.js +99 -0
  24. package/node_modules/node-addon-api/tools/clang-format.js +71 -0
  25. package/node_modules/node-addon-api/tools/conversion.js +301 -0
  26. package/node_modules/node-addon-api/tools/eslint-format.js +79 -0
  27. package/node_modules/node-pty/LICENSE +69 -0
  28. package/node_modules/node-pty/README.md +164 -0
  29. package/node_modules/node-pty/binding.gyp +150 -0
  30. package/node_modules/node-pty/lib/conpty_console_list_agent.js +25 -0
  31. package/node_modules/node-pty/lib/eventEmitter2.js +47 -0
  32. package/node_modules/node-pty/lib/index.js +52 -0
  33. package/node_modules/node-pty/lib/interfaces.js +7 -0
  34. package/node_modules/node-pty/lib/shared/conout.js +10 -0
  35. package/node_modules/node-pty/lib/terminal.js +190 -0
  36. package/node_modules/node-pty/lib/types.js +7 -0
  37. package/node_modules/node-pty/lib/unixTerminal.js +351 -0
  38. package/node_modules/node-pty/lib/utils.js +38 -0
  39. package/node_modules/node-pty/lib/windowsConoutConnection.js +131 -0
  40. package/node_modules/node-pty/lib/windowsPtyAgent.js +344 -0
  41. package/node_modules/node-pty/lib/windowsTerminal.js +209 -0
  42. package/node_modules/node-pty/lib/worker/conoutSocketWorker.js +22 -0
  43. package/node_modules/node-pty/package.json +66 -0
  44. package/node_modules/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
  45. package/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
  46. package/node_modules/node-pty/prebuilds/darwin-x64/pty.node +0 -0
  47. package/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
  48. package/node_modules/node-pty/prebuilds/linux-arm64/pty.node +0 -0
  49. package/node_modules/node-pty/prebuilds/linux-x64/pty.node +0 -0
  50. package/node_modules/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe +0 -0
  51. package/node_modules/node-pty/prebuilds/win32-arm64/conpty/conpty.dll +0 -0
  52. package/node_modules/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
  53. package/node_modules/node-pty/prebuilds/win32-arm64/conpty.pdb +0 -0
  54. package/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
  55. package/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.pdb +0 -0
  56. package/node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe +0 -0
  57. package/node_modules/node-pty/prebuilds/win32-x64/conpty/conpty.dll +0 -0
  58. package/node_modules/node-pty/prebuilds/win32-x64/conpty.node +0 -0
  59. package/node_modules/node-pty/prebuilds/win32-x64/conpty.pdb +0 -0
  60. package/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
  61. package/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.pdb +0 -0
  62. package/node_modules/node-pty/scripts/post-install.js +76 -0
  63. package/node_modules/node-pty/scripts/prebuild.js +34 -0
  64. package/node_modules/node-pty/src/unix/pty.cc +876 -0
  65. package/node_modules/node-pty/src/unix/spawn-helper.cc +23 -0
  66. package/node_modules/node-pty/src/win/conpty.cc +616 -0
  67. package/node_modules/node-pty/src/win/conpty.h +41 -0
  68. package/node_modules/node-pty/src/win/conpty_console_list.cc +44 -0
  69. package/node_modules/node-pty/src/win/path_util.cc +95 -0
  70. package/node_modules/node-pty/src/win/path_util.h +26 -0
  71. package/node_modules/node-pty/third_party/conpty/1.25.260303002/win10-arm64/OpenConsole.exe +0 -0
  72. package/node_modules/node-pty/third_party/conpty/1.25.260303002/win10-arm64/conpty.dll +0 -0
  73. package/node_modules/node-pty/third_party/conpty/1.25.260303002/win10-x64/OpenConsole.exe +0 -0
  74. package/node_modules/node-pty/third_party/conpty/1.25.260303002/win10-x64/conpty.dll +0 -0
  75. package/node_modules/node-pty/typings/node-pty.d.ts +215 -0
  76. package/package.json +32 -5
  77. package/src/cli.js +123 -0
  78. package/src/client.js +27 -0
  79. package/src/codex.js +112 -0
  80. package/src/daemon.js +125 -0
  81. package/src/federation-backend.js +453 -0
  82. package/src/federation-core.ts +24 -0
  83. package/src/federation.js +454 -0
  84. package/src/handoffs.js +141 -0
  85. package/src/projects.js +140 -0
  86. package/src/runtime.js +265 -0
  87. package/src/store.js +80 -0
  88. package/src/wire.js +33 -0
@@ -0,0 +1,454 @@
1
+ import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
+ import { readFileSync, lstatSync } from "node:fs";
3
+ import { hostname, arch, platform, release } from "node:os";
4
+ import path from "node:path";
5
+ import {
6
+ connectFederationClient, FederationGatewayWebSocketServer, FederationRouter,
7
+ generateFederationIdentityKeyPair, generateFederationNoiseStaticKeyPair,
8
+ noiseKeyPairFromRawPrivate, decodeFederationInvite, encodeFederationInvite,
9
+ FEDERATION_PROTOCOL_VERSION, FEDERATION_INVITE_VERSION, FederationRpcEndpoint,
10
+ FederationReplacementReceiver, pushFederationFile, authenticateMessageOrigin, FederationPtyService, TerminalHistoryStore, FEDERATION_PTY_METHOD_CAPABILITIES, registerFederationPtyHandlers,
11
+ } from "../dist/federation-core.js";
12
+ import { setupHandoffs, HANDOFF_CAPABILITIES } from "./handoffs.js";
13
+ import { atomicJson } from "./store.js";
14
+ import { HeadlessBackend, METHOD_CAPABILITIES } from "./federation-backend.js";
15
+
16
+ export const HEADLESS_CAPABILITIES = ["remote_window", "thread_navigation", "thread_detail", "turn_control", "event_subscriptions", "gateway_relay", "transport_brotli", "remote_pty", "messaging_route", "messaging_destinations", "messaging_files", "environment_actions", "thread_handoff", "file_push"];
17
+
18
+ const RECEIVER_PERMISSIONS = Object.freeze({ remoteShells: true, portForwarding: false, filePush: true, filePull: false, filePullOutsideThreadDirectories: false });
19
+
20
+ const EVENT_CAPABILITIES = new Map([
21
+ ["navigation", "thread_navigation"], ["star_map", "thread_navigation"],
22
+ ["directory_set", "thread_navigation"], ["transcript", "thread_detail"],
23
+ ["pending_requests", "pending_request_control"], ["scheduled_actions", "scheduled_actions"],
24
+ ]);
25
+
26
+ function forwardEndpoint(value) {
27
+ const url = new URL(value);
28
+ if (url.username || url.password || url.search || url.hash
29
+ || (url.protocol !== "wss:" && !(url.protocol === "ws:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)))) {
30
+ throw new Error("Gateway must be a loopback ws:// forward or a wss:// endpoint without credentials");
31
+ }
32
+ return url.toString();
33
+ }
34
+
35
+ /** Identity persists independently of compute/tunnel presence. No VM API calls. */
36
+ export class HeadlessFederation {
37
+ constructor({ directory, runtime, profile, onFailure }) {
38
+ this.file = path.join(directory, "federation.json");
39
+ this.runtime = runtime;
40
+ this.profile = profile;
41
+ this.onFailure = onFailure;
42
+ this.connections = new Map();
43
+ this.endpoints = new Map();
44
+ this.remotePeers = new Map();
45
+ this.peerDirectory = new FederationReplacementReceiver();
46
+ this.ptyRoutes = new Map();
47
+ this.subscriptions = new Map();
48
+ this.retryDelay = 1000;
49
+ this.generation = 0;
50
+ try {
51
+ const stat = lstatSync(this.file);
52
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== process.getuid() || (stat.mode & 0o077)) throw new Error("Federation identity file must be private and owned by this user");
53
+ this.state = JSON.parse(readFileSync(this.file, "utf8"));
54
+ if (this.state.version !== 1 || !this.state.instanceId || !this.state.identity?.privateKeyPem || !this.state.noise?.privateKeyBase64) throw new Error("Invalid federation identity file");
55
+ } catch (error) { if (error.code !== "ENOENT") throw error; }
56
+ this.backend = new HeadlessBackend(runtime, (event, eventClass) => this.publish(event, eventClass));
57
+ runtime.onEvent = (message) => this.backend.event(message);
58
+ runtime.requestMessaging = (request, loadFile) => this.requestMessaging(request, loadFile);
59
+ this.backend.authenticateOrigin = (origin, envelope) => this.authenticateOrigin(origin, envelope);
60
+ this.pty = new FederationPtyService({
61
+ historyStore: new TerminalHistoryStore(path.join(directory, "terminal-history")),
62
+ resolveThreadCwd: async ({ backend, threadId }) => {
63
+ if (backend !== "codex") throw new Error("This outpost serves Codex only");
64
+ return runtime.projects.resolve(this.backend.owned(threadId).cwd || runtime.workspace);
65
+ },
66
+ spawnPty: async ({ cwd, cols, rows }) => {
67
+ const { spawn } = await import("node-pty");
68
+ if (!cwd) throw new Error("An owned thread cwd is required");
69
+ const shell = { file: "/bin/bash", args: ["--noprofile", "--norc"] };
70
+ const pty = spawn(shell.file, shell.args, { cwd, cols, rows, name: "xterm-256color", env: { ...process.env, TERM: "xterm-256color" } });
71
+ return { pty, cwd, shell };
72
+ },
73
+ sendNotification: (peer, method, params) => this.notify(peer, method, params),
74
+ });
75
+ }
76
+
77
+ save() {
78
+ try { atomicJson(this.file, this.state); } catch (error) { this.onFailure(error); throw error; }
79
+ }
80
+
81
+ ensureIdentity(label) {
82
+ if (label !== undefined && (typeof label !== "string" || !label.trim() || label.length > 200)) throw new Error("Label must be 1–200 characters");
83
+ if (this.state) return;
84
+ this.state = { version: 1, instanceId: randomUUID(), label: label || `${hostname()} outpost`,
85
+ identity: generateFederationIdentityKeyPair(), noise: generateFederationNoiseStaticKeyPair(),
86
+ hmacKey: randomBytes(32).toString("hex"), peers: {}, invites: [],
87
+ lifecycle: { kind: "outpost", role: "remote_agent", persistence: "unknown", retainUntil: null, threadStorage: "owner" } };
88
+ this.save();
89
+ }
90
+
91
+ setupRouter() {
92
+ this.router = new FederationRouter({ localInstanceId: this.state.instanceId,
93
+ trustedRelayPeerId: () => this.state.gateway?.gatewayInstanceId, methodCapabilities: { ...METHOD_CAPABILITIES, ...FEDERATION_PTY_METHOD_CAPABILITIES, ...HANDOFF_CAPABILITIES,
94
+ "outpost.projects.list": "thread_navigation", "outpost.projects.create": "environment_actions",
95
+ "outpost.projects.add": "environment_actions", "outpost.projects.remove": "environment_actions" },
96
+ additionalRequiredCapabilities: (envelope) => {
97
+ const required = envelope.method.startsWith("thread.handoff.") ? ["turn_control", "environment_actions", "file_push"] : [];
98
+ if (envelope.params?.messageOrigin?.kind === "messaging" || envelope.params?.messageOrigin?.causalMessagingSource) required.push("messaging_route");
99
+ return required;
100
+ } });
101
+ this.handoffs = setupHandoffs(this, path.dirname(this.file));
102
+ registerFederationPtyHandlers({ router: this.router, service: this.pty });
103
+ for (const [method, command] of [["list", "project-list"], ["create", "project-create"], ["add", "project-add"], ["remove", "project-remove"]]) {
104
+ this.router.registerHandler(`outpost.projects.${method}`, (envelope) => this.runtime.handle(command, envelope.params));
105
+ }
106
+ for (const method of Object.keys(METHOD_CAPABILITIES)) {
107
+ this.router.registerHandler(method, (envelope) => this.backend.handle(method, envelope.params, envelope));
108
+ }
109
+ }
110
+
111
+ async start() {
112
+ if (!this.state) return;
113
+ this.stopping = false;
114
+ if (!this.router) this.setupRouter();
115
+ if (this.state.listenPort !== undefined) await this.listen(this.state.listenPort);
116
+ if (this.state.gateway) void this.connect();
117
+ }
118
+
119
+ host() {
120
+ return { hostname: hostname(), platform: platform(), osVersion: release(), arch: arch(),
121
+ machineId: this.state.instanceId, outpost: this.state.lifecycle };
122
+ }
123
+
124
+ status() {
125
+ return { enabled: Boolean(this.state), supportedCapabilities: HEADLESS_CAPABILITIES, messagingService: "consumer-only", instanceId: this.state?.instanceId,
126
+ label: this.state?.label, lifecycle: this.state?.lifecycle, listener: this.listenerAddress,
127
+ gateway: this.state?.gateway ? { instanceId: this.state.gateway.gatewayInstanceId,
128
+ endpoint: this.state.gateway.endpoint, status: this.client ? "connected" : "unreachable", error: this.connectionError } : undefined,
129
+ peers: [...this.connections.keys()].map((id) => ({ id, status: "connected" })) };
130
+ }
131
+
132
+ async join({ invite, gateway, label }) {
133
+ if (this.state?.gateway) throw new Error("This outpost already has a gateway; use federation-leave before importing another invite");
134
+ const decoded = decodeFederationInvite(invite);
135
+ const endpoint = forwardEndpoint(gateway || decoded.gatewayUrl);
136
+ this.ensureIdentity(label);
137
+ this.state.gateway = { ...decoded, endpoint };
138
+ this.save();
139
+ if (!this.router) this.setupRouter();
140
+ await this.connect();
141
+ return this.status();
142
+ }
143
+
144
+ async invite({ port = 47831, label }) {
145
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Federation port must be between 1 and 65535");
146
+ this.ensureIdentity(label);
147
+ if (!this.router) this.setupRouter();
148
+ if (this.state.listenPort !== undefined && this.state.listenPort !== port) throw new Error("Restart with the existing listener port; it cannot change while serving");
149
+ await this.listen(port);
150
+ this.state.listenPort = port;
151
+ const token = randomBytes(32).toString("base64url");
152
+ const now = Date.now();
153
+ const entry = { id: randomUUID(), status: "pending", generatedAt: now, expiresAt: now + 15 * 60_000,
154
+ gatewayInstanceId: this.state.instanceId, tokenHash: this.hashToken(token) };
155
+ this.state.invites = this.state.invites.filter((item) => item.status === "pending" && item.expiresAt > now).slice(-15);
156
+ this.state.invites.push(entry);
157
+ this.save();
158
+ return { invite: encodeFederationInvite({ version: FEDERATION_INVITE_VERSION, token,
159
+ gatewayInstanceId: this.state.instanceId, gatewayPublicKeyPem: this.state.identity.publicKeyPem,
160
+ gatewayNoisePublicKey: this.state.noise.publicKeyBase64, gatewayUrl: this.listenerAddress.url, expiresAt: entry.expiresAt }),
161
+ expiresAt: entry.expiresAt, instanceId: this.state.instanceId };
162
+ }
163
+
164
+ hashToken(token) { return createHmac("sha256", this.state.hmacKey).update(token).digest("hex"); }
165
+
166
+ store() {
167
+ return {
168
+ getPeer: (id) => this.state.peers[id],
169
+ upsertPeer: ({ peer, clearRevocation }) => {
170
+ const existing = this.state.peers[peer.id];
171
+ this.state.peers[peer.id] = existing?.revokedAt && !clearRevocation ? { ...peer, status: "revoked", revokedAt: existing.revokedAt } : peer;
172
+ this.save();
173
+ },
174
+ findMatchingPendingEnrollment: ({ token, now }) => this.state.invites.find((entry) => entry.status === "pending" && entry.expiresAt > now
175
+ && timingSafeEqual(Buffer.from(entry.tokenHash, "hex"), Buffer.from(this.hashToken(token), "hex"))),
176
+ markEnrollmentUsed: ({ enrollmentId, peerId, usedAt }) => {
177
+ const entry = this.state.invites.find((item) => item.id === enrollmentId);
178
+ if (!entry || entry.status !== "pending") return undefined;
179
+ Object.assign(entry, { status: "used", peerId, usedAt });
180
+ this.save();
181
+ return entry;
182
+ },
183
+ appendAudit: (entry) => ({ ...entry, eventId: 0 }),
184
+ };
185
+ }
186
+
187
+ async listen(port) {
188
+ if (this.listener) return;
189
+ this.listener = new FederationGatewayWebSocketServer({ gatewayInstanceId: this.state.instanceId,
190
+ gatewayPrivateKeyPem: this.state.identity.privateKeyPem, gatewayPublicKeyPem: this.state.identity.publicKeyPem,
191
+ noiseStatic: noiseKeyPairFromRawPrivate(Buffer.from(this.state.noise.privateKeyBase64, "base64")),
192
+ host: "127.0.0.1", port, store: this.store(),
193
+ supportedCapabilities: HEADLESS_CAPABILITIES,
194
+ onConnection: (connection) => this.register(connection.peerId, connection.capabilities, connection),
195
+ onDisconnect: (connection) => this.unregister(connection.peerId, connection),
196
+ onEnvelope: (envelope, connection) => this.receive(envelope, connection.peerId),
197
+ });
198
+ try { this.listenerAddress = await this.listener.start(); } catch (error) { this.listener = null; throw error; }
199
+ }
200
+
201
+ async connect() {
202
+ const gateway = this.state?.gateway;
203
+ if (this.stopping || !gateway || this.connecting || this.client) return;
204
+ this.connecting = true;
205
+ const generation = this.generation;
206
+ const dial = async (mode) => {
207
+ let connection;
208
+ connection = await connectFederationClient({ url: gateway.endpoint, mode,
209
+ gatewayInstanceId: gateway.gatewayInstanceId, gatewayPublicKeyPem: gateway.gatewayPublicKeyPem,
210
+ gatewayNoisePublicKey: Buffer.from(gateway.gatewayNoisePublicKey, "base64"),
211
+ noiseStatic: noiseKeyPairFromRawPrivate(Buffer.from(this.state.noise.privateKeyBase64, "base64")),
212
+ peerInstanceId: this.state.instanceId, ...this.state.identity, capabilities: HEADLESS_CAPABILITIES,
213
+ inviteToken: mode === "enroll" ? gateway.token : undefined, label: this.state.label,
214
+ role: this.listener ? "dual" : "client", profileName: this.profile, receiverPermissions: RECEIVER_PERMISSIONS,
215
+ notes: "Headless outpost: retained identity; tunnel loss does not stop work or imply deletion.", host: this.host(),
216
+ deferReceiving: true, connectTimeoutMs: 5000,
217
+ onEnvelope: (envelope) => this.receive(envelope, gateway.gatewayInstanceId),
218
+ onClose: (info) => {
219
+ if (!connection || this.client !== connection) return;
220
+ this.unregister(gateway.gatewayInstanceId, connection);
221
+ this.client = null;
222
+ if (info?.code === 4002 || info?.code === 4001) {
223
+ this.connectionError = info.code === 4002 ? "revoked_peer" : "identity_in_use";
224
+ return;
225
+ }
226
+ this.schedule();
227
+ },
228
+ });
229
+ return connection;
230
+ };
231
+ try {
232
+ let client;
233
+ // Enrollment may already have succeeded before its acknowledgment was
234
+ // persisted. Reconnect first, so a consumed one-time token is not reused.
235
+ try { client = await dial("reconnect"); } catch (error) {
236
+ if (this.stopping || generation !== this.generation) throw error;
237
+ if (error.message !== "unknown_peer" || !gateway.token || gateway.expiresAt <= Date.now()) throw error;
238
+ client = await dial("enroll");
239
+ }
240
+ if (this.stopping || generation !== this.generation) { client.close(); return; }
241
+ this.client = client;
242
+ if (gateway.token) { delete gateway.token; this.save(); }
243
+ this.connectionError = undefined;
244
+ this.retryDelay = 1000;
245
+ this.register(gateway.gatewayInstanceId, client.capabilities, client);
246
+ client.startReceiving();
247
+ } catch (error) {
248
+ if (this.stopping || generation !== this.generation) return;
249
+ this.connectionError = ["revoked_peer", "unknown_peer", "missing_invite"].includes(error.message) ? error.message : "connection_failed";
250
+ if (this.connectionError === "connection_failed") this.schedule();
251
+ } finally {
252
+ this.connecting = false;
253
+ // A leave/join can replace the pairing while this handshake owns the
254
+ // dial guard. Its completion must release admission to the new pairing.
255
+ if (generation !== this.generation && !this.stopping) void this.connect();
256
+ }
257
+ }
258
+
259
+ schedule() {
260
+ if (this.stopping || !this.state?.gateway || this.retryTimer) return;
261
+ this.retryTimer = setTimeout(() => { this.retryTimer = null; void this.connect(); }, this.retryDelay);
262
+ this.retryDelay = Math.min(30000, this.retryDelay * 2);
263
+ }
264
+
265
+ register(id, capabilities, connection) {
266
+ this.connections.set(id, connection);
267
+ this.pty.notifyPeerConnected(id);
268
+ this.router.registerConnection({ peerId: id, capabilities, sendEnvelope: (envelope) => connection.sendEnvelope(envelope) });
269
+ }
270
+
271
+ unregister(id, connection) {
272
+ if (this.connections.get(id) !== connection) return;
273
+ this.connections.delete(id);
274
+ this.router.unregisterConnection(id);
275
+ if (id === this.state.gateway?.gatewayInstanceId) { this.remotePeers.clear(); this.peerDirectory.clear(); }
276
+ this.pty.notifyPeerDisconnected(id);
277
+ for (const [origin, via] of this.ptyRoutes) {
278
+ if (via === id) { this.pty.notifyPeerDisconnected(origin); this.ptyRoutes.delete(origin); }
279
+ }
280
+ for (const [target, endpoint] of this.endpoints) {
281
+ if (target === id || id === this.state.gateway?.gatewayInstanceId) endpoint.rejectAll(new Error("Original messaging owner unavailable; send outcome may be uncertain. No automatic replay."));
282
+ }
283
+ for (const [subscriber, subscription] of this.subscriptions) {
284
+ if (subscription.via === id) this.subscriptions.delete(subscriber);
285
+ }
286
+ }
287
+
288
+ endpoint(target) {
289
+ if (!this.endpoints.has(target)) this.endpoints.set(target, new FederationRpcEndpoint({
290
+ localInstanceId: this.state.instanceId, remoteInstanceId: target,
291
+ sendEnvelope: (envelope) => {
292
+ const route = this.connections.get(target) || this.connections.get(this.state.gateway?.gatewayInstanceId);
293
+ if (!route) throw new Error("Original service owner is unavailable");
294
+ route.sendEnvelope(envelope);
295
+ },
296
+ }));
297
+ return this.endpoints.get(target);
298
+ }
299
+
300
+ notify(target, method, params) {
301
+ const route = this.connections.get(target) || this.connections.get(this.state?.gateway?.gatewayInstanceId);
302
+ if (!route || !this.state) return false;
303
+ try { route.sendEnvelope({ kind: "notification", method, params, id: randomUUID(), protocolVersion: FEDERATION_PROTOCOL_VERSION,
304
+ sourceInstanceId: this.state.instanceId, targetInstanceId: target, createdAt: Date.now() }); return true; } catch { return false; }
305
+ }
306
+
307
+ authenticateOrigin(origin, envelope) {
308
+ if (!origin) return undefined;
309
+ if (Buffer.byteLength(JSON.stringify(origin)) > 16384) throw new Error("Messaging origin exceeds its byte budget");
310
+ const source = origin.causalMessagingSource;
311
+ if (source && (typeof source.ownerId !== "string" || !source.ownerId || typeof source.requestId !== "string" || !source.requestId
312
+ || (origin.kind === "messaging" && source.instanceId && source.instanceId !== envelope.sourceInstanceId))) throw new Error("Messaging source must belong to the authenticated originating peer");
313
+ return authenticateMessageOrigin({ messageOrigin: { ...origin,
314
+ ...(source ? { causalMessagingSource: { ...source, instanceId: source.instanceId || envelope.sourceInstanceId } } : {}) },
315
+ sourceInstanceId: envelope.sourceInstanceId });
316
+ }
317
+
318
+ async requestMessaging(request, loadFile) {
319
+ const owner = request.context.causalMessagingSource?.instanceId;
320
+ if (!owner || request.context.causalMessagingAmbiguous) return { ok: false, error: { code: "forbidden", message: "No unambiguous original messaging destination is retained for this turn" } };
321
+ // Capability negotiation is necessary on the selected route, and the owner
322
+ // repeats admission/RBAC/route checks. Connection alone grants no service.
323
+ const route = this.router?.getConnection(owner) || this.router?.getConnection(this.state?.gateway?.gatewayInstanceId);
324
+ if (!route?.capabilities.includes("messaging_route") || !route.capabilities.includes("messaging_destinations")) return { ok: false, error: { code: "peer_unavailable", message: "The original messaging owner is unavailable or messaging_route is not granted" } };
325
+ const attachmentTool = request.operation === "send_messaging_file" || ["inspect_messaging_pdfs", "search_messaging_pdf_text", "render_messaging_pdf_pages"].includes(request.operation);
326
+ if (attachmentTool && !route.capabilities.includes("messaging_files")) return { ok: false, error: { code: "unsupported_operation", message: "The original messaging owner requires a file/PDF tools upgrade" } };
327
+ if (loadFile && !route.capabilities.includes("file_push")) return { ok: false, error: { code: "forbidden", message: "File transfer is not granted on the messaging route" } };
328
+ try {
329
+ const endpoint = this.endpoint(owner);
330
+ const check = { ...request.context, category: "messaging_context", tool: request.operation, arguments: request.args };
331
+ const permission = await endpoint.request({ method: "messaging.destination", params: { kind: "authorize", context: request.context, check } });
332
+ if (!permission.allowed) return { ok: false, error: { code: "forbidden", message: "The original messaging owner denied this operation" } };
333
+ let file;
334
+ if (loadFile) {
335
+ const surface = await endpoint.request({ method: "messaging.destination", params: { kind: "request", request: { ...request, operation: "get_current_messaging_surface", args: {} } } });
336
+ if (!surface.ok) return surface;
337
+ const capabilities = surface.data?.location?.outboundAttachments;
338
+ if (!capabilities) return { ok: false, error: { code: "unsupported_operation", message: "The messaging owner did not supply upload limits" } };
339
+ const outbound = await loadFile(capabilities);
340
+ if (!outbound.ok) return { ok: false, error: { code: outbound.code, message: outbound.message } };
341
+ // Transfer the validated snapshot, never reopen its original path.
342
+ file = await pushFederationFile(endpoint, outbound.data, "messaging-upload.bin");
343
+ request = { ...request, args: { ...request.args, path: "", filename: outbound.filename } };
344
+ }
345
+ return await endpoint.request({ method: "messaging.destination", params: { kind: "request", request, ...(file ? { file } : {}) } });
346
+ } catch {
347
+ return { ok: false, error: { code: "peer_unavailable", message: "The original messaging service is unavailable. A send may have an uncertain outcome; it was not replayed." } };
348
+ }
349
+ }
350
+
351
+ receive(envelope, peerId) {
352
+ if (envelope.kind === "notification" && envelope.method === "federation.peerDirectoryPage") {
353
+ // Only the pinned gateway can replace routing metadata. A sibling's
354
+ // relayed notification must never authorize a handoff destination.
355
+ if (peerId !== this.state.gateway?.gatewayInstanceId || envelope.sourceInstanceId !== peerId
356
+ || envelope.targetInstanceId !== this.state.instanceId || !this.router.getConnection(peerId)?.capabilities.includes("gateway_relay")
357
+ || !this.router.authenticatesOrigin(envelope, peerId)) return;
358
+ try {
359
+ const peers = this.peerDirectory.accept(envelope.params);
360
+ if (peers) {
361
+ const replacement = new Map();
362
+ for (const peer of peers) {
363
+ if (!peer || typeof peer.id !== "string" || !peer.id || !Array.isArray(peer.capabilities)
364
+ || peer.capabilities.some((capability) => typeof capability !== "string")) throw new Error("Invalid peer directory entry");
365
+ if (peer.id !== this.state.instanceId) replacement.set(peer.id, peer);
366
+ }
367
+ this.remotePeers = replacement;
368
+ }
369
+ } catch { this.remotePeers.clear(); this.peerDirectory.clear(); }
370
+ return;
371
+ }
372
+ if (envelope.kind === "request" && envelope.targetInstanceId === this.state.instanceId && envelope.method.startsWith("pty.")
373
+ && this.router.authenticatesOrigin(envelope, peerId) && this.router.getConnection(peerId)?.capabilities.includes("remote_pty")) {
374
+ if (!this.ptyRoutes.has(envelope.sourceInstanceId)) this.pty.notifyPeerConnected(envelope.sourceInstanceId);
375
+ this.ptyRoutes.set(envelope.sourceInstanceId, peerId);
376
+ }
377
+ if (envelope.targetInstanceId === this.state.instanceId && this.router.authenticatesOrigin(envelope, peerId)
378
+ && this.endpoints.get(envelope.sourceInstanceId)?.receiveEnvelope(envelope)) return;
379
+ if (envelope.kind === "notification" && envelope.method === "federation.eventSubscription") {
380
+ if (envelope.sourceInstanceId !== peerId && (peerId !== this.state.gateway?.gatewayInstanceId || !(envelope.hopCount >= 1))) return;
381
+ const capabilities = this.router.getConnection(peerId)?.capabilities || [];
382
+ if (!capabilities.includes("event_subscriptions")) return;
383
+ const subscription = envelope.params;
384
+ const validSelection = (selection) => selection?.kind === "all" || (selection?.kind === "threads"
385
+ && Array.isArray(selection.threads) && selection.threads.length <= 100
386
+ && selection.threads.every((thread) => thread.backend === "codex" && typeof thread.threadId === "string"));
387
+ if (!Array.isArray(subscription?.eventClasses) || subscription.eventClasses.length > 16
388
+ || (subscription.threadSelection !== undefined && !validSelection(subscription.threadSelection))
389
+ || (subscription.eventClassSelections && !Object.values(subscription.eventClassSelections).every(validSelection))) return;
390
+ const eventClasses = subscription.eventClasses.filter((eventClass) => capabilities.includes(EVENT_CAPABILITIES.get(eventClass)));
391
+ const authorized = { ...subscription, eventClasses, threadSelection: subscription.threadSelection || { kind: "all" } };
392
+ if (envelope.targetInstanceId === this.state.instanceId) {
393
+ if (!eventClasses.length) this.subscriptions.delete(envelope.sourceInstanceId);
394
+ else if (this.subscriptions.size < 128 || this.subscriptions.has(envelope.sourceInstanceId)) {
395
+ this.subscriptions.set(envelope.sourceInstanceId, { ...authorized, via: peerId });
396
+ }
397
+ return;
398
+ }
399
+ // Filter at the first hop, before a trusted relay could amplify the
400
+ // subscriber's grants. Empty replacements also clear earlier demand.
401
+ envelope = { ...envelope, params: authorized };
402
+ }
403
+ void this.router.routeEnvelope({ envelope, sourcePeerId: peerId }).catch(() => { /* Transport shutdown can race a response. */ });
404
+ }
405
+
406
+ publish(event, eventClass = "transcript") {
407
+ for (const [subscriber, subscription] of this.subscriptions) {
408
+ if (!subscription.eventClasses?.includes(eventClass)) continue;
409
+ const selection = subscription.eventClassSelections ? subscription.eventClassSelections[eventClass] : subscription.threadSelection;
410
+ const threadId = event.notification.params?.threadId || event.notification.params?.thread?.id;
411
+ if (selection?.kind !== "all" && !selection?.threads?.some((thread) => thread.backend === "codex" && thread.threadId === threadId)) continue;
412
+ try { this.connections.get(subscription.via)?.sendEnvelope({ kind: "notification", method: "backend.event",
413
+ params: event, id: randomUUID(), protocolVersion: FEDERATION_PROTOCOL_VERSION,
414
+ sourceInstanceId: this.state.instanceId, targetInstanceId: subscriber, createdAt: Date.now() }); } catch { /* Reconnect reestablishes demand; no job is cancelled. */ }
415
+ }
416
+ }
417
+
418
+ async leave() {
419
+ this.generation++;
420
+ clearTimeout(this.retryTimer);
421
+ this.retryTimer = null;
422
+ const gatewayId = this.state?.gateway?.gatewayInstanceId;
423
+ const client = this.client;
424
+ this.remotePeers.clear();
425
+ this.peerDirectory.clear();
426
+ if (this.state) { delete this.state.gateway; this.save(); }
427
+ if (client) this.unregister(gatewayId, client);
428
+ this.client = null;
429
+ client?.close();
430
+ this.connectionError = undefined;
431
+ return this.status();
432
+ }
433
+
434
+ revoke(id) {
435
+ const peer = this.state?.peers[id];
436
+ if (!peer) throw new Error("Unknown enrolled peer");
437
+ peer.status = "revoked";
438
+ peer.revokedAt = Date.now();
439
+ this.save();
440
+ this.listener?.closePeer(id);
441
+ return { revoked: id };
442
+ }
443
+
444
+ async stop() {
445
+ this.stopping = true;
446
+ this.generation++;
447
+ clearTimeout(this.retryTimer);
448
+ this.client?.close();
449
+ for (const endpoint of this.endpoints.values()) endpoint.rejectAll(new Error("Outpost is stopping"));
450
+ await this.pty.disposeAll();
451
+ await this.handoffs?.stop();
452
+ await this.listener?.stop();
453
+ }
454
+ }
@@ -0,0 +1,141 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, realpath } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { createGitHandoffService, ThreadInstanceHandoffService, FederationFilePushReceiver,
5
+ registerFilePushHandlers, pushFederationFile, THREAD_HANDOFF_METHODS, FILE_PUSH_METHOD_CAPABILITIES } from "../dist/federation-core.js";
6
+ import { atomicJson, digest } from "./store.js";
7
+
8
+ export const HANDOFF_CAPABILITIES = {
9
+ ...FILE_PUSH_METHOD_CAPABILITIES,
10
+ ...Object.fromEntries(Object.values(THREAD_HANDOFF_METHODS).map((method) => [method, "thread_handoff"])),
11
+ };
12
+
13
+ /** Adapts shared transactional intake to this daemon's protocol-owned threads. */
14
+ export function setupHandoffs(federation, directory) {
15
+ const { runtime, backend, router } = federation;
16
+ const receipts = new Map();
17
+ const locked = new Set();
18
+ const runGitCommand = (cwd, args, options) => runtime.projects.git.git(cwd, args, options);
19
+ const receiver = new FederationFilePushReceiver({
20
+ allowed: () => !federation.stopping,
21
+ formatError: (error, folder, operation) => new Error(`Cannot ${operation} in outpost receiving directory "${folder}" (${error.code || "unknown error"}). Check the outpost disk and permissions.`),
22
+ directory: () => path.join(directory, "incoming"),
23
+ onCompleted: (peer, file) => {
24
+ if (receipts.size >= 128) receipts.delete(receipts.keys().next().value);
25
+ receipts.set(file.path, { peer, ...file });
26
+ },
27
+ });
28
+ runtime.threadLocked = (id) => locked.has(id);
29
+ const service = new ThreadInstanceHandoffService({
30
+ directory: path.join(directory, "handoffs"),
31
+ localInstanceId: () => federation.state.instanceId,
32
+ runGitCommand,
33
+ git: createGitHandoffService(runGitCommand),
34
+ allocateWorktree: async (repository) => {
35
+ const base = path.join(repository, ".worktrees");
36
+ await mkdir(base, { recursive: true });
37
+ return path.join(runtime.projects.contained(await realpath(base)), `outpost-${randomUUID()}`);
38
+ },
39
+ releaseWorktree: async () => {},
40
+ createHistoryWorkspace: async () => {
41
+ const cwd = path.join(runtime.projects.resolve(), `intake-${randomUUID()}`);
42
+ await mkdir(cwd, { mode: 0o700 });
43
+ return cwd;
44
+ },
45
+ assertTarget: (target) => {
46
+ const required = ["thread_handoff", "turn_control", "environment_actions", "file_push"];
47
+ const direct = router.getConnection(target);
48
+ const gateway = router.getConnection(federation.state.gateway?.gatewayInstanceId);
49
+ const relayed = !direct && gateway?.capabilities.includes("gateway_relay")
50
+ && required.every((capability) => gateway.capabilities.includes(capability))
51
+ ? federation.remotePeers.get(target) : undefined;
52
+ const destination = direct || (relayed?.status === "connected" ? relayed : undefined);
53
+ if (!destination || !required.every((capability) => destination.capabilities.includes(capability))) {
54
+ throw new Error("The receiving peer must be connected and grant thread handoff, turn control, environment actions and file push");
55
+ }
56
+ if (!direct && relayed.receiverPermissions?.filePush !== true) throw new Error("The receiving peer must allow incoming files");
57
+ },
58
+ push: (target, file) => pushFederationFile(federation.endpoint(target), file),
59
+ remoteImport: (target, request) => federation.endpoint(target).request({ method: THREAD_HANDOFF_METHODS.import, params: request, timeoutMs: 120000 }),
60
+ prepareTarget: (target, request) => federation.endpoint(target).request({ method: THREAD_HANDOFF_METHODS.prepare, params: request, timeoutMs: 120000 }),
61
+ receipt: (peer, file) => {
62
+ const receipt = receipts.get(file.path);
63
+ return receipt?.peer === peer && receipt.sha256 === file.sha256 && receipt.sizeBytes === file.sizeBytes;
64
+ },
65
+ backend: {
66
+ withThreadHandoff: async (id, work) => {
67
+ backend.owned(id);
68
+ if (locked.has(id) || runtime.active || backend.creating || runtime.intakeBusy) throw new Error("Wait for the outpost to become idle before handoff");
69
+ locked.add(id);
70
+ try { return await work(); } finally { locked.delete(id); }
71
+ },
72
+ exportThreadForHandoff: async (id) => {
73
+ const owned = backend.owned(id);
74
+ if (runtime.active) throw new Error("Wait for the source turn to finish");
75
+ const result = await runtime.codex.request("thread/read", { threadId: id, includeTurns: false });
76
+ if (result.thread?.id !== id || typeof result.thread.path !== "string") throw new Error("Codex did not provide a persisted thread for protocol export");
77
+ const file = await runtime.codex.request("fs/readFile", { path: result.thread.path });
78
+ if (typeof file.dataBase64 !== "string") throw new Error("Codex protocol thread export is unavailable");
79
+ return { rolloutBase64: file.dataBase64, replay: (await backend.readThread(id)).replay,
80
+ cwd: runtime.projects.resolve(owned.cwd || runtime.workspace), title: owned.title };
81
+ },
82
+ forkThread: async (request) => {
83
+ if (runtime.active || backend.creating) throw new Error("Outpost is busy");
84
+ backend.creating = true;
85
+ try {
86
+ const cwd = runtime.projects.contained(await realpath(request.directoryPath));
87
+ if (backend.threads.size >= 10000) throw new Error("Thread retention limit reached");
88
+ const result = await runtime.codex.request("thread/fork", { threadId: request.sourceThreadId, path: request.sourceThreadPath,
89
+ cwd, approvalPolicy: "never", sandbox: "read-only" });
90
+ const id = result.thread?.id;
91
+ if (typeof id !== "string" || !id || backend.threads.has(id)) throw new Error("Invalid forked thread identity");
92
+ backend.threads.set(id, { id, cwd, title: "Imported thread", titleSource: "fallback", createdAt: Date.now(), sandbox: "read-only" });
93
+ runtime.loadedThreads.add(id);
94
+ backend.persist();
95
+ backend.invalidate(id);
96
+ return { threadId: id };
97
+ } finally { backend.creating = false; }
98
+ },
99
+ readThread: async ({ threadId }) => ({ replay: (await backend.readThread(threadId)).replay }),
100
+ archiveThread: async ({ threadId }) => {
101
+ backend.owned(threadId);
102
+ await runtime.codex.request("thread/archive", { threadId });
103
+ backend.owned(threadId).archived = true;
104
+ backend.persist();
105
+ backend.invalidate(threadId);
106
+ },
107
+ renameThread: (request) => backend.handle("backend.renameThread", request),
108
+ },
109
+ });
110
+ registerFilePushHandlers(router, receiver);
111
+ router.registerHandler(THREAD_HANDOFF_METHODS.prepare, (envelope) => {
112
+ if (envelope.params?.repository) runtime.projects.resolve(envelope.params.repository);
113
+ return service.prepare(envelope.params);
114
+ });
115
+ router.registerHandler(THREAD_HANDOFF_METHODS.send, (envelope) => service.send(envelope.params));
116
+ // A lost acknowledgement or cold restart must never repeat a fork whose
117
+ // outcome is uncertain. Operators reconcile it using the destination list.
118
+ router.registerHandler(THREAD_HANDOFF_METHODS.import, async (envelope) => {
119
+ const request = envelope.params;
120
+ if (request?.targetRepositoryPath) runtime.projects.resolve(request.targetRepositoryPath);
121
+ const file = path.join(directory, `intake-${digest([envelope.sourceInstanceId, request?.handoffId])}.json`);
122
+ const fingerprint = digest(request);
123
+ try {
124
+ const prior = JSON.parse(await readFile(file, "utf8"));
125
+ if (prior.fingerprint !== fingerprint) throw new Error("Intake identity was reused with different input");
126
+ if (prior.result) return prior.result;
127
+ throw new Error("Intake outcome is uncertain; inspect destination threads before starting a new handoff");
128
+ } catch (error) { if (error.code !== "ENOENT") throw error; }
129
+ if (runtime.active || backend.creating || runtime.intakeBusy) throw new Error("Outpost is busy; no intake was started");
130
+ runtime.intakeBusy = true;
131
+ try {
132
+ atomicJson(file, { fingerprint, status: "pending" });
133
+ const result = await service.receive(envelope.sourceInstanceId, request);
134
+ await runtime.projects.add({ path: backend.owned(result.threadId).cwd });
135
+ atomicJson(file, { fingerprint, status: "completed", result });
136
+ receipts.delete(request.file.path);
137
+ return result;
138
+ } finally { runtime.intakeBusy = false; }
139
+ });
140
+ return { stop: () => receiver.dispose() };
141
+ }