remote-codex 0.11.44 → 0.11.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/README.md +9 -0
  2. package/apps/relay-server/dist/index.js +17 -1
  3. package/apps/supervisor-api/dist/index.js +2178 -1795
  4. package/apps/supervisor-web/dist/assets/index-BO9S3vTX.css +1 -0
  5. package/apps/supervisor-web/dist/assets/index-GqVDOqbI.js +22 -0
  6. package/apps/supervisor-web/dist/assets/{thread-ui-Dmrigdek.js → thread-ui-BWC_ljvN.js} +11 -11
  7. package/apps/supervisor-web/dist/index.html +3 -3
  8. package/bin/remote-codex.mjs +426 -35
  9. package/docs/windows.md +81 -0
  10. package/package.json +14 -3
  11. package/packages/claude/src/runtimeAdapter.test.ts +2 -2
  12. package/packages/claude/src/runtimeAdapter.ts +10 -19
  13. package/packages/codex/src/appServerManager.test.ts +47 -0
  14. package/packages/codex/src/appServerManager.ts +6 -2
  15. package/packages/codex/src/runtimeAdapter.test.ts +9 -2
  16. package/packages/opencode/src/historyItems.ts +7 -6
  17. package/packages/opencode/src/runtimeAdapter.ts +7 -11
  18. package/packages/process-runtime/src/index.test.ts +132 -0
  19. package/packages/process-runtime/src/index.ts +253 -0
  20. package/packages/shared/src/index.ts +12 -0
  21. package/scripts/service-manager.mjs +112 -4
  22. package/scripts/verify-relay-supervisor-smoke.mjs +262 -0
  23. package/scripts/windows/install-relay-supervisor-task.ps1 +44 -0
  24. package/scripts/windows/relay-smoke.ps1 +16 -0
  25. package/scripts/windows/uninstall-relay-supervisor-task.ps1 +27 -0
  26. package/scripts/windows/validate-real-codex.mjs +680 -0
  27. package/apps/supervisor-web/dist/assets/index-BcCLYWAf.css +0 -1
  28. package/apps/supervisor-web/dist/assets/index-CO8cU7a0.js +0 -21
