@amaster.ai/mirrorx-device-client 0.0.0-stage → 0.1.0
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.
- package/LICENSE +172 -0
- package/README.md +487 -2
- package/dist/codebuddy-auth.d.ts +2 -0
- package/dist/codebuddy-auth.d.ts.map +1 -0
- package/dist/codebuddy-auth.js +80 -0
- package/dist/codebuddy-auth.js.map +1 -0
- package/dist/codebuddy-locks.d.ts +12 -0
- package/dist/codebuddy-locks.d.ts.map +1 -0
- package/dist/codebuddy-locks.js +101 -0
- package/dist/codebuddy-locks.js.map +1 -0
- package/dist/codebuddy-redaction.d.ts +3 -0
- package/dist/codebuddy-redaction.d.ts.map +1 -0
- package/dist/codebuddy-redaction.js +31 -0
- package/dist/codebuddy-redaction.js.map +1 -0
- package/dist/codebuddy-runtime.d.ts +82 -0
- package/dist/codebuddy-runtime.d.ts.map +1 -0
- package/dist/codebuddy-runtime.js +285 -0
- package/dist/codebuddy-runtime.js.map +1 -0
- package/dist/codebuddy-session.d.ts +13 -0
- package/dist/codebuddy-session.d.ts.map +1 -0
- package/dist/codebuddy-session.js +73 -0
- package/dist/codebuddy-session.js.map +1 -0
- package/dist/codebuddy-tool-gate.d.ts +4 -0
- package/dist/codebuddy-tool-gate.d.ts.map +1 -0
- package/dist/codebuddy-tool-gate.js +10 -0
- package/dist/codebuddy-tool-gate.js.map +1 -0
- package/dist/codex-runtime.d.ts +6 -0
- package/dist/codex-runtime.d.ts.map +1 -0
- package/dist/codex-runtime.js +24 -0
- package/dist/codex-runtime.js.map +1 -0
- package/dist/computer.d.ts +25 -0
- package/dist/computer.d.ts.map +1 -0
- package/dist/computer.js +161 -0
- package/dist/computer.js.map +1 -0
- package/dist/configuration-lifetime.d.ts +15 -0
- package/dist/configuration-lifetime.d.ts.map +1 -0
- package/dist/configuration-lifetime.js +143 -0
- package/dist/configuration-lifetime.js.map +1 -0
- package/dist/device-id.d.ts +7 -0
- package/dist/device-id.d.ts.map +1 -0
- package/dist/device-id.js +84 -0
- package/dist/device-id.js.map +1 -0
- package/dist/index.d.ts +209 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1459 -0
- package/dist/index.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +126 -0
- package/dist/main.js.map +1 -0
- package/dist/managed-workspace.d.ts +33 -0
- package/dist/managed-workspace.d.ts.map +1 -0
- package/dist/managed-workspace.js +149 -0
- package/dist/managed-workspace.js.map +1 -0
- package/dist/pi-runtime.d.ts +24 -0
- package/dist/pi-runtime.d.ts.map +1 -0
- package/dist/pi-runtime.js +156 -0
- package/dist/pi-runtime.js.map +1 -0
- package/dist/readiness.d.ts +12 -0
- package/dist/readiness.d.ts.map +1 -0
- package/dist/readiness.js +66 -0
- package/dist/readiness.js.map +1 -0
- package/dist/resource-lock.d.ts +11 -0
- package/dist/resource-lock.d.ts.map +1 -0
- package/dist/resource-lock.js +74 -0
- package/dist/resource-lock.js.map +1 -0
- package/dist/session-index.d.ts +41 -0
- package/dist/session-index.d.ts.map +1 -0
- package/dist/session-index.js +99 -0
- package/dist/session-index.js.map +1 -0
- package/dist/user-service.d.ts +21 -0
- package/dist/user-service.d.ts.map +1 -0
- package/dist/user-service.js +224 -0
- package/dist/user-service.js.map +1 -0
- package/package.json +46 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,1459 @@
|
|
|
1
|
+
/** Persistent Pi/Codex device bridge; desktop callers restrict restoration to their signed-in user and service. */
|
|
2
|
+
import clientManifest from "../package.json" with { type: "json" };
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { chmod, mkdir, open, readFile, readdir, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { StringDecoder } from "node:string_decoder";
|
|
9
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
10
|
+
import { canonicalServerOrigin, mirrorxIdentityHome, readOrCreateDeviceProfile } from "./device-id.js";
|
|
11
|
+
import { inspectDeviceReadiness } from "./readiness.js";
|
|
12
|
+
import { acquireDeviceResourceLock, deviceResourceKey } from "./resource-lock.js";
|
|
13
|
+
import { isPiDeviceCommand, preparePiDeviceCommand } from "./pi-runtime.js";
|
|
14
|
+
import { discardCodebuddySession } from "./codebuddy-session.js";
|
|
15
|
+
import { reclaimCodebuddyLocks } from "./codebuddy-locks.js";
|
|
16
|
+
import { isCodebuddyCommand, prepareCodebuddyCommand } from "./codebuddy-runtime.js";
|
|
17
|
+
import { resolveCodexExecutable } from "./codex-runtime.js";
|
|
18
|
+
import { recordDeviceSession, findDeviceSession } from "./session-index.js";
|
|
19
|
+
import { createManagedWorkspace, validateManagedCwd, confineManagedCommand } from "./managed-workspace.js";
|
|
20
|
+
import { observeConfiguration, reclaimConfiguration } from "./configuration-lifetime.js";
|
|
21
|
+
const pluginPath = "/api/plugins/mx-device-execution/api/device";
|
|
22
|
+
// The complete stream is stored in MirrorX object storage. The command result
|
|
23
|
+
// carries only enough beginning/ending context for existing adapter parsers.
|
|
24
|
+
const RESULT_HEAD_CHARS = 32_000;
|
|
25
|
+
const RESULT_TAIL_CHARS = 480_000;
|
|
26
|
+
// Two streams plus JSON metadata must fit the host's 1,000,000-byte body limit.
|
|
27
|
+
const RESULT_STREAM_BYTES = 400_000;
|
|
28
|
+
const OUTPUT_SEGMENT_CHARS = 256_000;
|
|
29
|
+
const OUTPUT_PENDING_CHARS = 8 * 1024 * 1024;
|
|
30
|
+
const STOP_GRACE_MS = 3_000;
|
|
31
|
+
const GROUP_REAP_GRACE_MS = 5_000;
|
|
32
|
+
const POST_ROOT_GROUP_GRACE_MS = 30_000;
|
|
33
|
+
function processGroupAlive(pid) {
|
|
34
|
+
try {
|
|
35
|
+
process.kill(-pid, 0);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (error.code === "ESRCH")
|
|
40
|
+
return false;
|
|
41
|
+
// Permission denial proves existence, not exit. Keep bounded observation.
|
|
42
|
+
if (error.code === "EPERM")
|
|
43
|
+
return true;
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function signalProcessGroup(pid, signal) {
|
|
48
|
+
try {
|
|
49
|
+
process.kill(-pid, signal);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error.code !== "ESRCH")
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
class DeviceProcessGroupPendingError extends Error {
|
|
57
|
+
}
|
|
58
|
+
async function stopProcessTree(child, interruptFirst = false) {
|
|
59
|
+
if (!child.pid)
|
|
60
|
+
return;
|
|
61
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
62
|
+
// A reaped root PID no longer identifies an owned process/tree. Observing
|
|
63
|
+
// group visibility is safe, but never start another signal sequence here.
|
|
64
|
+
if (process.platform !== "win32" && !processGroupAlive(child.pid))
|
|
65
|
+
return;
|
|
66
|
+
throw new DeviceProcessGroupPendingError("Device root exited; descendant ownership requires reconciliation");
|
|
67
|
+
}
|
|
68
|
+
if (process.platform === "win32") {
|
|
69
|
+
// Windows has no POSIX process groups. /T includes descendants and /F
|
|
70
|
+
// ensures the command cannot be acknowledged while tools still run.
|
|
71
|
+
const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true });
|
|
72
|
+
const code = await new Promise((resolve, reject) => {
|
|
73
|
+
killer.once("error", reject);
|
|
74
|
+
killer.once("close", resolve);
|
|
75
|
+
});
|
|
76
|
+
if (code !== 0) {
|
|
77
|
+
throw new Error(`Could not stop device process tree: taskkill exited ${code}`);
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const pid = child.pid;
|
|
82
|
+
if (!processGroupAlive(pid))
|
|
83
|
+
return;
|
|
84
|
+
if (interruptFirst) {
|
|
85
|
+
// Codex owns tools in separate process groups. Its Ctrl-C handler interrupts
|
|
86
|
+
// the turn and reaps those tools; SIGTERM would bypass that cleanup.
|
|
87
|
+
signalProcessGroup(pid, "SIGINT");
|
|
88
|
+
const interruptEnds = Date.now() + STOP_GRACE_MS;
|
|
89
|
+
while (processGroupAlive(pid) && Date.now() < interruptEnds)
|
|
90
|
+
await delay(50);
|
|
91
|
+
if (!processGroupAlive(pid))
|
|
92
|
+
return;
|
|
93
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
94
|
+
throw new DeviceProcessGroupPendingError("Codex root exited before interrupt cleanup completed");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
signalProcessGroup(pid, "SIGTERM");
|
|
98
|
+
const graceEnds = Date.now() + STOP_GRACE_MS;
|
|
99
|
+
while (processGroupAlive(pid) && Date.now() < graceEnds)
|
|
100
|
+
await delay(50);
|
|
101
|
+
if (processGroupAlive(pid)) {
|
|
102
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
103
|
+
throw new DeviceProcessGroupPendingError("Device root exited before escalation; descendants require reconciliation");
|
|
104
|
+
}
|
|
105
|
+
signalProcessGroup(pid, "SIGKILL");
|
|
106
|
+
}
|
|
107
|
+
const killEnds = Date.now() + STOP_GRACE_MS;
|
|
108
|
+
while (processGroupAlive(pid) && Date.now() < killEnds)
|
|
109
|
+
await delay(50);
|
|
110
|
+
if (processGroupAlive(pid))
|
|
111
|
+
throw new DeviceProcessGroupPendingError("Device process tree has not stopped");
|
|
112
|
+
// Forced root termination cannot attest that Codex reaped its separate tool
|
|
113
|
+
// groups. Do not let a disappearing root group turn that uncertainty into ACK.
|
|
114
|
+
if (interruptFirst)
|
|
115
|
+
throw new Error("Codex interrupt timed out; tool shutdown requires reconciliation");
|
|
116
|
+
}
|
|
117
|
+
// Root exit starts observation; inherited pipes must not delay its deadline.
|
|
118
|
+
// Windows stop confirmation never uses POSIX group probes, including Pending errors.
|
|
119
|
+
async function confirmCommandCompletion(input) {
|
|
120
|
+
const { child, signal } = input;
|
|
121
|
+
const toolsUntil = input.rootExitedAt + POST_ROOT_GROUP_GRACE_MS;
|
|
122
|
+
try {
|
|
123
|
+
if (process.platform === "win32") {
|
|
124
|
+
await input.stopTask(); // No stop: normal foreground completion. Any failure: reconcile.
|
|
125
|
+
}
|
|
126
|
+
else if (input.observeDescendants && child.pid) {
|
|
127
|
+
while (processGroupAlive(child.pid) && !input.stopTask()) {
|
|
128
|
+
if (signal?.aborted || Date.now() >= toolsUntil) {
|
|
129
|
+
throw new DeviceProcessGroupPendingError("Device root exited; descendant wait ended without confirmed exit");
|
|
130
|
+
}
|
|
131
|
+
await delay(250);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (process.platform !== "win32") {
|
|
135
|
+
try {
|
|
136
|
+
await input.stopTask();
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (!(error instanceof DeviceProcessGroupPendingError) || !child.pid)
|
|
140
|
+
throw error;
|
|
141
|
+
const reapUntil = Date.now() + GROUP_REAP_GRACE_MS;
|
|
142
|
+
while (processGroupAlive(child.pid) && !signal?.aborted && Date.now() < reapUntil)
|
|
143
|
+
await delay(250);
|
|
144
|
+
if (processGroupAlive(child.pid))
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// close also waits for inherited stdout/stderr, even if an escaped tool is
|
|
149
|
+
// no longer visible in the root group. Drain only within the same deadline.
|
|
150
|
+
while (!input.stdioClosed()) {
|
|
151
|
+
if (signal?.aborted || Date.now() >= toolsUntil) {
|
|
152
|
+
throw new DeviceProcessGroupPendingError("Device root exited; output pipes did not close before observation ended");
|
|
153
|
+
}
|
|
154
|
+
await delay(50);
|
|
155
|
+
}
|
|
156
|
+
// A stop can arrive while inherited pipes are draining after root exit.
|
|
157
|
+
if (process.platform === "win32")
|
|
158
|
+
await input.stopTask();
|
|
159
|
+
return { state: "completed" };
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
return { state: "reconcile", error };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function takeOutputChunk(value, limit) {
|
|
166
|
+
let end = Math.min(value.length, limit);
|
|
167
|
+
if (end < value.length && end > 0) {
|
|
168
|
+
const last = value.charCodeAt(end - 1);
|
|
169
|
+
const next = value.charCodeAt(end);
|
|
170
|
+
if (last >= 0xd800 && last <= 0xdbff && next >= 0xdc00 && next <= 0xdfff)
|
|
171
|
+
end -= 1;
|
|
172
|
+
}
|
|
173
|
+
return value.slice(0, end);
|
|
174
|
+
}
|
|
175
|
+
export class DeviceRequestError extends Error {
|
|
176
|
+
status;
|
|
177
|
+
code;
|
|
178
|
+
constructor(status, method, route, code) {
|
|
179
|
+
super(`MirrorX request failed: HTTP ${status} on ${method} ${route.split("?")[0]}`);
|
|
180
|
+
this.status = status;
|
|
181
|
+
this.code = code;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function outputSegmentFile(directory, seq) {
|
|
185
|
+
return path.join(directory, `${String(seq).padStart(10, "0")}.json`);
|
|
186
|
+
}
|
|
187
|
+
function retainOutput(head, tail, total, chunk) {
|
|
188
|
+
return {
|
|
189
|
+
head: (head + chunk).slice(0, RESULT_HEAD_CHARS),
|
|
190
|
+
tail: (tail + chunk).slice(-RESULT_TAIL_CHARS),
|
|
191
|
+
total: total + chunk.length,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function retainedResult(value) {
|
|
195
|
+
if (value.total <= RESULT_TAIL_CHARS)
|
|
196
|
+
return value.tail;
|
|
197
|
+
if (value.total <= RESULT_HEAD_CHARS + RESULT_TAIL_CHARS) {
|
|
198
|
+
return value.head + value.tail.slice(-(value.total - value.head.length));
|
|
199
|
+
}
|
|
200
|
+
return `${value.head}\n[MirrorX: middle output omitted from command result]\n${value.tail}`;
|
|
201
|
+
}
|
|
202
|
+
function boundedOutput(value) {
|
|
203
|
+
const serializedBytes = (text) => Buffer.byteLength(JSON.stringify(text), "utf8");
|
|
204
|
+
if (serializedBytes(value) <= RESULT_STREAM_BYTES)
|
|
205
|
+
return value;
|
|
206
|
+
const bytes = Buffer.from(value, "utf8");
|
|
207
|
+
const marker = "\n[MirrorX: middle output omitted from command result]\n";
|
|
208
|
+
let budget = Math.min(bytes.length, RESULT_STREAM_BYTES);
|
|
209
|
+
for (;;) {
|
|
210
|
+
const headBytes = Math.min(24_000, Math.floor(budget / 4));
|
|
211
|
+
const tailBytes = Math.max(0, budget - headBytes - Buffer.byteLength(marker) - 8);
|
|
212
|
+
const tail = tailBytes === 0 ? "" : bytes.subarray(-tailBytes).toString("utf8");
|
|
213
|
+
const candidate = `${bytes.subarray(0, headBytes).toString("utf8")}${marker}${tail}`;
|
|
214
|
+
if (serializedBytes(candidate) <= RESULT_STREAM_BYTES)
|
|
215
|
+
return candidate;
|
|
216
|
+
budget = Math.floor(budget * 0.7);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function boundedResult(result, companyId, claimId) {
|
|
220
|
+
const bounded = { ...result, stdout: boundedOutput(result.stdout), stderr: boundedOutput(result.stderr) };
|
|
221
|
+
const requestBytes = Buffer.byteLength(JSON.stringify({ companyId, claimId, result: bounded, outputLost: true }), "utf8");
|
|
222
|
+
if (requestBytes > 900_000)
|
|
223
|
+
throw new Error("MirrorX command result exceeds its request budget");
|
|
224
|
+
return bounded;
|
|
225
|
+
}
|
|
226
|
+
export function mirrorxDeviceHome() {
|
|
227
|
+
return process.env.MIRRORX_DEVICE_HOME?.trim()
|
|
228
|
+
? path.resolve(process.env.MIRRORX_DEVICE_HOME.trim()) : path.join(os.homedir(), ".mirrorx-device");
|
|
229
|
+
}
|
|
230
|
+
function defaultWorkspace(kind, companyId) {
|
|
231
|
+
// WorkBuddy's workspace cannot overlap its protected device-state tree.
|
|
232
|
+
const root = mirrorxDeviceHome() + "-workspaces";
|
|
233
|
+
return path.join(root, companyId, kind);
|
|
234
|
+
}
|
|
235
|
+
function configFile(kind, companyId, config) {
|
|
236
|
+
return path.join(mirrorxDeviceHome(), `${kind}-${companyId}${config?.principalId && config.deviceProfileId
|
|
237
|
+
? `-${deviceResourceKey({ ...config, bindingId: config.workspaceMode === "managed" ? config.bindingId : undefined, principalId: config.principalId, deviceProfileId: config.deviceProfileId })}` : ""}.json`);
|
|
238
|
+
}
|
|
239
|
+
async function configsFor(kind) {
|
|
240
|
+
const home = mirrorxDeviceHome();
|
|
241
|
+
const files = await readdir(home).catch((error) => {
|
|
242
|
+
if (error.code === "ENOENT")
|
|
243
|
+
return [];
|
|
244
|
+
throw error;
|
|
245
|
+
});
|
|
246
|
+
const names = files.filter((name) => name.startsWith(`${kind}-`) && name.endsWith(".json") && !name.endsWith("-journal.json"));
|
|
247
|
+
const configs = (await Promise.all(names.map((name) => loadJson(path.join(home, name)))))
|
|
248
|
+
.filter((value) => value !== null);
|
|
249
|
+
const legacy = await loadJson(path.join(home, `${kind}.json`));
|
|
250
|
+
if (legacy && !configs.some((config) => config.companyId === legacy.companyId))
|
|
251
|
+
configs.push(legacy);
|
|
252
|
+
const byBinding = new Map();
|
|
253
|
+
for (const config of configs) {
|
|
254
|
+
const key = JSON.stringify([base(config.baseUrl), config.userSubject ?? config.principalId ?? "legacy", config.companyId, config.bindingId]);
|
|
255
|
+
const previous = byBinding.get(key), generation = config.generation ?? 1, previousGeneration = previous?.generation ?? 1;
|
|
256
|
+
if (!previous || generation > previousGeneration || generation === previousGeneration && !previous.deviceProfileId && config.deviceProfileId)
|
|
257
|
+
byBinding.set(key, config);
|
|
258
|
+
else if (generation === previousGeneration && previous.deviceProfileId && config.deviceProfileId && previous.deviceProfileId !== config.deviceProfileId)
|
|
259
|
+
throw new Error("Conflicting same-generation device profiles; select an authenticated restore instead of guessing");
|
|
260
|
+
}
|
|
261
|
+
return [...byBinding.values()];
|
|
262
|
+
}
|
|
263
|
+
export async function hasMirrorxDevicePairing(kind) {
|
|
264
|
+
return (await configsFor(kind)).length > 0;
|
|
265
|
+
}
|
|
266
|
+
async function saveJson(file, value) {
|
|
267
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
268
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
269
|
+
await writeFile(temporary, JSON.stringify(value), { mode: 0o600 });
|
|
270
|
+
await chmod(temporary, 0o600);
|
|
271
|
+
await rename(temporary, file);
|
|
272
|
+
}
|
|
273
|
+
async function loadJson(file) {
|
|
274
|
+
try {
|
|
275
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
if (error.code === "ENOENT")
|
|
279
|
+
return null;
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async function acquireBridgeLock(kind, companyId, instanceId) {
|
|
284
|
+
const file = path.join(mirrorxDeviceHome(), `${kind}-${companyId}.lock`);
|
|
285
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
286
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
287
|
+
try {
|
|
288
|
+
const handle = await open(file, "wx", 0o600);
|
|
289
|
+
try {
|
|
290
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, instanceId }));
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
await handle.close();
|
|
294
|
+
await unlink(file).catch(() => undefined);
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
await handle.close();
|
|
298
|
+
return async () => {
|
|
299
|
+
const owner = await loadJson(file).catch(() => null);
|
|
300
|
+
if (owner?.instanceId === instanceId)
|
|
301
|
+
await unlink(file).catch(() => undefined);
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
if (error.code !== "EEXIST")
|
|
306
|
+
throw error;
|
|
307
|
+
const owner = await loadJson(file).catch(() => null);
|
|
308
|
+
if (owner?.pid) {
|
|
309
|
+
try {
|
|
310
|
+
process.kill(owner.pid, 0);
|
|
311
|
+
throw new Error("Another MirrorX bridge is running for this executor and company");
|
|
312
|
+
}
|
|
313
|
+
catch (probe) {
|
|
314
|
+
if (probe.code !== "ESRCH")
|
|
315
|
+
throw probe;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
const age = await stat(file).then((entry) => Date.now() - entry.mtimeMs).catch(() => 0);
|
|
320
|
+
if (age < 5_000)
|
|
321
|
+
throw new Error("MirrorX bridge lock is being initialized");
|
|
322
|
+
}
|
|
323
|
+
await unlink(file).catch((removeError) => {
|
|
324
|
+
if (removeError.code !== "ENOENT")
|
|
325
|
+
throw removeError;
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
throw new Error("Could not acquire MirrorX bridge lock");
|
|
330
|
+
}
|
|
331
|
+
function base(url) { return canonicalServerOrigin(url); }
|
|
332
|
+
async function request(baseUrl, route, options = {}) {
|
|
333
|
+
const response = await fetch(new URL(route, baseUrl), {
|
|
334
|
+
method: options.method ?? "GET",
|
|
335
|
+
headers: {
|
|
336
|
+
"x-mirrorx-device-protocol-min": String(clientManifest.mirrorxDeviceProtocol.min), "x-mirrorx-device-protocol-max": String(clientManifest.mirrorxDeviceProtocol.max),
|
|
337
|
+
"x-mirrorx-device-client-version": clientManifest.version,
|
|
338
|
+
...(options.body === undefined ? {} : { "content-type": "application/json" }),
|
|
339
|
+
...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
|
|
340
|
+
...(options.deviceToken ? { "x-mirrorx-device-token": options.deviceToken } : {}),
|
|
341
|
+
},
|
|
342
|
+
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
|
|
343
|
+
signal: options.signal
|
|
344
|
+
? AbortSignal.any([options.signal, AbortSignal.timeout(options.timeoutMs ?? 25_000)])
|
|
345
|
+
: AbortSignal.timeout(options.timeoutMs ?? 25_000),
|
|
346
|
+
redirect: "error",
|
|
347
|
+
});
|
|
348
|
+
if (!response.ok) {
|
|
349
|
+
// Project only a closed set of machine codes. Never expose upstream text,
|
|
350
|
+
// commands, credentials, arbitrary error bodies or query strings.
|
|
351
|
+
const body = await response.json().catch(() => null);
|
|
352
|
+
const candidate = body?.details?.code ?? body?.code;
|
|
353
|
+
const known = new Set(["DEVICE_SESSION_EXPIRED", "DEVICE_SESSION_ROTATED", "DEVICE_SESSION_SIGNED_OUT", "DEVICE_SESSION_INVALID", "DEVICE_REVOKED",
|
|
354
|
+
"DEVICE_MEMBERSHIP_LOST", "DEVICE_PROTOCOL_UNSUPPORTED", "PI_IDENTITY_CHOICE_REQUIRED", "DEVICE_BUSY", "DEVICE_RECOVERY_REQUIRED", "DEVICE_RECONCILIATION_REQUIRED", "DEVICE_INTENT_COMMITTED"]);
|
|
355
|
+
throw new DeviceRequestError(response.status, options.method ?? "GET", route, typeof candidate === "string" && known.has(candidate) ? candidate : undefined);
|
|
356
|
+
}
|
|
357
|
+
return await response.json();
|
|
358
|
+
}
|
|
359
|
+
/** Redeem a one-use code created by the signed-in MirrorX user. */
|
|
360
|
+
export async function pairMirrorxDevice(input) {
|
|
361
|
+
const baseUrl = base(input.baseUrl);
|
|
362
|
+
const context = await request(baseUrl, "/api/devices/pairing-codes/context", {
|
|
363
|
+
method: "POST", body: { code: input.code, executorKind: input.executorKind },
|
|
364
|
+
});
|
|
365
|
+
if (context.enrollment && (!input.workspaceRoot || path.resolve(input.workspaceRoot) !== context.enrollment.workspaceRoot)) {
|
|
366
|
+
throw new Error("Use the exact workspace authorized in MirrorX");
|
|
367
|
+
}
|
|
368
|
+
const deviceProfileId = await readOrCreateDeviceProfile(baseUrl, context.principalId);
|
|
369
|
+
const deviceId = `${input.executorKind}:profile:${deviceProfileId}`;
|
|
370
|
+
const workspaceRoot = path.resolve(input.workspaceRoot ?? defaultWorkspace(input.executorKind, context.companyId));
|
|
371
|
+
await mkdir(workspaceRoot, { recursive: true, mode: 0o700 });
|
|
372
|
+
await validateDeviceWorkspace(workspaceRoot);
|
|
373
|
+
const release = await acquireDeviceResourceLock({ baseUrl, principalId: context.principalId, deviceProfileId, companyId: context.companyId, executorKind: input.executorKind }, randomUUID());
|
|
374
|
+
try {
|
|
375
|
+
const registration = await request(baseUrl, "/api/devices/pairing-codes/claim", {
|
|
376
|
+
method: "POST",
|
|
377
|
+
body: {
|
|
378
|
+
code: input.code,
|
|
379
|
+
deviceId, deviceProfileId,
|
|
380
|
+
executorKind: input.executorKind,
|
|
381
|
+
displayName: input.executorKind === "pi" ? "My Pi" : input.executorKind === "codebuddy" ? "My WorkBuddy" : "My Codex",
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
const companyId = registration.companyId;
|
|
385
|
+
const config = {
|
|
386
|
+
baseUrl, companyId, managedBy: "standalone", principalId: context.principalId, deviceProfileId, deviceId: registration.deviceId ?? deviceId, ...(registration.generation ? { generation: registration.generation } : {}), executorKind: input.executorKind, workspaceRoot,
|
|
387
|
+
bindingId: registration.bindingId, agentId: registration.agentId,
|
|
388
|
+
sessionId: registration.deviceSession.sessionId,
|
|
389
|
+
deviceToken: registration.deviceSession.token,
|
|
390
|
+
};
|
|
391
|
+
await saveJson(configFile(input.executorKind, companyId, config), config);
|
|
392
|
+
return {
|
|
393
|
+
baseUrl, companyId, principalId: context.principalId, deviceProfileId, deviceId, executorKind: input.executorKind, workspaceRoot,
|
|
394
|
+
bindingId: registration.bindingId, agentId: registration.agentId, sessionId: registration.deviceSession.sessionId,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
finally {
|
|
398
|
+
await release();
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/** Reuse the Pi login's IAM bearer; no second browser approval or board key. */
|
|
402
|
+
export async function enrollMirrorxDevice(input) {
|
|
403
|
+
const baseUrl = base(input.baseUrl);
|
|
404
|
+
if (input.executorKind !== "pi")
|
|
405
|
+
throw new Error("External tools require explicit MirrorX enrollment authorization");
|
|
406
|
+
const identity = await request(baseUrl, "/api/devices/iam-context", {
|
|
407
|
+
token: input.iamAccessToken, ...(input.signal ? { signal: input.signal } : {}),
|
|
408
|
+
});
|
|
409
|
+
if (identity.subject !== input.userSubject)
|
|
410
|
+
throw new Error("MirrorX IAM identity does not match the signed-in Pi user");
|
|
411
|
+
const deviceProfileId = await authenticatedDeviceProfile(baseUrl, identity.principalId);
|
|
412
|
+
const deviceId = `${input.executorKind}:profile:${deviceProfileId}`;
|
|
413
|
+
const registrationBody = { deviceId, deviceProfileId, executorKind: input.executorKind,
|
|
414
|
+
displayName: input.executorKind === "pi" ? "My Pi" : input.executorKind === "codebuddy" ? "My WorkBuddy" : "My Codex" };
|
|
415
|
+
const savedConfigs = (await configsFor(input.executorKind)).filter((config) => config.baseUrl === baseUrl && config.userSubject === input.userSubject && config.workspaceMode !== "managed"
|
|
416
|
+
&& typeof config.deviceToken === "string");
|
|
417
|
+
const register = async (companyId) => {
|
|
418
|
+
const result = await request(baseUrl, "/api/devices/iam-registration", {
|
|
419
|
+
method: "POST", token: input.iamAccessToken,
|
|
420
|
+
body: { ...registrationBody, bindingReferences: Object.fromEntries(savedConfigs.map(config => [config.companyId, config.bindingId])), ...(companyId ? { companyId,
|
|
421
|
+
...(savedConfigs.find(config => config.companyId === companyId)?.bindingId
|
|
422
|
+
? { bindingId: savedConfigs.find(config => config.companyId === companyId).bindingId } : {}) } : {}) },
|
|
423
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
424
|
+
});
|
|
425
|
+
if (result.subject !== input.userSubject) {
|
|
426
|
+
throw new Error("MirrorX IAM identity does not match the signed-in Pi user");
|
|
427
|
+
}
|
|
428
|
+
if (companyId && result.registrations.some(entry => entry.companyId !== companyId)) {
|
|
429
|
+
throw new Error("MirrorX returned an unexpected device company");
|
|
430
|
+
}
|
|
431
|
+
return result;
|
|
432
|
+
};
|
|
433
|
+
const configs = [];
|
|
434
|
+
const saveRegistrations = async (registrations) => {
|
|
435
|
+
for (const registration of registrations) {
|
|
436
|
+
input.signal?.throwIfAborted();
|
|
437
|
+
const workspaceRoot = savedConfigs.find(config => config.companyId === registration.companyId && config.bindingId === registration.bindingId)?.workspaceRoot ?? defaultWorkspace(input.executorKind, registration.companyId);
|
|
438
|
+
await mkdir(workspaceRoot, { recursive: true, mode: 0o700 });
|
|
439
|
+
const config = {
|
|
440
|
+
baseUrl, userSubject: input.userSubject, managedBy: "desktop", companyId: registration.companyId,
|
|
441
|
+
...(registration.generation ? { generation: registration.generation } : {}), deviceId: registration.deviceId ?? deviceId, principalId: identity.principalId, deviceProfileId, executorKind: input.executorKind, workspaceRoot,
|
|
442
|
+
bindingId: registration.bindingId, agentId: registration.agentId,
|
|
443
|
+
sessionId: registration.deviceSession.sessionId, deviceToken: registration.deviceSession.token,
|
|
444
|
+
};
|
|
445
|
+
await saveJson(configFile(input.executorKind, registration.companyId, config), config);
|
|
446
|
+
configs.push(config);
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
input.signal?.throwIfAborted();
|
|
450
|
+
const first = { subject: input.userSubject, registrations: [], failures: [] };
|
|
451
|
+
if (input.companyIds) {
|
|
452
|
+
for (const companyId of new Set(input.companyIds)) {
|
|
453
|
+
try {
|
|
454
|
+
const result = await register(companyId);
|
|
455
|
+
first.registrations.push(...result.registrations);
|
|
456
|
+
first.failures.push(...result.failures ?? []);
|
|
457
|
+
if (!result.registrations.some(entry => entry.companyId === companyId)
|
|
458
|
+
&& !result.failures?.some(entry => entry.companyId === companyId)) {
|
|
459
|
+
first.failures.push({ companyId, status: 0, retryable: false, error: "Device company registration was not returned" });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
catch (error) {
|
|
463
|
+
input.signal?.throwIfAborted();
|
|
464
|
+
const status = error instanceof DeviceRequestError ? error.status : 0;
|
|
465
|
+
const retryable = status === 408 || status === 429 || status >= 500
|
|
466
|
+
|| error instanceof TypeError || error instanceof Error && ["AbortError", "TimeoutError"].includes(error.name);
|
|
467
|
+
first.failures.push({ companyId, status, retryable, error: "Device company registration failed" });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
else
|
|
472
|
+
Object.assign(first, await register());
|
|
473
|
+
await saveRegistrations(first.registrations);
|
|
474
|
+
for (const failure of first.failures ?? []) {
|
|
475
|
+
if (failure.code === "active_session") {
|
|
476
|
+
const saved = savedConfigs.find((config) => config.companyId === failure.companyId);
|
|
477
|
+
if (saved && saved.sessionId === failure.sessionId) {
|
|
478
|
+
// A whole-set retry must keep the token already used by this device.
|
|
479
|
+
configs.push(saved);
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (!failure.retryable && failure.code !== "active_session") {
|
|
484
|
+
input.onRegistrationFailure?.(failure);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
let remaining = failure;
|
|
488
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
489
|
+
await delay((attempt + 1) * 200, undefined, input.signal ? { signal: input.signal } : {});
|
|
490
|
+
try {
|
|
491
|
+
// Target only the failed company: a whole-set retry would rotate the
|
|
492
|
+
// sessions already saved above and leave their client tokens dead.
|
|
493
|
+
const retried = await register(failure.companyId);
|
|
494
|
+
await saveRegistrations(retried.registrations);
|
|
495
|
+
if (retried.registrations.some((entry) => entry.companyId === failure.companyId)) {
|
|
496
|
+
remaining = undefined;
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
remaining = retried.failures?.find((entry) => entry.companyId === failure.companyId) ?? remaining;
|
|
500
|
+
if (!remaining?.retryable)
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
catch (error) {
|
|
504
|
+
input.signal?.throwIfAborted();
|
|
505
|
+
if (error instanceof DeviceRequestError) {
|
|
506
|
+
remaining = { companyId: failure.companyId, status: error.status, error: "Device company registration failed",
|
|
507
|
+
retryable: error.status === 408 || error.status === 429 || error.status >= 500 };
|
|
508
|
+
if (!remaining.retryable)
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
// Preserve the last structured failure for the caller's diagnostics.
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (remaining)
|
|
515
|
+
input.onRegistrationFailure?.(remaining);
|
|
516
|
+
}
|
|
517
|
+
return configs.map(({ deviceToken: _token, ...config }) => config);
|
|
518
|
+
}
|
|
519
|
+
/** Non-sensitive inspection; restoration never scans unselected tools. */
|
|
520
|
+
export async function listMirrorxDevicePairings(input) {
|
|
521
|
+
const kinds = input.executorKind ? [input.executorKind] : ["pi", "codex", "codebuddy"];
|
|
522
|
+
const configs = (await Promise.all(kinds.map(configsFor))).flat().filter(config => (!input.expectedSubject || config.userSubject === input.expectedSubject) && (!input.expectedBaseUrl || base(config.baseUrl) === base(input.expectedBaseUrl)));
|
|
523
|
+
return configs.map(({ deviceToken: _token, ...config }) => config);
|
|
524
|
+
}
|
|
525
|
+
/** Restore credentials only for a retained, explicitly authorized desktop tool.
|
|
526
|
+
* A live Pi session proves the same account/company/profile to the server. */
|
|
527
|
+
export async function restoreMirrorxToolSession(input) {
|
|
528
|
+
input.signal?.throwIfAborted();
|
|
529
|
+
const baseUrl = base(input.expectedBaseUrl);
|
|
530
|
+
const config = (await configsFor(input.executorKind)).find(c => c.bindingId === input.bindingId && c.managedBy === "desktop"
|
|
531
|
+
&& c.userSubject === input.expectedSubject && base(c.baseUrl) === baseUrl);
|
|
532
|
+
if (!config || config.executorKind === "pi" && config.workspaceMode !== "managed" || !config.deviceProfileId || !config.generation)
|
|
533
|
+
throw new Error("Original desktop tool connection is unavailable");
|
|
534
|
+
if (config.deviceToken && !config.signedOut) {
|
|
535
|
+
try {
|
|
536
|
+
const proof = await request(baseUrl, "/api/devices/session/context", { deviceToken: config.deviceToken, ...(input.signal ? { signal: input.signal } : {}) });
|
|
537
|
+
if (proof.bindingId !== config.bindingId || proof.generation !== config.generation)
|
|
538
|
+
throw new Error("Tool session identity mismatch");
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
catch (error) {
|
|
542
|
+
if (!(error instanceof DeviceRequestError) || !["DEVICE_SESSION_EXPIRED", "DEVICE_SESSION_ROTATED", "DEVICE_SESSION_SIGNED_OUT"].includes(error.code ?? ""))
|
|
543
|
+
throw error;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const pi = (await configsFor("pi")).find(c => c.companyId === config.companyId && c.principalId === config.principalId && c.workspaceMode !== "managed"
|
|
547
|
+
&& c.deviceProfileId === config.deviceProfileId && c.userSubject === input.expectedSubject && base(c.baseUrl) === baseUrl && c.deviceToken && !c.signedOut && c.managedBy === "desktop");
|
|
548
|
+
if (!pi)
|
|
549
|
+
throw new Error("Sign in to the original Pi computer before reconnecting tools");
|
|
550
|
+
const result = await request(baseUrl, "/api/devices/session/restore", {
|
|
551
|
+
method: "POST", deviceToken: pi.deviceToken, ...(input.signal ? { signal: input.signal } : {}),
|
|
552
|
+
body: { bindingId: config.bindingId, sessionId: config.sessionId, generation: config.generation },
|
|
553
|
+
});
|
|
554
|
+
input.signal?.throwIfAborted();
|
|
555
|
+
if (result.bindingId !== config.bindingId || result.agentId !== config.agentId || result.companyId !== config.companyId
|
|
556
|
+
|| result.deviceProfileId !== config.deviceProfileId || result.generation !== config.generation)
|
|
557
|
+
throw new Error("Restored tool identity mismatch");
|
|
558
|
+
await saveJson(configFile(config.executorKind, config.companyId, config), { ...config, signedOut: false,
|
|
559
|
+
sessionId: result.deviceSession.sessionId, deviceToken: result.deviceSession.token });
|
|
560
|
+
}
|
|
561
|
+
async function authenticatedDeviceProfile(baseUrl, principalId) {
|
|
562
|
+
const retained = new Set();
|
|
563
|
+
for (const kind of ["pi", "codex", "codebuddy"]) {
|
|
564
|
+
for (const config of await configsFor(kind)) {
|
|
565
|
+
if (base(config.baseUrl) !== baseUrl || config.principalId !== principalId || !config.deviceToken || config.signedOut)
|
|
566
|
+
continue;
|
|
567
|
+
const proof = await request(baseUrl, "/api/devices/session/context", { deviceToken: config.deviceToken }).catch(() => null);
|
|
568
|
+
if (proof?.principalId === principalId && proof.bindingId === config.bindingId && proof.companyId === config.companyId && proof.deviceProfileId)
|
|
569
|
+
retained.add(proof.deviceProfileId);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (retained.size > 1)
|
|
573
|
+
throw new Error("Multiple retained device identities; explicitly select a binding to restore");
|
|
574
|
+
return readOrCreateDeviceProfile(baseUrl, principalId, [...retained][0]);
|
|
575
|
+
}
|
|
576
|
+
async function validateDeviceWorkspace(workspaceRoot) {
|
|
577
|
+
const root = await import("node:fs/promises").then(fs => fs.realpath(workspaceRoot));
|
|
578
|
+
const protectedRoots = [mirrorxDeviceHome(), mirrorxIdentityHome()];
|
|
579
|
+
const overlaps = (a, b) => { const relative = path.relative(a, b); return !relative || !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative); };
|
|
580
|
+
for (const protectedRoot of protectedRoots) {
|
|
581
|
+
const real = await import("node:fs/promises").then(fs => fs.realpath(protectedRoot)).catch(() => path.resolve(protectedRoot));
|
|
582
|
+
if (overlaps(root, real) || overlaps(real, root))
|
|
583
|
+
throw new Error("Workspace overlaps private connection identity or state");
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
export async function getMirrorxEnrollmentIntent(input) {
|
|
587
|
+
if (!/^[0-9a-f-]{36}$/i.test(input.enrollmentIntentId))
|
|
588
|
+
throw new Error("Invalid enrollment intent");
|
|
589
|
+
const intent = await request(base(input.baseUrl), `/api/devices/intents/${input.enrollmentIntentId}`, { token: input.iamAccessToken });
|
|
590
|
+
if (intent.subject !== input.userSubject || !(["codex", "codebuddy"].includes(intent.executorKind) || intent.executorKind === "pi" && (intent.recoveryBindingId || intent.explicitPiChoice || intent.enrollment.workspaceMode === "managed")))
|
|
591
|
+
throw new Error("Enrollment identity/tool mismatch");
|
|
592
|
+
return intent;
|
|
593
|
+
}
|
|
594
|
+
export async function connectMirrorxEnrollmentIntent(input) {
|
|
595
|
+
const baseUrl = base(input.baseUrl);
|
|
596
|
+
const intent = await getMirrorxEnrollmentIntent(input);
|
|
597
|
+
const profileId = await authenticatedDeviceProfile(baseUrl, intent.principalId);
|
|
598
|
+
const managed = intent.enrollment.workspaceMode === "managed";
|
|
599
|
+
input.signal?.throwIfAborted();
|
|
600
|
+
const workspaceRoot = managed ? await createManagedWorkspace({ baseUrl, principalId: intent.principalId,
|
|
601
|
+
deviceProfileId: profileId, companyId: intent.companyId, workspaceId: intent.enrollmentIntentId }) : input.workspaceRoot;
|
|
602
|
+
if (!workspaceRoot || !managed && (!input.confirmed || path.resolve(workspaceRoot) !== workspaceRoot || intent.enrollment.workspaceRoot !== workspaceRoot)) {
|
|
603
|
+
throw new Error("Confirm the exact authorized workspace on this computer");
|
|
604
|
+
}
|
|
605
|
+
await validateDeviceWorkspace(workspaceRoot);
|
|
606
|
+
const requestFile = path.join(mirrorxDeviceHome(), "enrollment-requests", `${intent.enrollmentIntentId}.json`);
|
|
607
|
+
const prior = await loadJson(requestFile);
|
|
608
|
+
const claimRequestId = prior?.claimRequestId ?? randomUUID();
|
|
609
|
+
if (!prior)
|
|
610
|
+
await saveJson(requestFile, { claimRequestId });
|
|
611
|
+
const unlock = await acquireDeviceResourceLock({ baseUrl, principalId: intent.principalId, deviceProfileId: profileId,
|
|
612
|
+
companyId: intent.companyId, executorKind: intent.executorKind, ...(managed ? { bindingId: intent.enrollmentIntentId } : {}) }, randomUUID());
|
|
613
|
+
try {
|
|
614
|
+
input.signal?.throwIfAborted();
|
|
615
|
+
const registration = await request(baseUrl, `/api/devices/intents/${intent.enrollmentIntentId}/claim`, { method: "POST", token: input.iamAccessToken,
|
|
616
|
+
body: { deviceProfileId: profileId, claimRequestId, workspaceRoot, confirmed: true }, ...(input.signal ? { signal: input.signal } : {}) });
|
|
617
|
+
if (registration.subject !== input.userSubject || registration.principalId !== intent.principalId
|
|
618
|
+
|| registration.companyId !== intent.companyId)
|
|
619
|
+
throw new Error("Enrollment result identity mismatch");
|
|
620
|
+
const config = { baseUrl, userSubject: input.userSubject, managedBy: "desktop", principalId: intent.principalId,
|
|
621
|
+
deviceProfileId: profileId, generation: registration.generation, companyId: intent.companyId, executorKind: intent.executorKind, deviceId: registration.deviceId,
|
|
622
|
+
workspaceRoot, ...(managed ? { workspaceMode: "managed" } : {}), bindingId: registration.bindingId, agentId: registration.agentId,
|
|
623
|
+
sessionId: registration.deviceSession.sessionId, deviceToken: registration.deviceSession.token };
|
|
624
|
+
const discardCancelledSession = async () => {
|
|
625
|
+
// Preserve only the binding reference for authenticated recovery. Revoke
|
|
626
|
+
// this response's narrow session, never every session for the account.
|
|
627
|
+
await saveJson(configFile(intent.executorKind, config.companyId, config), { ...config, deviceToken: "", signedOut: true });
|
|
628
|
+
await request(baseUrl, "/api/devices/session/revoke", { method: "POST", deviceToken: config.deviceToken,
|
|
629
|
+
body: { reason: "sign_out" } });
|
|
630
|
+
};
|
|
631
|
+
if (input.signal?.aborted) {
|
|
632
|
+
await discardCancelledSession();
|
|
633
|
+
input.signal.throwIfAborted();
|
|
634
|
+
}
|
|
635
|
+
await saveJson(configFile(intent.executorKind, config.companyId, config), config);
|
|
636
|
+
// Cancellation can also happen while the atomic file write is in progress.
|
|
637
|
+
if (input.signal?.aborted) {
|
|
638
|
+
await discardCancelledSession();
|
|
639
|
+
input.signal.throwIfAborted();
|
|
640
|
+
}
|
|
641
|
+
const { deviceToken: _token, ...summary } = config;
|
|
642
|
+
return summary;
|
|
643
|
+
}
|
|
644
|
+
finally {
|
|
645
|
+
await unlock();
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
export async function runMirrorxDevices(input) {
|
|
649
|
+
const configs = (await configsFor(input.executorKind)).filter((config) => (!input.expectedSubject || config.userSubject === input.expectedSubject)
|
|
650
|
+
&& (!input.expectedBaseUrl || base(config.baseUrl) === base(input.expectedBaseUrl))
|
|
651
|
+
&& (!input.companyIds || input.companyIds.includes(config.companyId))
|
|
652
|
+
&& (!input.bindingIds || input.bindingIds.includes(config.bindingId))
|
|
653
|
+
&& (!input.computerCarrierOnly || config.executorKind === "pi" && config.workspaceMode !== "managed"));
|
|
654
|
+
if (configs.length === 0)
|
|
655
|
+
throw new Error("No MirrorX companies are connected");
|
|
656
|
+
await Promise.all(configs.map((config) => runMirrorxDevice({ ...input, companyId: config.companyId, bindingId: config.bindingId,
|
|
657
|
+
onConnectionState: state => input.onConnectionState?.(config.companyId, state) })));
|
|
658
|
+
}
|
|
659
|
+
/** Revoke the narrow device sessions when the user signs out of Pi. */
|
|
660
|
+
export async function disconnectMirrorxDevices(kind, userSubject) {
|
|
661
|
+
const configs = (await configsFor(kind)).filter((config) => config.userSubject === userSubject && (config.managedBy === "desktop" || kind === "pi" && !config.managedBy));
|
|
662
|
+
await Promise.all(configs.map(async (config) => {
|
|
663
|
+
// Keep a non-secret binding reference for authenticated parent-IAM
|
|
664
|
+
// restoration. A logout is neither identity deletion nor a new employee.
|
|
665
|
+
const retained = { ...config, deviceToken: "", signedOut: true };
|
|
666
|
+
await saveJson(configFile(kind, config.companyId, config), retained);
|
|
667
|
+
const legacyFile = path.join(mirrorxDeviceHome(), `${kind}.json`);
|
|
668
|
+
const legacy = await loadJson(legacyFile);
|
|
669
|
+
if (legacy?.bindingId === config.bindingId)
|
|
670
|
+
await saveJson(legacyFile, { ...legacy, deviceToken: "", signedOut: true });
|
|
671
|
+
// Persist logout locally even if the server commits but its reply is lost.
|
|
672
|
+
const response = await fetch(new URL("/api/devices/session/revoke", config.baseUrl), {
|
|
673
|
+
method: "POST", headers: { "x-mirrorx-device-token": config.deviceToken, "content-type": "application/json" },
|
|
674
|
+
body: JSON.stringify({ reason: "sign_out" }),
|
|
675
|
+
signal: AbortSignal.timeout(5_000), redirect: "error",
|
|
676
|
+
});
|
|
677
|
+
if (!response.ok && response.status !== 401)
|
|
678
|
+
throw new Error(`MirrorX device sign-out failed: HTTP ${response.status}`);
|
|
679
|
+
}));
|
|
680
|
+
}
|
|
681
|
+
/** Local operator recovery only: this neither acknowledges nor relaunches the command. */
|
|
682
|
+
export async function reclaimMirrorxDeviceConfiguration(input) {
|
|
683
|
+
if (!input.confirmedStopped)
|
|
684
|
+
throw new Error("Confirm all command tools have stopped before reclaiming their configuration");
|
|
685
|
+
if (![input.companyId, input.commandId].every(value => /^[a-zA-Z0-9_-]+$/.test(value)))
|
|
686
|
+
throw new Error("Invalid company or command ID");
|
|
687
|
+
const config = input.bindingId ? (await configsFor(input.executorKind)).find(item => item.companyId === input.companyId && item.bindingId === input.bindingId) : undefined;
|
|
688
|
+
if (input.bindingId && !config)
|
|
689
|
+
throw new Error("Requested connection profile was not found");
|
|
690
|
+
const resource = config?.principalId && config.deviceProfileId
|
|
691
|
+
? { ...config, bindingId: config.workspaceMode === "managed" ? config.bindingId : undefined, principalId: config.principalId, deviceProfileId: config.deviceProfileId } : undefined;
|
|
692
|
+
const release = resource ? await acquireDeviceResourceLock(resource, randomUUID()) : await acquireBridgeLock(input.executorKind, input.companyId, randomUUID());
|
|
693
|
+
try {
|
|
694
|
+
const file = path.join(mirrorxDeviceHome(), `${input.executorKind}-${input.companyId}${resource ? `-${deviceResourceKey(resource)}` : ""}-journal.json`);
|
|
695
|
+
const journal = await loadJson(file);
|
|
696
|
+
const record = journal?.[input.commandId];
|
|
697
|
+
if (!record?.configurationDirectory)
|
|
698
|
+
throw new Error("No retained configuration is recorded for this command");
|
|
699
|
+
await reclaimConfiguration(record, true, () => reclaimCodebuddyLocks(record, mirrorxDeviceHome()));
|
|
700
|
+
delete record.configurationDirectory;
|
|
701
|
+
delete record.configurationToken;
|
|
702
|
+
await saveJson(file, journal);
|
|
703
|
+
}
|
|
704
|
+
finally {
|
|
705
|
+
await release();
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
/** Operator reset discards native context only; the original command still needs reconciliation. */
|
|
709
|
+
export async function resetMirrorxCodebuddySession(input) {
|
|
710
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(input.companyId))
|
|
711
|
+
throw new Error("Invalid company ID");
|
|
712
|
+
if (!input.confirmedStopped)
|
|
713
|
+
throw new Error("Confirm all original command tools have stopped before resetting a native session");
|
|
714
|
+
const release = await acquireBridgeLock("codebuddy", input.companyId, randomUUID());
|
|
715
|
+
try {
|
|
716
|
+
await discardCodebuddySession({ ...input, stateRoot: mirrorxDeviceHome() });
|
|
717
|
+
}
|
|
718
|
+
finally {
|
|
719
|
+
await release();
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
export async function runMirrorxDevice(input) {
|
|
723
|
+
const connectionAbort = new AbortController();
|
|
724
|
+
const signal = input.signal ? AbortSignal.any([input.signal, connectionAbort.signal]) : connectionAbort.signal;
|
|
725
|
+
const matches = (await configsFor(input.executorKind)).filter(config => (!input.companyId || config.companyId === input.companyId)
|
|
726
|
+
&& (!input.bindingId || config.bindingId === input.bindingId)
|
|
727
|
+
&& (!input.expectedSubject || config.userSubject === input.expectedSubject)
|
|
728
|
+
&& (!input.expectedBaseUrl || base(config.baseUrl) === base(input.expectedBaseUrl)));
|
|
729
|
+
if (matches.length > 1)
|
|
730
|
+
throw new Error("Choose an unambiguous MirrorX connection profile");
|
|
731
|
+
const loadedConfig = matches[0] ?? null;
|
|
732
|
+
if (!loadedConfig)
|
|
733
|
+
throw new Error("Pair this device with MirrorX first");
|
|
734
|
+
if (input.companyId && loadedConfig.companyId !== input.companyId)
|
|
735
|
+
throw new Error("MirrorX company is not paired");
|
|
736
|
+
const config = loadedConfig;
|
|
737
|
+
if (!config.deviceToken || config.signedOut)
|
|
738
|
+
throw new Error("Sign in or explicitly reconnect this MirrorX tool first");
|
|
739
|
+
if (input.expectedSubject && config.userSubject !== input.expectedSubject) {
|
|
740
|
+
throw new Error("MirrorX device pairing belongs to another signed-in user");
|
|
741
|
+
}
|
|
742
|
+
if (input.expectedBaseUrl && base(config.baseUrl) !== base(input.expectedBaseUrl)) {
|
|
743
|
+
throw new Error("MirrorX device pairing belongs to another service");
|
|
744
|
+
}
|
|
745
|
+
const resourceSuffix = config.principalId && config.deviceProfileId
|
|
746
|
+
? `-${deviceResourceKey({ ...config, bindingId: config.workspaceMode === "managed" ? config.bindingId : undefined, principalId: config.principalId, deviceProfileId: config.deviceProfileId })}` : "";
|
|
747
|
+
const journalPath = path.join(mirrorxDeviceHome(), `${input.executorKind}-${config.companyId}${resourceSuffix}-journal.json`);
|
|
748
|
+
const outputRoot = path.join(mirrorxDeviceHome(), "output-outbox", config.companyId);
|
|
749
|
+
let loadedJournal = await loadJson(journalPath);
|
|
750
|
+
if (!loadedJournal) {
|
|
751
|
+
// Legacy journals have no owner metadata. Only the matching saved config
|
|
752
|
+
// may prove their binding, account, server and generation before adoption.
|
|
753
|
+
for (const stem of [`${input.executorKind}-${config.companyId}`, input.executorKind]) {
|
|
754
|
+
const legacy = await loadJson(path.join(mirrorxDeviceHome(), `${stem}.json`));
|
|
755
|
+
const sameBinding = legacy && legacy.companyId === config.companyId && base(legacy.baseUrl) === base(config.baseUrl)
|
|
756
|
+
&& legacy.bindingId === config.bindingId && legacy.userSubject === config.userSubject
|
|
757
|
+
&& (legacy.generation ?? 1) === (config.generation ?? 1)
|
|
758
|
+
&& (!legacy.principalId || legacy.principalId === config.principalId);
|
|
759
|
+
if (!sameBinding)
|
|
760
|
+
continue;
|
|
761
|
+
loadedJournal = await loadJson(path.join(mirrorxDeviceHome(), `${stem}-journal.json`));
|
|
762
|
+
if (loadedJournal) {
|
|
763
|
+
await saveJson(journalPath, loadedJournal);
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
const journal = loadedJournal ?? {};
|
|
769
|
+
const active = new Map();
|
|
770
|
+
const stopTasks = new WeakMap();
|
|
771
|
+
function requestStop(child) {
|
|
772
|
+
if (stopTasks.has(child))
|
|
773
|
+
return;
|
|
774
|
+
const stop = stopProcessTree(child, input.executorKind === "codex");
|
|
775
|
+
stopTasks.set(child, stop);
|
|
776
|
+
void stop.catch(() => undefined); // execute() observes the same failure before reporting completion.
|
|
777
|
+
}
|
|
778
|
+
const inFlight = new Set();
|
|
779
|
+
const executions = new Set();
|
|
780
|
+
const reportedReconciliation = new Set();
|
|
781
|
+
const reportedOutputPending = new Set();
|
|
782
|
+
const reportedConfigurationPending = new Set();
|
|
783
|
+
const recentlyCompleted = new Set();
|
|
784
|
+
const receiptRetryAt = new Map();
|
|
785
|
+
async function persist() { await saveJson(journalPath, journal); }
|
|
786
|
+
async function reclaimRetainedConfigurations() {
|
|
787
|
+
let changed = false;
|
|
788
|
+
for (const [id, record] of Object.entries(journal)) {
|
|
789
|
+
if (!record.configurationDirectory || inFlight.has(id))
|
|
790
|
+
continue;
|
|
791
|
+
try {
|
|
792
|
+
if (await reclaimConfiguration(record, false, () => reclaimCodebuddyLocks(record, mirrorxDeviceHome()))) {
|
|
793
|
+
delete record.configurationDirectory;
|
|
794
|
+
delete record.configurationToken;
|
|
795
|
+
changed = true;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
catch {
|
|
799
|
+
if (!reportedConfigurationPending.has(id)) {
|
|
800
|
+
reportedConfigurationPending.add(id);
|
|
801
|
+
input.onState?.(`Command ${id} private configuration cleanup requires operator attention`);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
if (changed)
|
|
806
|
+
await persist();
|
|
807
|
+
}
|
|
808
|
+
const api = (route, options = {}) => request(config.baseUrl, `${pluginPath}${route}`, {
|
|
809
|
+
...(options.method ? { method: options.method } : {}),
|
|
810
|
+
...(options.body === undefined ? {} : { body: options.body }),
|
|
811
|
+
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
|
812
|
+
deviceToken: config.deviceToken,
|
|
813
|
+
...(signal && !options.afterStop ? { signal: signal } : {}),
|
|
814
|
+
});
|
|
815
|
+
async function uploadOutput(command, segment) {
|
|
816
|
+
const bytes = Buffer.from(segment.chunk, "utf8");
|
|
817
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
818
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
819
|
+
try {
|
|
820
|
+
const prepared = await api(`/commands/${command.id}/output/prepare`, {
|
|
821
|
+
method: "POST", body: { companyId: config.companyId, claimId: command.claimId,
|
|
822
|
+
seq: segment.seq, stream: segment.stream, byteSize: bytes.length, sha256 },
|
|
823
|
+
timeoutMs: 15_000, afterStop: true,
|
|
824
|
+
});
|
|
825
|
+
if (!prepared.accepted)
|
|
826
|
+
throw new Error("MirrorX did not prepare the output object");
|
|
827
|
+
if (!prepared.committed && !prepared.discard) {
|
|
828
|
+
if (!prepared.url)
|
|
829
|
+
throw new Error("MirrorX did not provide an output upload address");
|
|
830
|
+
const response = await fetch(prepared.url, {
|
|
831
|
+
method: "PUT", headers: prepared.headers ?? {}, body: bytes,
|
|
832
|
+
signal: AbortSignal.timeout(15_000), redirect: "error",
|
|
833
|
+
});
|
|
834
|
+
if (!response.ok)
|
|
835
|
+
throw new Error(`MirrorX output object upload failed: HTTP ${response.status}`);
|
|
836
|
+
await api(`/commands/${command.id}/output/commit`, {
|
|
837
|
+
method: "POST", body: { companyId: config.companyId, claimId: command.claimId, seq: segment.seq },
|
|
838
|
+
timeoutMs: 15_000, afterStop: true,
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
await unlink(outputSegmentFile(path.join(outputRoot, command.id), segment.seq)).catch((error) => {
|
|
842
|
+
if (error.code !== "ENOENT")
|
|
843
|
+
throw error;
|
|
844
|
+
});
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
catch (error) {
|
|
848
|
+
if (error instanceof DeviceRequestError
|
|
849
|
+
&& [400, 401, 403, 404, 409, 410, 422].includes(error.status))
|
|
850
|
+
throw error;
|
|
851
|
+
if (attempt === 2)
|
|
852
|
+
throw error;
|
|
853
|
+
if (attempt === 0)
|
|
854
|
+
input.onState?.(`MirrorX output transfer is retrying for command ${command.id}`);
|
|
855
|
+
// This path also drains output after a requested stop. Keep the retry
|
|
856
|
+
// bounded, then leave the durable outbox for a later connection.
|
|
857
|
+
await delay(1_000 * 2 ** attempt);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
throw new Error("MirrorX output upload was not acknowledged");
|
|
861
|
+
}
|
|
862
|
+
async function replayOutput(command) {
|
|
863
|
+
const directory = path.join(outputRoot, command.id);
|
|
864
|
+
const names = await readdir(directory).catch((error) => {
|
|
865
|
+
if (error.code === "ENOENT")
|
|
866
|
+
return [];
|
|
867
|
+
throw error;
|
|
868
|
+
});
|
|
869
|
+
for (const name of names.filter((value) => /^\d{10}\.json$/.test(value)).sort()) {
|
|
870
|
+
const segment = await loadJson(path.join(directory, name));
|
|
871
|
+
if (!segment)
|
|
872
|
+
continue;
|
|
873
|
+
await uploadOutput(command, segment);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
async function complete(command, result, outputLost = false) {
|
|
877
|
+
receiptRetryAt.set(command.id, Date.now() + 10_000);
|
|
878
|
+
if (!outputLost)
|
|
879
|
+
await replayOutput(command);
|
|
880
|
+
await api(`/commands/${command.id}/complete`, {
|
|
881
|
+
method: "POST", body: { companyId: config.companyId, claimId: command.claimId, result,
|
|
882
|
+
...(outputLost ? { outputLost: true } : {}) },
|
|
883
|
+
timeoutMs: 10_000, afterStop: true,
|
|
884
|
+
});
|
|
885
|
+
recentlyCompleted.add(command.id);
|
|
886
|
+
receiptRetryAt.delete(command.id);
|
|
887
|
+
// A successful acknowledgement is durable on the server. Keep finished
|
|
888
|
+
// results only while an acknowledgement is missing, so normal operation
|
|
889
|
+
// does not rewrite an ever-growing journal on every state transition.
|
|
890
|
+
delete journal[command.id];
|
|
891
|
+
await persist();
|
|
892
|
+
const outputDirectory = path.join(outputRoot, command.id);
|
|
893
|
+
if (outputLost) {
|
|
894
|
+
await rm(outputDirectory, { recursive: true, force: true });
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
await readdir(outputDirectory).then(async (names) => {
|
|
898
|
+
if (names.length === 0)
|
|
899
|
+
await rmdir(outputDirectory);
|
|
900
|
+
}).catch(() => undefined);
|
|
901
|
+
}
|
|
902
|
+
function reportReconciliation(command, error) {
|
|
903
|
+
if (reportedReconciliation.has(command.id))
|
|
904
|
+
return;
|
|
905
|
+
reportedReconciliation.add(command.id);
|
|
906
|
+
const reason = error instanceof Error ? `: ${error.message}` : "";
|
|
907
|
+
input.onState?.(`Command ${command.id} needs manual reconciliation${reason}`);
|
|
908
|
+
}
|
|
909
|
+
async function execute(command) {
|
|
910
|
+
const prior = journal[command.id];
|
|
911
|
+
if (prior?.state === "finished" && prior.result) {
|
|
912
|
+
await complete(command, prior.result, prior.outputLost === true);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
const retryableClaim = prior?.state === "claimed" && prior.preSpawnSafe === true;
|
|
916
|
+
if (retryableClaim && prior.configurationDirectory) {
|
|
917
|
+
// Preparation may have crashed with workspace/session locks held. Keep
|
|
918
|
+
// its recovery authority until confirmed-stop reclamation, never overwrite it.
|
|
919
|
+
reportReconciliation(command, new Error("Retained preparation requires reclaim-config after confirmed stop"));
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
if (prior && !retryableClaim && !active.has(command.id)) {
|
|
923
|
+
// A previous process may have reached external systems. Never relaunch it
|
|
924
|
+
// just because this bridge lost its in-memory child handle. Older claimed
|
|
925
|
+
// records lack the write-ahead guarantee and also need reconciliation.
|
|
926
|
+
reportReconciliation(command);
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
if (active.has(command.id)) {
|
|
930
|
+
if (command.stopRequested)
|
|
931
|
+
requestStop(active.get(command.id));
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (command.state === "running" && !retryableClaim) {
|
|
935
|
+
// A previous bridge may still own this process. A new instance may
|
|
936
|
+
// receive its command after takeover, but must never launch it again.
|
|
937
|
+
reportReconciliation(command);
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
if (command.state === "reconcile_required" && !retryableClaim) {
|
|
941
|
+
reportReconciliation(command);
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
// "claimed" is written before contacting the server, and "running" is
|
|
945
|
+
// durably written before spawn below. Only a claimed record proves that
|
|
946
|
+
// this client has not launched the process, including after a lost start
|
|
947
|
+
// response or a claim expiry.
|
|
948
|
+
journal[command.id] = { claimId: command.claimId, state: "claimed", preSpawnSafe: true };
|
|
949
|
+
await persist();
|
|
950
|
+
const start = await api(`/commands/${command.id}/start`, {
|
|
951
|
+
method: "POST", body: { companyId: config.companyId, claimId: command.claimId,
|
|
952
|
+
instanceId, preSpawnSafe: true },
|
|
953
|
+
});
|
|
954
|
+
if (!start.accepted)
|
|
955
|
+
throw new Error("MirrorX did not accept command start");
|
|
956
|
+
signal.throwIfAborted();
|
|
957
|
+
if (start.stopRequested) {
|
|
958
|
+
const result = { exitCode: null, signal: "SIGTERM", timedOut: false, stdout: "", stderr: "" };
|
|
959
|
+
journal[command.id] = { claimId: command.claimId, state: "finished", result };
|
|
960
|
+
await persist();
|
|
961
|
+
await complete(command, result);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
const payload = command.payload;
|
|
965
|
+
let prepared;
|
|
966
|
+
try {
|
|
967
|
+
if (config.workspaceMode === "managed") {
|
|
968
|
+
const execution = payload.managedExecution;
|
|
969
|
+
if (!execution || execution.companyId !== config.companyId || execution.bindingId !== config.bindingId || execution.agentId !== config.agentId)
|
|
970
|
+
throw new Error("Managed command identity mismatch");
|
|
971
|
+
await validateManagedCwd(config.workspaceRoot, execution.cwd);
|
|
972
|
+
if (!payload.cwd || (path.relative(execution.cwd, payload.cwd).split(path.sep)[0] === "..") || path.isAbsolute(path.relative(execution.cwd, payload.cwd)))
|
|
973
|
+
throw new Error("Managed command escaped its task");
|
|
974
|
+
}
|
|
975
|
+
prepared = input.executorKind === "codebuddy" && isCodebuddyCommand(payload.command)
|
|
976
|
+
? await prepareCodebuddyCommand({ args: payload.args, env: payload.env, cwd: payload.cwd ?? config.workspaceRoot,
|
|
977
|
+
authority: { baseUrl: config.baseUrl, companyId: config.companyId, agentId: config.agentId, bindingId: config.bindingId, ...(config.deviceProfileId ? { deviceProfileId: config.deviceProfileId } : {}), ...(config.generation ? { generation: config.generation } : {}) }, stateRoot: mirrorxDeviceHome(),
|
|
978
|
+
onConfiguration: async (reference) => {
|
|
979
|
+
journal[command.id] = { claimId: command.claimId, state: "claimed", preSpawnSafe: true, ...reference };
|
|
980
|
+
await persist();
|
|
981
|
+
} })
|
|
982
|
+
: await preparePiDeviceCommand(payload.command, payload.args, payload.env, input.executorKind === "pi" ? input.piRuntime ?? {
|
|
983
|
+
configDir: process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent"),
|
|
984
|
+
} : undefined);
|
|
985
|
+
}
|
|
986
|
+
catch (error) {
|
|
987
|
+
signal.throwIfAborted();
|
|
988
|
+
const reference = journal[command.id];
|
|
989
|
+
if (reference?.configurationDirectory && await stat(reference.configurationDirectory).catch((failure) => {
|
|
990
|
+
if (failure.code === "ENOENT")
|
|
991
|
+
return null;
|
|
992
|
+
throw failure;
|
|
993
|
+
})) {
|
|
994
|
+
reportReconciliation(command, new Error("Retained preparation requires reclaim-config after confirmed stop"));
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
const result = { exitCode: 1, signal: null, timedOut: false, stdout: input.executorKind === "codebuddy" && isCodebuddyCommand(payload.command)
|
|
998
|
+
? JSON.stringify({ type: "mirrorx_codebuddy_bootstrap_error", version: 1, providerWorkStarted: false }) + "\n" : "",
|
|
999
|
+
stderr: error instanceof Error ? error.message : "Could not prepare Pi device runtime" };
|
|
1000
|
+
journal[command.id] = { claimId: command.claimId, state: "finished", result };
|
|
1001
|
+
await persist();
|
|
1002
|
+
await complete(command, result);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
let executionOutcome = "not-started";
|
|
1006
|
+
try {
|
|
1007
|
+
signal.throwIfAborted();
|
|
1008
|
+
const outputDirectory = path.join(outputRoot, command.id);
|
|
1009
|
+
await mkdir(outputDirectory, { recursive: true, mode: 0o700 });
|
|
1010
|
+
signal.throwIfAborted();
|
|
1011
|
+
journal[command.id] = { claimId: command.claimId, state: "running",
|
|
1012
|
+
...(prepared.configDirectory ? { configurationDirectory: prepared.configDirectory,
|
|
1013
|
+
configurationToken: prepared.configurationToken } : {}) };
|
|
1014
|
+
await persist();
|
|
1015
|
+
const executable = input.executorKind === "pi" && isPiDeviceCommand(payload.command)
|
|
1016
|
+
? input.piRuntime?.executable : undefined;
|
|
1017
|
+
// Preparation and the write-ahead journal both await I/O. Cancellation
|
|
1018
|
+
// may have drained active while this command did not yet own a child.
|
|
1019
|
+
// No await may separate this check, spawn and active.set below.
|
|
1020
|
+
signal.throwIfAborted();
|
|
1021
|
+
const launchCwd = prepared.cwd ?? payload.cwd ?? config.workspaceRoot;
|
|
1022
|
+
let launch = { command: prepared.command ?? executable?.command ?? (payload.command === "node" ? process.execPath : payload.command),
|
|
1023
|
+
args: [...(executable?.argsPrefix ?? []), ...prepared.args],
|
|
1024
|
+
env: prepared.command ? prepared.env : { ...process.env, ...prepared.env, ...(executable ? { PI_AGENT_NODE: executable.command } : {}) } };
|
|
1025
|
+
if (input.executorKind === "codex")
|
|
1026
|
+
launch.command = await resolveCodexExecutable(launch.command, launchCwd, launch.env);
|
|
1027
|
+
if (config.workspaceMode === "managed")
|
|
1028
|
+
launch = await confineManagedCommand({ ...launch, cwd: launchCwd, workspaceRoot: config.workspaceRoot, ...(executable?.readableFiles ? { runtimeFiles: executable.readableFiles } : {}), ...(payload.managedExecution ? { taskRoot: payload.managedExecution.cwd } : {}), ...(prepared.configDirectory ? { configurationDirectory: prepared.configDirectory,
|
|
1029
|
+
privateConfigurationCwd: input.executorKind === "codebuddy" && isCodebuddyCommand(payload.command) } : {}) });
|
|
1030
|
+
signal.throwIfAborted();
|
|
1031
|
+
const child = spawn(launch.command, launch.args, {
|
|
1032
|
+
cwd: launchCwd,
|
|
1033
|
+
env: launch.env,
|
|
1034
|
+
shell: false,
|
|
1035
|
+
detached: process.platform !== "win32",
|
|
1036
|
+
windowsHide: true,
|
|
1037
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1038
|
+
});
|
|
1039
|
+
if (child.pid)
|
|
1040
|
+
executionOutcome = "running";
|
|
1041
|
+
const configuration = prepared.configDirectory && prepared.configurationToken
|
|
1042
|
+
? observeConfiguration({ configurationDirectory: prepared.configDirectory, configurationToken: prepared.configurationToken }, child, () => input.onState?.(`Command ${command.id} private configuration cleanup requires operator attention`)) : undefined;
|
|
1043
|
+
active.set(command.id, child);
|
|
1044
|
+
let stdout = { head: "", tail: "", total: 0 };
|
|
1045
|
+
let stderr = { head: "", tail: "", total: 0 };
|
|
1046
|
+
let timedOut = false;
|
|
1047
|
+
let outputSeq = 0;
|
|
1048
|
+
let pendingChars = 0;
|
|
1049
|
+
let outputError = null;
|
|
1050
|
+
let stagingError = null;
|
|
1051
|
+
let outputUploads = Promise.resolve();
|
|
1052
|
+
const stagingWrites = new Set();
|
|
1053
|
+
const buffers = { stdout: "", stderr: "" };
|
|
1054
|
+
let flushTimer = null;
|
|
1055
|
+
const decoders = { stdout: new StringDecoder("utf8"), stderr: new StringDecoder("utf8") };
|
|
1056
|
+
function flushOutput(stream) {
|
|
1057
|
+
while (buffers[stream]) {
|
|
1058
|
+
const chunk = takeOutputChunk(buffers[stream], OUTPUT_SEGMENT_CHARS);
|
|
1059
|
+
buffers[stream] = buffers[stream].slice(chunk.length);
|
|
1060
|
+
const segment = { seq: ++outputSeq, stream, chunk };
|
|
1061
|
+
pendingChars += chunk.length;
|
|
1062
|
+
if (!outputError && pendingChars >= OUTPUT_PENDING_CHARS) {
|
|
1063
|
+
child.stdout?.pause();
|
|
1064
|
+
child.stderr?.pause();
|
|
1065
|
+
}
|
|
1066
|
+
const staged = saveJson(outputSegmentFile(outputDirectory, segment.seq), segment);
|
|
1067
|
+
stagingWrites.add(staged);
|
|
1068
|
+
void staged.then(() => stagingWrites.delete(staged), (error) => {
|
|
1069
|
+
stagingWrites.delete(staged);
|
|
1070
|
+
// Once local spooling fails, neither the live upload nor a later
|
|
1071
|
+
// reconnect can recover the stream. Stop instead of hanging forever.
|
|
1072
|
+
stagingError = error;
|
|
1073
|
+
outputError = error;
|
|
1074
|
+
requestStop(child);
|
|
1075
|
+
});
|
|
1076
|
+
outputUploads = outputUploads.then(async () => {
|
|
1077
|
+
await staged;
|
|
1078
|
+
if (outputError)
|
|
1079
|
+
return;
|
|
1080
|
+
await uploadOutput(command, segment);
|
|
1081
|
+
pendingChars -= chunk.length;
|
|
1082
|
+
if (pendingChars < OUTPUT_PENDING_CHARS / 2 && !signal?.aborted) {
|
|
1083
|
+
child.stdout?.resume();
|
|
1084
|
+
child.stderr?.resume();
|
|
1085
|
+
}
|
|
1086
|
+
}).catch((error) => {
|
|
1087
|
+
outputError = error;
|
|
1088
|
+
// A transfer failure must not leave a noisy process blocked on a
|
|
1089
|
+
// paused pipe. Keep staging later segments for reconnect replay.
|
|
1090
|
+
pendingChars = 0;
|
|
1091
|
+
child.stdout?.resume();
|
|
1092
|
+
child.stderr?.resume();
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
function appendOutput(stream, text, final = false) {
|
|
1097
|
+
if (stream === "stdout")
|
|
1098
|
+
prepared.observeStdout?.(text);
|
|
1099
|
+
if (prepared.redact)
|
|
1100
|
+
text = prepared.redact(stream, text, final);
|
|
1101
|
+
if (!text)
|
|
1102
|
+
return;
|
|
1103
|
+
if (stream === "stdout")
|
|
1104
|
+
stdout = retainOutput(stdout.head, stdout.tail, stdout.total, text);
|
|
1105
|
+
else
|
|
1106
|
+
stderr = retainOutput(stderr.head, stderr.tail, stderr.total, text);
|
|
1107
|
+
buffers[stream] += text;
|
|
1108
|
+
if (buffers[stream].length >= OUTPUT_SEGMENT_CHARS)
|
|
1109
|
+
flushOutput(stream);
|
|
1110
|
+
if (!flushTimer) {
|
|
1111
|
+
flushTimer = setTimeout(() => {
|
|
1112
|
+
flushTimer = null;
|
|
1113
|
+
flushOutput("stdout");
|
|
1114
|
+
flushOutput("stderr");
|
|
1115
|
+
}, 500);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
child.stdout?.on("data", (part) => appendOutput("stdout", decoders.stdout.write(part)));
|
|
1119
|
+
child.stderr?.on("data", (part) => appendOutput("stderr", decoders.stderr.write(part)));
|
|
1120
|
+
child.stdin?.end(payload.stdin ?? undefined);
|
|
1121
|
+
const timeout = payload.timeoutMs > 0
|
|
1122
|
+
? setTimeout(() => {
|
|
1123
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
1124
|
+
return;
|
|
1125
|
+
timedOut = true;
|
|
1126
|
+
requestStop(child);
|
|
1127
|
+
}, payload.timeoutMs)
|
|
1128
|
+
: null;
|
|
1129
|
+
let rootExitedAt = 0, stdioClosed = false;
|
|
1130
|
+
child.once("close", () => { stdioClosed = true; });
|
|
1131
|
+
const result = await new Promise((resolve) => {
|
|
1132
|
+
child.once("error", (error) => {
|
|
1133
|
+
rootExitedAt = Date.now();
|
|
1134
|
+
resolve({ exitCode: null, signal: null, timedOut: false,
|
|
1135
|
+
stdout: retainedResult(stdout),
|
|
1136
|
+
stderr: retainedResult(retainOutput(stderr.head, stderr.tail, stderr.total, error.message)),
|
|
1137
|
+
});
|
|
1138
|
+
});
|
|
1139
|
+
child.once("exit", (exitCode, signal) => {
|
|
1140
|
+
rootExitedAt = Date.now();
|
|
1141
|
+
resolve({ exitCode, signal, timedOut, stdout: retainedResult(stdout), stderr: retainedResult(stderr) });
|
|
1142
|
+
});
|
|
1143
|
+
});
|
|
1144
|
+
try {
|
|
1145
|
+
const completion = await confirmCommandCompletion({ child,
|
|
1146
|
+
stopTask: () => stopTasks.get(child), observeDescendants: !!prepared.configDirectory,
|
|
1147
|
+
rootExitedAt, stdioClosed: () => stdioClosed, signal: signal,
|
|
1148
|
+
});
|
|
1149
|
+
executionOutcome = completion.state;
|
|
1150
|
+
if (completion.state === "reconcile") {
|
|
1151
|
+
child.stdin?.destroy();
|
|
1152
|
+
if (configuration)
|
|
1153
|
+
configuration.retain();
|
|
1154
|
+
else {
|
|
1155
|
+
child.stdout?.destroy();
|
|
1156
|
+
child.stderr?.destroy();
|
|
1157
|
+
}
|
|
1158
|
+
reportReconciliation(command, completion.error);
|
|
1159
|
+
return; // Journal remains running; the reconciliation notice owns this outcome.
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
finally {
|
|
1163
|
+
if (timeout)
|
|
1164
|
+
clearTimeout(timeout);
|
|
1165
|
+
if (flushTimer)
|
|
1166
|
+
clearTimeout(flushTimer);
|
|
1167
|
+
// Execution ownership ends here. inFlight still covers output/ACK
|
|
1168
|
+
// retries; a late stop must not signal a completed/reaped process.
|
|
1169
|
+
active.delete(command.id);
|
|
1170
|
+
}
|
|
1171
|
+
if (config.workspaceMode === "managed" && payload.managedExecution && ["pi", "codex"].includes(config.executorKind)) {
|
|
1172
|
+
await recordDeviceSession({ home: mirrorxDeviceHome(), authority: config, cwd: payload.managedExecution.cwd,
|
|
1173
|
+
runId: payload.managedExecution.runId, ...(payload.managedExecution.issueId ? { issueId: payload.managedExecution.issueId } : {}),
|
|
1174
|
+
args: payload.args, env: payload.env, stdout: result.stdout, executorKind: config.executorKind })
|
|
1175
|
+
.catch(() => input.onState?.("Native session history index could not be saved; original task execution and files are retained"));
|
|
1176
|
+
}
|
|
1177
|
+
// Persist shutdown evidence and reclaim credentials before replacing the
|
|
1178
|
+
// running record or ACKing. A crash during removal is retried on startup.
|
|
1179
|
+
// Some CLIs catch SIGTERM and exit 0 without a terminal protocol event.
|
|
1180
|
+
// Once shutdown is verified, preserve the stop outcome rather than asking
|
|
1181
|
+
// the provider to checkpoint a success that never happened.
|
|
1182
|
+
if (stopTasks.has(child) && !result.signal && !result.timedOut) {
|
|
1183
|
+
result.signal = input.executorKind === "codex" ? "SIGINT" : "SIGTERM";
|
|
1184
|
+
}
|
|
1185
|
+
if (configuration) {
|
|
1186
|
+
try {
|
|
1187
|
+
if (await prepared.finalize?.(result.exitCode === 0 && !result.timedOut && !result.signal) === "failed") {
|
|
1188
|
+
result.exitCode = 1;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
catch (error) {
|
|
1192
|
+
executionOutcome = "reconcile";
|
|
1193
|
+
reportReconciliation(command, error);
|
|
1194
|
+
return; // Keep the running journal and private profile; do not replay or claim the checkpoint survived.
|
|
1195
|
+
}
|
|
1196
|
+
await configuration.completed();
|
|
1197
|
+
await prepared.cleanup();
|
|
1198
|
+
}
|
|
1199
|
+
appendOutput("stdout", decoders.stdout.end(), true);
|
|
1200
|
+
appendOutput("stderr", decoders.stderr.end(), true);
|
|
1201
|
+
if (flushTimer)
|
|
1202
|
+
clearTimeout(flushTimer);
|
|
1203
|
+
flushOutput("stdout");
|
|
1204
|
+
flushOutput("stderr");
|
|
1205
|
+
// The adapter's generic file-read fallback emits base64 bytes on stdout.
|
|
1206
|
+
// The plugin reconstructs that bounded read from object storage on return;
|
|
1207
|
+
// the command result in PostgreSQL must not duplicate credential bytes.
|
|
1208
|
+
const isTransferRead = payload.args.some((arg) => /\bdd if=.*\|\s*base64\b/.test(arg));
|
|
1209
|
+
result.stdout = isTransferRead ? "" : retainedResult(stdout);
|
|
1210
|
+
result.stderr = retainedResult(stderr);
|
|
1211
|
+
await Promise.allSettled([...stagingWrites]);
|
|
1212
|
+
if (stagingError) {
|
|
1213
|
+
// Missing bytes cannot be replayed. Report a failed command instead of
|
|
1214
|
+
// pretending the partial output is a successful execution.
|
|
1215
|
+
if (result.exitCode === 0)
|
|
1216
|
+
result.exitCode = 1;
|
|
1217
|
+
result.stderr = `${result.stderr}\nMirrorX could not stage command output on this device`.trim();
|
|
1218
|
+
}
|
|
1219
|
+
const finalResult = boundedResult(result, config.companyId, command.claimId);
|
|
1220
|
+
journal[command.id] = { claimId: command.claimId, state: "finished", result: finalResult,
|
|
1221
|
+
...(stagingError ? { outputLost: true } : {}) };
|
|
1222
|
+
await persist();
|
|
1223
|
+
await outputUploads;
|
|
1224
|
+
if (stagingError) {
|
|
1225
|
+
await complete(command, finalResult, true);
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
if (outputError)
|
|
1229
|
+
throw outputError;
|
|
1230
|
+
await complete(command, finalResult);
|
|
1231
|
+
}
|
|
1232
|
+
finally {
|
|
1233
|
+
// An unconfirmed child may still read its private configuration on either
|
|
1234
|
+
// platform. The journal reaper consumes verified shutdown evidence; an
|
|
1235
|
+
// ambiguous crash or failed Windows tree stop needs operator confirmation.
|
|
1236
|
+
const retainConfiguration = ["running", "reconcile"].includes(executionOutcome);
|
|
1237
|
+
if (!retainConfiguration) {
|
|
1238
|
+
if (executionOutcome === "not-started")
|
|
1239
|
+
await prepared.rollbackUnstarted?.();
|
|
1240
|
+
await prepared.cleanup();
|
|
1241
|
+
if (executionOutcome === "not-started") {
|
|
1242
|
+
// This bridge knows spawn was never reached. Restore the safe claim
|
|
1243
|
+
// only after cleanup; a real/unknown launched process stays running.
|
|
1244
|
+
journal[command.id] = { claimId: command.claimId, state: "claimed", preSpawnSafe: true };
|
|
1245
|
+
await persist();
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
else if (prepared.configDirectory)
|
|
1249
|
+
input.onState?.(`Command ${command.id} retained private ${input.executorKind === "codebuddy" ? "WorkBuddy" : "Pi"} configuration because descendant shutdown could not be verified`);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
const instanceId = randomUUID();
|
|
1253
|
+
const releaseBridgeLock = config.principalId && config.deviceProfileId
|
|
1254
|
+
? await acquireDeviceResourceLock({ ...config, bindingId: config.workspaceMode === "managed" ? config.bindingId : undefined, principalId: config.principalId, deviceProfileId: config.deviceProfileId }, instanceId)
|
|
1255
|
+
: await acquireBridgeLock(input.executorKind, config.companyId, instanceId);
|
|
1256
|
+
let readiness = { state: "unknown", checkedAt: new Date().toISOString() };
|
|
1257
|
+
let readinessTask;
|
|
1258
|
+
let nextReadinessAt = 0;
|
|
1259
|
+
let managedEnrollmentTask;
|
|
1260
|
+
function refreshReadiness() {
|
|
1261
|
+
if (readinessTask || Date.now() < nextReadinessAt || signal.aborted)
|
|
1262
|
+
return;
|
|
1263
|
+
nextReadinessAt = Date.now() + 30_000;
|
|
1264
|
+
readinessTask = inspectDeviceReadiness({ executorKind: config.executorKind, workspaceRoot: config.workspaceRoot, piAvailable: input.piRuntime?.available,
|
|
1265
|
+
...(input.piRuntime?.executable?.command ? { piExecutable: input.piRuntime.executable.command } : {}) })
|
|
1266
|
+
.then(result => { readiness = config.workspaceMode === "managed" && process.platform !== "darwin" ? { state: "workspace_unavailable", checkedAt: result.checkedAt } : result; }).catch(() => { readiness = { state: "unknown", checkedAt: new Date().toISOString() }; })
|
|
1267
|
+
.finally(() => { readinessTask = undefined; });
|
|
1268
|
+
}
|
|
1269
|
+
function retryFinishedReceipt(commandId) {
|
|
1270
|
+
// Drain settles attempts already in flight, but must never replenish them.
|
|
1271
|
+
// This gate covers both background recovery and finished commands from poll.
|
|
1272
|
+
if (input.drainSignal?.aborted)
|
|
1273
|
+
return;
|
|
1274
|
+
// A server may commit completion before its HTTP response is lost. Such a
|
|
1275
|
+
// terminal command is no longer returned by /poll. Replay only the durable
|
|
1276
|
+
// result, never the process, and retain the journal until it is acknowledged.
|
|
1277
|
+
// Oldest eligible attempt first: a permanently rejected record must not
|
|
1278
|
+
// starve later receipts when an idle long poll exceeds the retry interval.
|
|
1279
|
+
const pending = Object.entries(journal).filter(([id, record]) => (!commandId || id === commandId)
|
|
1280
|
+
&& record.state === "finished" && record.result && !inFlight.has(id)
|
|
1281
|
+
&& Date.now() >= (receiptRetryAt.get(id) ?? 0))
|
|
1282
|
+
.sort(([left], [right]) => (receiptRetryAt.get(left) ?? 0) - (receiptRetryAt.get(right) ?? 0))[0];
|
|
1283
|
+
if (!pending)
|
|
1284
|
+
return;
|
|
1285
|
+
const [id, record] = pending;
|
|
1286
|
+
inFlight.add(id);
|
|
1287
|
+
const receipt = complete({ id, claimId: record.claimId }, record.result, record.outputLost === true)
|
|
1288
|
+
.catch(() => {
|
|
1289
|
+
if (!reportedOutputPending.has(id)) {
|
|
1290
|
+
reportedOutputPending.add(id);
|
|
1291
|
+
input.onState?.(`Command ${id} finished locally; its receipt will retry when reachable`);
|
|
1292
|
+
}
|
|
1293
|
+
})
|
|
1294
|
+
.finally(() => { inFlight.delete(id); executions.delete(receipt); });
|
|
1295
|
+
executions.add(receipt);
|
|
1296
|
+
}
|
|
1297
|
+
try {
|
|
1298
|
+
await reclaimRetainedConfigurations();
|
|
1299
|
+
while (!signal?.aborted) {
|
|
1300
|
+
if (input.drainSignal?.aborted && executions.size === 0) {
|
|
1301
|
+
if (Object.values(journal).some(record => record.state === "running" || record.state === "finished")) {
|
|
1302
|
+
throw new Error("Device drain requires reconciliation; original journal retained");
|
|
1303
|
+
}
|
|
1304
|
+
break;
|
|
1305
|
+
}
|
|
1306
|
+
retryFinishedReceipt();
|
|
1307
|
+
try {
|
|
1308
|
+
await reclaimRetainedConfigurations();
|
|
1309
|
+
refreshReadiness(); // Never delay the independent lease/long-poll loop.
|
|
1310
|
+
await api("/connect", {
|
|
1311
|
+
method: "POST", body: {
|
|
1312
|
+
companyId: config.companyId,
|
|
1313
|
+
instanceId,
|
|
1314
|
+
workspaceRoot: config.workspaceRoot,
|
|
1315
|
+
capabilities: { [input.executorKind]: true, ...(config.managedBy === "desktop" && config.executorKind === "pi" && config.workspaceMode !== "managed" && input.piRuntime && input.piRuntime.available !== false && process.platform === "darwin" ? { managedPiEmployees: true } : {}), ...(config.managedBy === "desktop" && process.platform === "darwin" ? { managedWorkspaces: "seatbelt" } : {}), ...(config.workspaceMode === "managed" ? { workspaceMode: "managed", filesystemBoundary: process.platform === "darwin" ? "seatbelt" : "unavailable" } : {}), readiness, protocol: clientManifest.mirrorxDeviceProtocol, channel: config.managedBy === "desktop" ? "desktop" : "npm", clientVersion: clientManifest.version, platform: `${process.platform}-${process.arch}` },
|
|
1316
|
+
},
|
|
1317
|
+
});
|
|
1318
|
+
input.onConnectionState?.("connected");
|
|
1319
|
+
if (input.executorKind === "pi" && config.managedBy === "desktop" && input.onManagedIntent && !managedEnrollmentTask) {
|
|
1320
|
+
managedEnrollmentTask = (async () => {
|
|
1321
|
+
const pending = await request(config.baseUrl, "/api/devices/managed-intents", { deviceToken: config.deviceToken, signal }).catch(() => []);
|
|
1322
|
+
const connectedBindings = new Set((await Promise.all([configsFor("pi"), configsFor("codex"), configsFor("codebuddy")])).flat().filter(entry => entry.userSubject === config.userSubject && entry.baseUrl === config.baseUrl && entry.deviceToken).map(entry => entry.bindingId));
|
|
1323
|
+
for (const intent of pending) {
|
|
1324
|
+
if (intent.bindingId && connectedBindings.has(intent.bindingId))
|
|
1325
|
+
continue;
|
|
1326
|
+
signal.throwIfAborted();
|
|
1327
|
+
await input.onManagedIntent(intent.enrollmentIntentId).catch(() => input.onState?.("Managed employee connection failed; inspect MirrorX settings before retrying"));
|
|
1328
|
+
}
|
|
1329
|
+
})().catch(() => undefined).finally(() => { managedEnrollmentTask = undefined; });
|
|
1330
|
+
}
|
|
1331
|
+
const inFlightCommandIds = [...inFlight].slice(0, 32).join(",");
|
|
1332
|
+
const polled = await api(`/poll?companyId=${encodeURIComponent(config.companyId)}&instanceId=${instanceId}`
|
|
1333
|
+
+ (inFlightCommandIds ? `&inFlightCommandIds=${encodeURIComponent(inFlightCommandIds)}` : "")
|
|
1334
|
+
+ (input.drainSignal?.aborted ? "&acceptingWork=false" : ""), { timeoutMs: 22_000 });
|
|
1335
|
+
if (signal?.aborted)
|
|
1336
|
+
break;
|
|
1337
|
+
if (!polled.command)
|
|
1338
|
+
recentlyCompleted.clear();
|
|
1339
|
+
if (polled.command && !recentlyCompleted.has(polled.command.id)) {
|
|
1340
|
+
const command = polled.command;
|
|
1341
|
+
if (inFlight.has(command.id)) {
|
|
1342
|
+
if (command.stopRequested && active.has(command.id))
|
|
1343
|
+
requestStop(active.get(command.id));
|
|
1344
|
+
}
|
|
1345
|
+
else if (journal[command.id]?.state === "finished" && journal[command.id]?.result) {
|
|
1346
|
+
// Poll and background recovery share cooldown and the original claim.
|
|
1347
|
+
retryFinishedReceipt(command.id);
|
|
1348
|
+
}
|
|
1349
|
+
else {
|
|
1350
|
+
inFlight.add(command.id);
|
|
1351
|
+
const execution = execute(command)
|
|
1352
|
+
.catch((error) => {
|
|
1353
|
+
if (!signal?.aborted && journal[command.id]?.state === "finished") {
|
|
1354
|
+
if (!reportedOutputPending.has(command.id)) {
|
|
1355
|
+
reportedOutputPending.add(command.id);
|
|
1356
|
+
input.onState?.(`Command ${command.id} finished locally; output transfer will resume when reachable`);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
else if (!signal?.aborted) {
|
|
1360
|
+
input.onState?.(error instanceof Error ? error.message : String(error));
|
|
1361
|
+
}
|
|
1362
|
+
})
|
|
1363
|
+
.finally(() => { inFlight.delete(command.id); executions.delete(execution); });
|
|
1364
|
+
executions.add(execution);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
catch (error) {
|
|
1369
|
+
if (signal?.aborted)
|
|
1370
|
+
break;
|
|
1371
|
+
if (error instanceof DeviceRequestError && [401, 403, 426].includes(error.status)) {
|
|
1372
|
+
input.onConnectionState?.("failed");
|
|
1373
|
+
connectionAbort.abort();
|
|
1374
|
+
throw error;
|
|
1375
|
+
}
|
|
1376
|
+
input.onConnectionState?.("retrying");
|
|
1377
|
+
input.onState?.(error instanceof Error ? error.message : String(error));
|
|
1378
|
+
await delay(3_000, undefined, { signal: signal }).catch(() => undefined);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
finally {
|
|
1383
|
+
connectionAbort.abort();
|
|
1384
|
+
for (const child of active.values())
|
|
1385
|
+
requestStop(child);
|
|
1386
|
+
await Promise.allSettled([...executions, ...(managedEnrollmentTask ? [managedEnrollmentTask] : [])]);
|
|
1387
|
+
// The provider accepts stop proof only from this exact live instance and
|
|
1388
|
+
// only after every queue claim has settled. Unknown journals retain fences.
|
|
1389
|
+
if (!Object.values(journal).some(record => record.state === "running" || record.state === "finished")) {
|
|
1390
|
+
await api("/disconnect", { method: "POST", body: { companyId: config.companyId, instanceId }, afterStop: true, timeoutMs: 10_000 })
|
|
1391
|
+
.catch(() => input.onState?.("Graceful disconnect is unverified; restore requires reconciliation"));
|
|
1392
|
+
}
|
|
1393
|
+
await releaseBridgeLock();
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
/** Resolve only exact references owned by this login and canonical service. */
|
|
1397
|
+
export async function findMirrorxDeviceSession(input) {
|
|
1398
|
+
for (const kind of ["pi", "codex"]) {
|
|
1399
|
+
for (const authority of await configsFor(kind)) {
|
|
1400
|
+
if (authority.userSubject !== input.expectedSubject || base(authority.baseUrl) !== base(input.expectedBaseUrl) || authority.workspaceMode !== "managed")
|
|
1401
|
+
continue;
|
|
1402
|
+
const reference = await findDeviceSession({ home: mirrorxDeviceHome(), authority, sessionId: input.sessionId });
|
|
1403
|
+
if (reference)
|
|
1404
|
+
return reference;
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
return undefined;
|
|
1408
|
+
}
|
|
1409
|
+
/** Computer-only enrollment can claim only a web-authorized managed employee. */
|
|
1410
|
+
export async function connectMirrorxComputerIntent(input) {
|
|
1411
|
+
if (!/^[0-9a-f-]{36}$/i.test(input.enrollmentIntentId))
|
|
1412
|
+
throw new Error("Invalid enrollment intent");
|
|
1413
|
+
const baseUrl = base(input.baseUrl);
|
|
1414
|
+
const intent = await request(baseUrl, `/api/computers/intents/${input.enrollmentIntentId}`, { deviceToken: input.computerToken, signal: input.signal });
|
|
1415
|
+
if (intent.enrollmentIntentId !== input.enrollmentIntentId || intent.companyId !== input.companyId || intent.principalId !== input.principalId
|
|
1416
|
+
|| intent.enrollment.workspaceMode !== "managed" || intent.enrollment.targetBindingId !== input.computerId
|
|
1417
|
+
|| !["codex", "codebuddy"].includes(intent.executorKind))
|
|
1418
|
+
throw new Error("Enrollment identity/tool mismatch");
|
|
1419
|
+
const existing = (await configsFor(intent.executorKind)).find(config => config.computerId === input.computerId && config.bindingId === intent.bindingId && base(config.baseUrl) === baseUrl && config.principalId === input.principalId);
|
|
1420
|
+
if (existing)
|
|
1421
|
+
return { bindingId: existing.bindingId, executorKind: existing.executorKind };
|
|
1422
|
+
const unlock = await acquireDeviceResourceLock({ baseUrl, principalId: input.principalId, deviceProfileId: input.deviceProfileId,
|
|
1423
|
+
companyId: input.companyId, executorKind: intent.executorKind, bindingId: input.enrollmentIntentId }, randomUUID());
|
|
1424
|
+
try {
|
|
1425
|
+
input.signal?.throwIfAborted();
|
|
1426
|
+
const workspaceRoot = await createManagedWorkspace({ ...input, workspaceId: input.enrollmentIntentId });
|
|
1427
|
+
await validateDeviceWorkspace(workspaceRoot);
|
|
1428
|
+
const requestFile = path.join(mirrorxDeviceHome(), "enrollment-requests", `${input.enrollmentIntentId}.json`);
|
|
1429
|
+
const prior = await loadJson(requestFile);
|
|
1430
|
+
const claimRequestId = prior?.claimRequestId ?? randomUUID();
|
|
1431
|
+
if (!prior)
|
|
1432
|
+
await saveJson(requestFile, { claimRequestId });
|
|
1433
|
+
const registration = await request(baseUrl, `/api/computers/intents/${input.enrollmentIntentId}/claim`, { method: "POST", deviceToken: input.computerToken, body: { claimRequestId, workspaceRoot }, signal: input.signal });
|
|
1434
|
+
if (registration.companyId !== input.companyId || registration.principalId !== input.principalId || registration.deviceProfileId !== input.deviceProfileId)
|
|
1435
|
+
throw new Error("Enrollment response identity mismatch");
|
|
1436
|
+
const config = { baseUrl, managedBy: "standalone", computerId: input.computerId, principalId: input.principalId, deviceProfileId: input.deviceProfileId,
|
|
1437
|
+
companyId: input.companyId, executorKind: intent.executorKind, generation: registration.generation, deviceId: registration.deviceId,
|
|
1438
|
+
workspaceRoot, workspaceMode: "managed", bindingId: registration.bindingId, agentId: registration.agentId, sessionId: registration.deviceSession.sessionId, deviceToken: registration.deviceSession.token };
|
|
1439
|
+
// Persist the committed result even during shutdown; the next supervisor resumes the same employee.
|
|
1440
|
+
await saveJson(configFile(config.executorKind, config.companyId, config), config);
|
|
1441
|
+
return { bindingId: config.bindingId, executorKind: config.executorKind };
|
|
1442
|
+
}
|
|
1443
|
+
finally {
|
|
1444
|
+
await unlock();
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
export async function restoreMirrorxComputerEmployee(input) {
|
|
1448
|
+
const configs = (await configsFor(input.executorKind)).filter(config => base(config.baseUrl) === base(input.baseUrl) && config.computerId === input.computerId && config.bindingId === input.bindingId);
|
|
1449
|
+
if (configs.length !== 1)
|
|
1450
|
+
throw new Error("Original employee configuration unavailable");
|
|
1451
|
+
const config = configs[0];
|
|
1452
|
+
const restored = await request(config.baseUrl, "/api/computers/session/restore", {
|
|
1453
|
+
method: "POST", deviceToken: input.computerToken, signal: input.signal, body: { bindingId: config.bindingId, sessionId: config.sessionId, generation: config.generation ?? 1 }
|
|
1454
|
+
});
|
|
1455
|
+
if (restored.bindingId !== config.bindingId || restored.generation !== (config.generation ?? 1))
|
|
1456
|
+
throw new Error("Restoration response identity mismatch");
|
|
1457
|
+
await saveJson(configFile(config.executorKind, config.companyId, config), { ...config, sessionId: restored.deviceSession.sessionId, deviceToken: restored.deviceSession.token, signedOut: false });
|
|
1458
|
+
}
|
|
1459
|
+
//# sourceMappingURL=index.js.map
|