@@ -0,0 +1,680 @@
1
+ import crypto from 'node:crypto';
2
+ import fsp from 'node:fs/promises';
3
+ import net from 'node:net';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ import crossSpawn from 'cross-spawn';
9
+
10
+ if (process.platform !== 'win32') {
11
+ throw new Error('This validation script must run on native Windows.');
12
+ }
13
+
14
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
15
+ const packageRoot = path.resolve(scriptDir, '..', '..');
16
+ const cliEntry = path.join(packageRoot, 'bin', 'remote-codex.mjs');
17
+ const relayEntry = path.join(
18
+ packageRoot,
19
+ 'apps',
20
+ 'relay-server',
21
+ 'dist',
22
+ 'index.js',
23
+ );
24
+ const codexExe = requiredEnv('REMOTE_CODEX_REAL_CODEX_EXE');
25
+ const codexCmd = requiredEnv('REMOTE_CODEX_REAL_CODEX_CMD');
26
+ const codexHome = path.resolve(
27
+ process.env.CODEX_HOME ?? path.join(os.homedir(), '.codex'),
28
+ );
29
+ const temporaryRoot = await fsp.mkdtemp(
30
+ path.join(os.tmpdir(), 'Remote Codex Real Windows '),
31
+ );
32
+ const workspaceRoot = path.join(temporaryRoot, 'workspace root validation');
33
+ const relayPort = await reservePort();
34
+ const relayBaseUrl = `http://127.0.0.1:${relayPort}`;
35
+ const processes = [];
36
+ let relay;
37
+ let foreground;
38
+ let backgroundStarted = false;
39
+ let clientSocket;
40
+
41
+ try {
42
+ await fsp.mkdir(workspaceRoot, { recursive: true });
43
+ await verifyCodexCommand(codexExe, 'codex.exe');
44
+ await verifyCodexCommand(codexCmd, 'codex.cmd');
45
+
46
+ const relayEnvironment = {
47
+ REMOTE_CODEX_ADMIN_USERNAME: 'windows-real-admin',
48
+ REMOTE_CODEX_ADMIN_PASSWORD: 'windows-real-admin-password',
49
+ REMOTE_CODEX_RELAY_SESSION_SECRET:
50
+ 'windows-real-session-secret-32-characters',
51
+ REMOTE_CODEX_RELAY_REGISTRATION_ENABLED: 'true',
52
+ REMOTE_CODEX_RELAY_DATA_DIR: path.join(temporaryRoot, 'relay data'),
53
+ REMOTE_CODEX_RELAY_HOST: '127.0.0.1',
54
+ REMOTE_CODEX_RELAY_PORT: String(relayPort),
55
+ };
56
+ relay = startNode(relayEntry, relayEnvironment);
57
+ processes.push(relay);
58
+ await waitForHttp(`${relayBaseUrl}/healthz`, relay, 30_000);
59
+
60
+ const username = `windows-real-${crypto.randomBytes(4).toString('hex')}`;
61
+ const registration = await jsonRequest(
62
+ `${relayBaseUrl}/relay/auth/register`,
63
+ {
64
+ method: 'POST',
65
+ body: {
66
+ email: `${username}@example.test`,
67
+ username,
68
+ password: 'windows-real-user-password',
69
+ },
70
+ },
71
+ );
72
+ const userToken = requiredString(registration.token, 'registration token');
73
+ const deviceRegistration = await jsonRequest(
74
+ `${relayBaseUrl}/relay/devices`,
75
+ {
76
+ method: 'POST',
77
+ token: userToken,
78
+ body: { name: 'Windows real Codex validation' },
79
+ },
80
+ );
81
+ const deviceId = requiredString(deviceRegistration.device?.id, 'device id');
82
+ const deviceToken = requiredString(deviceRegistration.token, 'device token');
83
+ const deviceApi = `${relayBaseUrl}/relay/devices/${deviceId}`;
84
+
85
+ const foregroundPort = await reservePort();
86
+ const foregroundInstanceId = crypto.randomUUID();
87
+ const foregroundControlToken = crypto.randomBytes(32).toString('base64url');
88
+ const foregroundControlEndpoint = `\\\\.\\pipe\\remote-codex-real-${foregroundInstanceId}`;
89
+ const foregroundEnv = supervisorEnvironment({
90
+ name: 'foreground-exe',
91
+ port: foregroundPort,
92
+ command: codexExe,
93
+ deviceToken,
94
+ extra: {
95
+ REMOTE_CODEX_LIFECYCLE_CONTROL_ENDPOINT: foregroundControlEndpoint,
96
+ REMOTE_CODEX_LIFECYCLE_CONTROL_TOKEN: foregroundControlToken,
97
+ REMOTE_CODEX_LIFECYCLE_INSTANCE_ID: foregroundInstanceId,
98
+ },
99
+ });
100
+ foreground = startNode(cliEntry, foregroundEnv, ['relay-supervisor', 'run']);
101
+ processes.push(foreground);
102
+ await waitForHttp(`${deviceApi}/healthz`, foreground, 60_000, userToken);
103
+
104
+ const projectPath = path.join(workspaceRoot, 'project with spaces');
105
+ const workspace = await jsonRequest(`${deviceApi}/api/workspaces`, {
106
+ method: 'POST',
107
+ token: userToken,
108
+ body: { absPath: projectPath },
109
+ });
110
+ const workspaceId = requiredString(workspace.id, 'workspace id');
111
+ const model = await selectDefaultModel(deviceApi, userToken);
112
+ const thread = await jsonRequest(`${deviceApi}/api/threads/start`, {
113
+ method: 'POST',
114
+ token: userToken,
115
+ body: {
116
+ workspaceId,
117
+ provider: 'codex',
118
+ model,
119
+ approvalMode: 'yolo',
120
+ title: 'Windows codex.exe validation',
121
+ },
122
+ });
123
+ const threadId = requiredString(thread.id, 'thread id');
124
+ const socketEvents = [];
125
+ clientSocket = new WebSocket(
126
+ `${relayBaseUrl.replace(/^http/, 'ws')}/relay/devices/${deviceId}/ws` +
127
+ `?threadId=${encodeURIComponent(threadId)}&relaySession=${encodeURIComponent(userToken)}`,
128
+ );
129
+ clientSocket.addEventListener('message', (event) => {
130
+ try {
131
+ socketEvents.push(JSON.parse(String(event.data)));
132
+ } catch {
133
+ // Ignore non-JSON diagnostics.
134
+ }
135
+ });
136
+ await waitForSocketOpen(clientSocket, 10_000);
137
+
138
+ await jsonRequest(`${deviceApi}/api/threads/${threadId}/prompt`, {
139
+ method: 'POST',
140
+ token: userToken,
141
+ body: {
142
+ prompt: 'Reply with exactly WINDOWS_REAL_CODEX_EXE_OK and nothing else.',
143
+ },
144
+ });
145
+ const firstDetail = await waitForJson(
146
+ `${deviceApi}/api/threads/${threadId}`,
147
+ userToken,
148
+ (value) =>
149
+ value.turns?.at?.(-1)?.status === 'completed' &&
150
+ JSON.stringify(value).includes('WINDOWS_REAL_CODEX_EXE_OK'),
151
+ 120_000,
152
+ );
153
+ await waitForCondition(
154
+ () =>
155
+ socketEvents.some(
156
+ (event) =>
157
+ event.type === 'thread.updated' &&
158
+ event.threadId === threadId &&
159
+ event.payload?.status === 'running',
160
+ ),
161
+ 10_000,
162
+ 'WebSocket running event',
163
+ );
164
+ if (firstDetail.turns?.length !== 1) {
165
+ throw new Error(
166
+ `Unexpected first transcript: ${JSON.stringify(firstDetail)}`,
167
+ );
168
+ }
169
+ const reloaded = await jsonRequest(`${deviceApi}/api/threads/${threadId}`, {
170
+ token: userToken,
171
+ });
172
+ if (!JSON.stringify(reloaded).includes('WINDOWS_REAL_CODEX_EXE_OK')) {
173
+ throw new Error('Transcript reload lost the first real Codex response.');
174
+ }
175
+
176
+ await stopProcess(relay);
177
+ relay = startNode(relayEntry, relayEnvironment);
178
+ processes.push(relay);
179
+ await waitForHttp(`${relayBaseUrl}/healthz`, relay, 30_000);
180
+ await waitForHttp(`${deviceApi}/healthz`, foreground, 60_000, userToken);
181
+
182
+ await jsonRequest(`${deviceApi}/api/threads/${threadId}/prompt`, {
183
+ method: 'POST',
184
+ token: userToken,
185
+ body: {
186
+ prompt:
187
+ 'Reply with exactly WINDOWS_REAL_CODEX_EXE_FOLLOWUP_OK and nothing else.',
188
+ },
189
+ });
190
+ const followUpDetail = await waitForJson(
191
+ `${deviceApi}/api/threads/${threadId}`,
192
+ userToken,
193
+ (value) =>
194
+ value.turns?.length === 2 &&
195
+ value.turns.at(-1)?.status === 'completed' &&
196
+ JSON.stringify(value).includes('WINDOWS_REAL_CODEX_EXE_FOLLOWUP_OK'),
197
+ 120_000,
198
+ );
199
+ if (!JSON.stringify(followUpDetail).includes('WINDOWS_REAL_CODEX_EXE_OK')) {
200
+ throw new Error('Relay reconnect lost the first transcript turn.');
201
+ }
202
+
203
+ await requestControl(
204
+ {
205
+ controlEndpoint: foregroundControlEndpoint,
206
+ controlToken: foregroundControlToken,
207
+ instanceId: foregroundInstanceId,
208
+ },
209
+ 'shutdown',
210
+ );
211
+ await waitForExit(foreground, 15_000);
212
+ await waitForPortClosed(foregroundPort, 10_000);
213
+ clientSocket.close();
214
+ clientSocket = undefined;
215
+
216
+ const backgroundPort = await reservePort();
217
+ const backgroundEnv = supervisorEnvironment({
218
+ name: 'background-cmd',
219
+ port: backgroundPort,
220
+ command: codexCmd,
221
+ deviceToken,
222
+ });
223
+ const startResult = await runNode(
224
+ cliEntry,
225
+ backgroundEnv,
226
+ ['relay-supervisor', 'start'],
227
+ 30_000,
228
+ );
229
+ if (
230
+ startResult.code !== 0 ||
231
+ !startResult.output.includes('Started remote-codex relay-supervisor')
232
+ ) {
233
+ throw new Error(`Background start failed: ${startResult.output}`);
234
+ }
235
+ backgroundStarted = true;
236
+ const statusResult = await runNode(
237
+ cliEntry,
238
+ backgroundEnv,
239
+ ['relay-supervisor', 'status'],
240
+ 10_000,
241
+ );
242
+ if (
243
+ statusResult.code !== 0 ||
244
+ !statusResult.output.includes('State: running')
245
+ ) {
246
+ throw new Error(`Background status failed: ${statusResult.output}`);
247
+ }
248
+ await waitForHttp(`${deviceApi}/healthz`, null, 60_000, userToken);
249
+
250
+ const backgroundWorkspace = await jsonRequest(`${deviceApi}/api/workspaces`, {
251
+ method: 'POST',
252
+ token: userToken,
253
+ body: { absPath: path.join(workspaceRoot, 'cmd project') },
254
+ });
255
+ const backgroundModel = await selectDefaultModel(deviceApi, userToken);
256
+ const backgroundThread = await jsonRequest(`${deviceApi}/api/threads/start`, {
257
+ method: 'POST',
258
+ token: userToken,
259
+ body: {
260
+ workspaceId: requiredString(
261
+ backgroundWorkspace.id,
262
+ 'background workspace id',
263
+ ),
264
+ provider: 'codex',
265
+ model: backgroundModel,
266
+ approvalMode: 'yolo',
267
+ title: 'Windows codex.cmd validation',
268
+ },
269
+ });
270
+ const backgroundThreadId = requiredString(
271
+ backgroundThread.id,
272
+ 'background thread id',
273
+ );
274
+ await jsonRequest(`${deviceApi}/api/threads/${backgroundThreadId}/prompt`, {
275
+ method: 'POST',
276
+ token: userToken,
277
+ body: {
278
+ prompt: 'Reply with exactly WINDOWS_REAL_CODEX_CMD_OK and nothing else.',
279
+ },
280
+ });
281
+ await waitForJson(
282
+ `${deviceApi}/api/threads/${backgroundThreadId}`,
283
+ userToken,
284
+ (value) =>
285
+ value.turns?.at?.(-1)?.status === 'completed' &&
286
+ JSON.stringify(value).includes('WINDOWS_REAL_CODEX_CMD_OK'),
287
+ 120_000,
288
+ );
289
+
290
+ const stopResult = await runNode(
291
+ cliEntry,
292
+ backgroundEnv,
293
+ ['relay-supervisor', 'stop'],
294
+ 30_000,
295
+ );
296
+ backgroundStarted = false;
297
+ if (
298
+ stopResult.code !== 0 ||
299
+ !stopResult.output.includes('Stopped remote-codex relay-supervisor')
300
+ ) {
301
+ throw new Error(`Background stop failed: ${stopResult.output}`);
302
+ }
303
+ await waitForPortClosed(backgroundPort, 10_000);
304
+
305
+ console.log(
306
+ JSON.stringify(
307
+ {
308
+ passed: true,
309
+ platform: process.platform,
310
+ arch: process.arch,
311
+ deviceId,
312
+ foreground: {
313
+ command: codexExe,
314
+ threadId,
315
+ turns: 2,
316
+ streamedRunningEvent: true,
317
+ relayReconnect: true,
318
+ gracefulExit: true,
319
+ },
320
+ background: {
321
+ command: codexCmd,
322
+ threadId: backgroundThreadId,
323
+ startStatusStop: true,
324
+ realPrompt: true,
325
+ },
326
+ },
327
+ null,
328
+ 2,
329
+ ),
330
+ );
331
+ } catch (error) {
332
+ const diagnostics = processes
333
+ .map((child) => `${child.label} output:\n${child.output()}`)
334
+ .join('\n');
335
+ throw new Error(
336
+ `${error instanceof Error ? error.message : String(error)}\n${diagnostics}`,
337
+ );
338
+ } finally {
339
+ clientSocket?.close();
340
+ if (backgroundStarted) {
341
+ const backgroundEnv = supervisorEnvironment({
342
+ name: 'background-cmd',
343
+ port: 1,
344
+ command: codexCmd,
345
+ deviceToken: 'cleanup-placeholder',
346
+ });
347
+ await runNode(
348
+ cliEntry,
349
+ backgroundEnv,
350
+ ['relay-supervisor', 'stop'],
351
+ 15_000,
352
+ ).catch(() => {});
353
+ }
354
+ await Promise.allSettled(processes.map((child) => stopProcess(child)));
355
+ await fsp.rm(temporaryRoot, { recursive: true, force: true });
356
+ }
357
+
358
+ function supervisorEnvironment({
359
+ name,
360
+ port,
361
+ command,
362
+ deviceToken,
363
+ extra = {},
364
+ }) {
365
+ const stateRoot = path.join(temporaryRoot, name);
366
+ return {
367
+ NODE_ENV: 'production',
368
+ LOG_LEVEL: 'warn',
369
+ REMOTE_CODEX_RELAY_SERVER_URL: `ws://127.0.0.1:${relayPort}`,
370
+ REMOTE_CODEX_RELAY_AGENT_TOKEN: deviceToken,
371
+ REMOTE_CODEX_ADMIN_USERNAME: 'supervisor-admin',
372
+ REMOTE_CODEX_ADMIN_PASSWORD: 'supervisor-admin-password',
373
+ REMOTE_CODEX_SESSION_SECRET: 'supervisor-session-secret-32-characters',
374
+ REMOTE_CODEX_RELAY_SUPERVISOR_HOST: '127.0.0.1',
375
+ REMOTE_CODEX_RELAY_SUPERVISOR_PORT: String(port),
376
+ REMOTE_CODEX_ENABLED_AGENT_PROVIDERS: 'codex',
377
+ DATABASE_URL: path.join(stateRoot, 'supervisor.sqlite'),
378
+ WORKSPACE_ROOT: workspaceRoot,
379
+ CODEX_HOME: codexHome,
380
+ CODEX_COMMAND: command,
381
+ REMOTE_CODEX_RELAY_SUPERVISOR_CONFIG: path.join(
382
+ stateRoot,
383
+ 'relay-supervisor.json',
384
+ ),
385
+ REMOTE_CODEX_RELAY_SUPERVISOR_STATE: path.join(
386
+ stateRoot,
387
+ 'relay-supervisor-state.json',
388
+ ),
389
+ REMOTE_CODEX_RELAY_SUPERVISOR_LOG: path.join(
390
+ stateRoot,
391
+ 'relay-supervisor.log',
392
+ ),
393
+ ...extra,
394
+ };
395
+ }
396
+
397
+ async function verifyCodexCommand(command, label) {
398
+ const result = await runCommand(command, ['--version'], 15_000);
399
+ if (result.code !== 0 || !/codex-cli/i.test(result.output)) {
400
+ throw new Error(`${label} --version failed: ${result.output}`);
401
+ }
402
+ }
403
+
404
+ async function selectDefaultModel(deviceApi, token) {
405
+ const models = await waitForJson(
406
+ `${deviceApi}/api/agent-runtimes/codex/models`,
407
+ token,
408
+ (value) => Array.isArray(value) && value.length > 0,
409
+ 60_000,
410
+ );
411
+ const selected =
412
+ models.find((model) => model.isDefault && !model.hidden) ??
413
+ models.find((model) => !model.hidden) ??
414
+ models[0];
415
+ return requiredString(selected?.model, 'Codex model');
416
+ }
417
+
418
+ function startNode(entry, additionalEnv, args = []) {
419
+ const child = crossSpawn(process.execPath, [entry, ...args], {
420
+ cwd: packageRoot,
421
+ windowsHide: true,
422
+ env: { ...process.env, ...additionalEnv },
423
+ stdio: ['ignore', 'pipe', 'pipe'],
424
+ });
425
+ let output = '';
426
+ child.stdout?.on('data', (chunk) => {
427
+ output += String(chunk);
428
+ });
429
+ child.stderr?.on('data', (chunk) => {
430
+ output += String(chunk);
431
+ });
432
+ return Object.assign(child, {
433
+ label: `${path.basename(entry)} ${args.join(' ')}`.trim(),
434
+ output: () => output,
435
+ });
436
+ }
437
+
438
+ function runNode(entry, additionalEnv, args, timeoutMs) {
439
+ return runCommand(
440
+ process.execPath,
441
+ [entry, ...args],
442
+ timeoutMs,
443
+ additionalEnv,
444
+ );
445
+ }
446
+
447
+ function runCommand(command, args, timeoutMs, additionalEnv = {}) {
448
+ return new Promise((resolve, reject) => {
449
+ const child = crossSpawn(command, args, {
450
+ cwd: packageRoot,
451
+ windowsHide: true,
452
+ env: { ...process.env, ...additionalEnv },
453
+ stdio: ['ignore', 'pipe', 'pipe'],
454
+ });
455
+ let output = '';
456
+ child.stdout?.on('data', (chunk) => {
457
+ output += String(chunk);
458
+ });
459
+ child.stderr?.on('data', (chunk) => {
460
+ output += String(chunk);
461
+ });
462
+ const timer = setTimeout(() => {
463
+ child.kill();
464
+ reject(new Error(`${command} timed out after ${timeoutMs} ms.`));
465
+ }, timeoutMs);
466
+ child.once('error', (error) => {
467
+ clearTimeout(timer);
468
+ reject(error);
469
+ });
470
+ child.once('exit', (code) => {
471
+ clearTimeout(timer);
472
+ resolve({ code, output });
473
+ });
474
+ });
475
+ }
476
+
477
+ async function jsonRequest(url, options = {}) {
478
+ const response = await fetch(url, {
479
+ method: options.method ?? 'GET',
480
+ headers: {
481
+ ...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
482
+ ...(options.body ? { 'content-type': 'application/json' } : {}),
483
+ },
484
+ ...(options.body ? { body: JSON.stringify(options.body) } : {}),
485
+ signal: AbortSignal.timeout(10_000),
486
+ });
487
+ const text = await response.text();
488
+ let value;
489
+ try {
490
+ value = text ? JSON.parse(text) : null;
491
+ } catch {
492
+ value = text;
493
+ }
494
+ if (!response.ok) {
495
+ throw new Error(
496
+ `${options.method ?? 'GET'} ${url} returned ${response.status}: ${text}`,
497
+ );
498
+ }
499
+ return value;
500
+ }
501
+
502
+ async function waitForHttp(url, child, timeoutMs, token) {
503
+ const deadline = Date.now() + timeoutMs;
504
+ while (Date.now() < deadline) {
505
+ if (child?.exitCode !== null && child?.exitCode !== undefined) {
506
+ throw new Error(`${child.label} exited with ${child.exitCode}.`);
507
+ }
508
+ try {
509
+ await jsonRequest(url, { token });
510
+ return;
511
+ } catch {
512
+ await delay(250);
513
+ }
514
+ }
515
+ throw new Error(`Timed out waiting for ${url}.`);
516
+ }
517
+
518
+ async function waitForJson(url, token, predicate, timeoutMs) {
519
+ const deadline = Date.now() + timeoutMs;
520
+ let latest = null;
521
+ while (Date.now() < deadline) {
522
+ try {
523
+ latest = await jsonRequest(url, { token });
524
+ if (predicate(latest)) {
525
+ return latest;
526
+ }
527
+ } catch {
528
+ // Relay reconnects and runtime startup are expected to be transient.
529
+ }
530
+ await delay(250);
531
+ }
532
+ throw new Error(`Timed out waiting for ${url}: ${JSON.stringify(latest)}`);
533
+ }
534
+
535
+ async function waitForSocketOpen(socket, timeoutMs) {
536
+ if (socket.readyState === WebSocket.OPEN) return;
537
+ await Promise.race([
538
+ new Promise((resolve, reject) => {
539
+ socket.addEventListener('open', resolve, { once: true });
540
+ socket.addEventListener(
541
+ 'error',
542
+ () => reject(new Error('WebSocket failed to open.')),
543
+ {
544
+ once: true,
545
+ },
546
+ );
547
+ }),
548
+ delay(timeoutMs).then(() => {
549
+ throw new Error('WebSocket open timed out.');
550
+ }),
551
+ ]);
552
+ }
553
+
554
+ async function waitForCondition(predicate, timeoutMs, label) {
555
+ const deadline = Date.now() + timeoutMs;
556
+ while (Date.now() < deadline) {
557
+ if (predicate()) return;
558
+ await delay(50);
559
+ }
560
+ throw new Error(`Timed out waiting for ${label}.`);
561
+ }
562
+
563
+ function requestControl(state, action) {
564
+ return new Promise((resolve, reject) => {
565
+ const socket = net.createConnection(state.controlEndpoint);
566
+ const timer = setTimeout(() => {
567
+ socket.destroy();
568
+ reject(new Error('Lifecycle control timed out.'));
569
+ }, 5_000);
570
+ let output = '';
571
+ socket.setEncoding('utf8');
572
+ socket.once('connect', () =>
573
+ socket.write(
574
+ `${JSON.stringify({
575
+ action,
576
+ token: state.controlToken,
577
+ instanceId: state.instanceId,
578
+ })}\n`,
579
+ ),
580
+ );
581
+ socket.on('data', (chunk) => {
582
+ output += chunk;
583
+ const newline = output.indexOf('\n');
584
+ if (newline < 0) return;
585
+ clearTimeout(timer);
586
+ socket.end();
587
+ const result = JSON.parse(output.slice(0, newline));
588
+ if (result.ok !== true || result.instanceId !== state.instanceId) {
589
+ reject(
590
+ new Error(`Lifecycle request failed: ${JSON.stringify(result)}`),
591
+ );
592
+ return;
593
+ }
594
+ resolve(result);
595
+ });
596
+ socket.once('error', (error) => {
597
+ clearTimeout(timer);
598
+ reject(error);
599
+ });
600
+ });
601
+ }
602
+
603
+ async function waitForExit(child, timeoutMs) {
604
+ if (child.exitCode !== null) return;
605
+ await Promise.race([
606
+ new Promise((resolve) => child.once('exit', resolve)),
607
+ delay(timeoutMs).then(() => {
608
+ throw new Error(`${child.label} did not exit.`);
609
+ }),
610
+ ]);
611
+ }
612
+
613
+ async function waitForPortClosed(port, timeoutMs) {
614
+ const deadline = Date.now() + timeoutMs;
615
+ while (Date.now() < deadline) {
616
+ if (!(await canConnect(port))) return;
617
+ await delay(100);
618
+ }
619
+ throw new Error(`Port ${port} remained open after shutdown.`);
620
+ }
621
+
622
+ function canConnect(port) {
623
+ return new Promise((resolve) => {
624
+ const socket = net.createConnection({ host: '127.0.0.1', port });
625
+ socket.once('connect', () => {
626
+ socket.destroy();
627
+ resolve(true);
628
+ });
629
+ socket.once('error', () => resolve(false));
630
+ socket.setTimeout(500, () => {
631
+ socket.destroy();
632
+ resolve(false);
633
+ });
634
+ });
635
+ }
636
+
637
+ async function stopProcess(child) {
638
+ if (!child || child.exitCode !== null) return;
639
+ const result = crossSpawn.sync(
640
+ 'taskkill.exe',
641
+ ['/PID', String(child.pid), '/T', '/F'],
642
+ {
643
+ windowsHide: true,
644
+ stdio: 'ignore',
645
+ },
646
+ );
647
+ if (result.status !== 0 && child.exitCode === null) {
648
+ child.kill();
649
+ }
650
+ await waitForExit(child, 10_000).catch(() => {});
651
+ }
652
+
653
+ function reservePort() {
654
+ return new Promise((resolve, reject) => {
655
+ const server = net.createServer();
656
+ server.once('error', reject);
657
+ server.listen(0, '127.0.0.1', () => {
658
+ const address = server.address();
659
+ const port = typeof address === 'object' && address ? address.port : null;
660
+ server.close(() =>
661
+ port ? resolve(port) : reject(new Error('Unable to reserve a port.')),
662
+ );
663
+ });
664
+ });
665
+ }
666
+
667
+ function requiredString(value, label) {
668
+ if (typeof value !== 'string' || !value) {
669
+ throw new Error(`Missing ${label}: ${JSON.stringify(value)}`);
670
+ }
671
+ return value;
672
+ }
673
+
674
+ function requiredEnv(name) {
675
+ return requiredString(process.env[name], name);
676
+ }
677
+
678
+ function delay(ms) {
679
+ return new Promise((resolve) => setTimeout(resolve, ms));
680
+ }