remote-codex 0.11.43 → 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 +2189 -1798
  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 +32 -4
  12. package/packages/claude/src/runtimeAdapter.ts +21 -22
  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-BT0SM9C-.js +0 -21
  28. package/apps/supervisor-web/dist/assets/index-BcCLYWAf.css +0 -1
@@ -0,0 +1,253 @@
1
+ import type { ChildProcess, SpawnOptions } from 'node:child_process';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ import crossSpawn from 'cross-spawn';
6
+
7
+ export interface SpawnProcessOptions {
8
+ command: string;
9
+ args?: readonly string[];
10
+ cwd?: string;
11
+ env?: NodeJS.ProcessEnv;
12
+ stdio?: SpawnOptions['stdio'];
13
+ detached?: boolean;
14
+ windowsHide?: boolean;
15
+ }
16
+
17
+ export interface RunProcessOptions extends Omit<SpawnProcessOptions, 'stdio' | 'detached'> {
18
+ timeoutMs?: number;
19
+ maxOutputBytes?: number;
20
+ input?: string | Buffer;
21
+ }
22
+
23
+ export interface ProcessResult {
24
+ code: number | null;
25
+ signal: NodeJS.Signals | null;
26
+ stdout: string;
27
+ stderr: string;
28
+ timedOut: boolean;
29
+ outputTruncated: boolean;
30
+ spawnError: Error | null;
31
+ }
32
+
33
+ export interface ParsedCommandLine {
34
+ command: string;
35
+ args: string[];
36
+ }
37
+
38
+ const DEFAULT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
39
+
40
+ /**
41
+ * Parse a persisted/display command into argv without evaluating a shell.
42
+ * Supports the quoting used by the built-in runtime install commands. Shell
43
+ * operators and environment expansion are intentionally treated as literals.
44
+ */
45
+ export function parseCommandLine(value: string): ParsedCommandLine {
46
+ const tokens: string[] = [];
47
+ let token = '';
48
+ let quote: 'single' | 'double' | null = null;
49
+ let tokenStarted = false;
50
+
51
+ for (let index = 0; index < value.length; index += 1) {
52
+ const character = value[index]!;
53
+ if (quote === 'single') {
54
+ if (character === "'") {
55
+ quote = null;
56
+ } else {
57
+ token += character;
58
+ }
59
+ tokenStarted = true;
60
+ continue;
61
+ }
62
+ if (quote === 'double') {
63
+ if (character === '"') {
64
+ quote = null;
65
+ } else if (
66
+ character === '\\' &&
67
+ (value[index + 1] === '"' || value[index + 1] === '\\')
68
+ ) {
69
+ token += value[index + 1];
70
+ index += 1;
71
+ } else {
72
+ token += character;
73
+ }
74
+ tokenStarted = true;
75
+ continue;
76
+ }
77
+
78
+ if (/\s/.test(character)) {
79
+ if (tokenStarted) {
80
+ tokens.push(token);
81
+ token = '';
82
+ tokenStarted = false;
83
+ }
84
+ continue;
85
+ }
86
+ if (character === "'") {
87
+ quote = 'single';
88
+ tokenStarted = true;
89
+ continue;
90
+ }
91
+ if (character === '"') {
92
+ quote = 'double';
93
+ tokenStarted = true;
94
+ continue;
95
+ }
96
+ token += character;
97
+ tokenStarted = true;
98
+ }
99
+
100
+ if (quote) {
101
+ throw new Error('Command contains an unterminated quoted argument.');
102
+ }
103
+ if (tokenStarted) {
104
+ tokens.push(token);
105
+ }
106
+ const [command, ...args] = tokens;
107
+ if (!command) {
108
+ throw new Error('Command is empty.');
109
+ }
110
+ return { command, args };
111
+ }
112
+
113
+ /**
114
+ * Spawn an executable without constructing a shell command string.
115
+ * cross-spawn is used so Windows PATHEXT and npm .cmd shims work consistently.
116
+ */
117
+ export function spawnProcess(options: SpawnProcessOptions): ChildProcess {
118
+ return crossSpawn(options.command, [...(options.args ?? [])], {
119
+ cwd: options.cwd,
120
+ env: options.env,
121
+ stdio: options.stdio ?? 'pipe',
122
+ detached: options.detached,
123
+ windowsHide: options.windowsHide ?? true,
124
+ shell: false,
125
+ });
126
+ }
127
+
128
+ export function runProcess(options: RunProcessOptions): Promise<ProcessResult> {
129
+ return new Promise((resolve) => {
130
+ const child = spawnProcess({
131
+ ...options,
132
+ stdio: ['pipe', 'pipe', 'pipe'],
133
+ });
134
+ const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
135
+ let stdout: Buffer = Buffer.alloc(0);
136
+ let stderr: Buffer = Buffer.alloc(0);
137
+ let timedOut = false;
138
+ let outputTruncated = false;
139
+ let spawnError: Error | null = null;
140
+ let settled = false;
141
+
142
+ const append = (current: Buffer, chunk: Buffer | string) => {
143
+ const nextChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
144
+ const remaining = Math.max(0, maxOutputBytes - current.byteLength);
145
+ if (nextChunk.byteLength > remaining) {
146
+ outputTruncated = true;
147
+ }
148
+ return remaining === 0
149
+ ? current
150
+ : Buffer.concat([current, nextChunk.subarray(0, remaining)]);
151
+ };
152
+
153
+ child.stdout?.on('data', (chunk: Buffer | string) => {
154
+ stdout = append(stdout, chunk);
155
+ });
156
+ child.stderr?.on('data', (chunk: Buffer | string) => {
157
+ stderr = append(stderr, chunk);
158
+ });
159
+ child.on('error', (error) => {
160
+ spawnError = error;
161
+ });
162
+
163
+ const finish = (code: number | null, signal: NodeJS.Signals | null) => {
164
+ if (settled) {
165
+ return;
166
+ }
167
+ settled = true;
168
+ if (timer) {
169
+ clearTimeout(timer);
170
+ }
171
+ resolve({
172
+ code: spawnError ? null : code,
173
+ signal,
174
+ stdout: stdout.toString('utf8'),
175
+ stderr: stderr.toString('utf8'),
176
+ timedOut,
177
+ outputTruncated,
178
+ spawnError,
179
+ });
180
+ };
181
+
182
+ child.on('close', finish);
183
+ const timer = options.timeoutMs && options.timeoutMs > 0
184
+ ? setTimeout(() => {
185
+ timedOut = true;
186
+ child.kill('SIGTERM');
187
+ }, options.timeoutMs)
188
+ : null;
189
+
190
+ if (options.input !== undefined) {
191
+ child.stdin?.end(options.input);
192
+ } else {
193
+ child.stdin?.end();
194
+ }
195
+ });
196
+ }
197
+
198
+ function pathEnvironmentValue(env: NodeJS.ProcessEnv) {
199
+ const entry = Object.entries(env).find(([key]) => key.toLowerCase() === 'path');
200
+ return entry?.[1] ?? '';
201
+ }
202
+
203
+ function windowsExtensions(command: string, env: NodeJS.ProcessEnv) {
204
+ if (path.extname(command)) {
205
+ return [''];
206
+ }
207
+ const pathExtEntry = Object.entries(env).find(([key]) => key.toLowerCase() === 'pathext');
208
+ const extensions = (pathExtEntry?.[1] ?? '.COM;.EXE;.BAT;.CMD')
209
+ .split(';')
210
+ .map((extension) => extension.trim())
211
+ .filter(Boolean);
212
+ return ['', ...extensions];
213
+ }
214
+
215
+ async function isFile(candidate: string) {
216
+ try {
217
+ return (await fs.stat(candidate)).isFile();
218
+ } catch {
219
+ return false;
220
+ }
221
+ }
222
+
223
+ /** Resolve a command for diagnostics only. Execution must still use spawnProcess. */
224
+ export async function resolveExecutable(
225
+ command: string,
226
+ options: {
227
+ cwd?: string;
228
+ env?: NodeJS.ProcessEnv;
229
+ platform?: NodeJS.Platform;
230
+ } = {},
231
+ ): Promise<string | null> {
232
+ const env = options.env ?? process.env;
233
+ const platform = options.platform ?? process.platform;
234
+ const cwd = options.cwd ?? process.cwd();
235
+ const hasPathSeparator = command.includes('/') || command.includes('\\');
236
+ const searchDirectories = hasPathSeparator
237
+ ? ['']
238
+ : pathEnvironmentValue(env).split(path.delimiter).filter(Boolean);
239
+ const extensions = platform === 'win32' ? windowsExtensions(command, env) : [''];
240
+
241
+ for (const directory of searchDirectories) {
242
+ const base = hasPathSeparator
243
+ ? (path.isAbsolute(command) ? command : path.resolve(cwd, command))
244
+ : path.join(directory, command);
245
+ for (const extension of extensions) {
246
+ const candidate = `${base}${extension}`;
247
+ if (await isFile(candidate)) {
248
+ return path.resolve(candidate);
249
+ }
250
+ }
251
+ }
252
+ return null;
253
+ }
@@ -64,6 +64,15 @@ export interface RuntimeConfigDto {
64
64
  port: number;
65
65
  workspaceRoot: string;
66
66
  environment: string;
67
+ platform?: string;
68
+ architecture?: string;
69
+ nodeVersion?: string;
70
+ capabilities?: {
71
+ terminal: boolean;
72
+ tmux: boolean;
73
+ managedSignals: boolean;
74
+ windowsTaskScheduler: boolean;
75
+ };
67
76
  }
68
77
 
69
78
  export interface AuthSessionDto {
@@ -1066,6 +1075,9 @@ export interface PluginManifestDto {
1066
1075
  export interface PluginDto extends PluginManifestDto {
1067
1076
  enabled: boolean;
1068
1077
  source?: 'builtin' | 'imported' | null;
1078
+ available?: boolean;
1079
+ unavailableReasonCode?: 'unsupported_platform' | null;
1080
+ unavailableReason?: string | null;
1069
1081
  }
1070
1082
 
1071
1083
  export interface UpdatePluginInput {
@@ -2,9 +2,10 @@ import fs from 'node:fs';
2
2
  import fsp from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
- import { spawn } from 'node:child_process';
5
+ import { spawn, spawnSync } from 'node:child_process';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import net from 'node:net';
8
+ import crypto from 'node:crypto';
8
9
 
9
10
  const scriptDir = path.dirname(fileURLToPath(import.meta.url));
10
11
  const repoRoot = path.resolve(scriptDir, '..');
@@ -67,6 +68,12 @@ async function startService() {
67
68
  await assertTcpPortAvailable(apiHost, apiPort, 'API');
68
69
  await assertTcpPortAvailable(serviceHost, servicePort, 'Web');
69
70
 
71
+ const apiInstanceId = crypto.randomUUID();
72
+ const apiControlToken = crypto.randomBytes(32).toString('base64url');
73
+ const apiControlEndpoint = process.platform === 'win32'
74
+ ? `\\\\.\\pipe\\remote-codex-service-${crypto.createHash('sha256').update(serviceDir).digest('hex').slice(0, 24)}`
75
+ : path.join(serviceDir, 'api-control.sock');
76
+
70
77
  const apiPid = spawnDetached(process.execPath, [apiEntry], apiLogPath, {
71
78
  NODE_ENV: 'production',
72
79
  HOST: apiHost,
@@ -76,6 +83,9 @@ async function startService() {
76
83
  REMOTE_CODEX_PACKAGE_ROOT: repoRoot,
77
84
  REMOTE_CODEX_DISABLE_BUILD_RESTART:
78
85
  process.env.REMOTE_CODEX_DISABLE_BUILD_RESTART ?? (supportsSourceRestart ? 'false' : 'true'),
86
+ REMOTE_CODEX_LIFECYCLE_CONTROL_ENDPOINT: apiControlEndpoint,
87
+ REMOTE_CODEX_LIFECYCLE_CONTROL_TOKEN: apiControlToken,
88
+ REMOTE_CODEX_LIFECYCLE_INSTANCE_ID: apiInstanceId,
79
89
  });
80
90
 
81
91
  try {
@@ -108,11 +118,14 @@ async function startService() {
108
118
  apiHost,
109
119
  apiPort,
110
120
  apiPid,
121
+ apiInstanceId,
122
+ apiControlToken,
123
+ apiControlEndpoint,
111
124
  webPid,
112
125
  apiLogPath,
113
126
  webLogPath,
114
127
  };
115
- await fsp.writeFile(stateFile, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
128
+ await writePrivateState(state);
116
129
 
117
130
  console.log(`Started supervisor service.`);
118
131
  console.log(`Web: http://${serviceHost}:${servicePort} (pid ${webPid})`);
@@ -305,8 +318,14 @@ async function probeHttp(url) {
305
318
  }
306
319
 
307
320
  async function stopState(state) {
308
- for (const pid of [state.webPid, state.apiPid]) {
309
- stopPid(pid);
321
+ stopPid(state.webPid);
322
+ let apiShutdownRequested = false;
323
+ if (state.apiControlEndpoint && state.apiControlToken && state.apiInstanceId) {
324
+ const response = await requestLifecycleControl(state, 'shutdown').catch(() => null);
325
+ apiShutdownRequested = response?.ok === true && response.instanceId === state.apiInstanceId;
326
+ }
327
+ if (!apiShutdownRequested) {
328
+ stopPid(state.apiPid);
310
329
  }
311
330
 
312
331
  const deadline = Date.now() + 5_000;
@@ -331,6 +350,44 @@ async function readState() {
331
350
  }
332
351
  }
333
352
 
353
+ async function writePrivateState(state) {
354
+ await fsp.mkdir(serviceDir, { recursive: true, mode: 0o700 });
355
+ await fsp.chmod(serviceDir, 0o700).catch(() => undefined);
356
+ const temporaryPath = `${stateFile}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
357
+ let handle = null;
358
+ try {
359
+ handle = await fsp.open(temporaryPath, 'wx', 0o600);
360
+ await handle.writeFile(`${JSON.stringify(state, null, 2)}\n`, 'utf8');
361
+ await handle.sync();
362
+ await handle.close();
363
+ handle = null;
364
+ await fsp.rename(temporaryPath, stateFile);
365
+ if (process.platform === 'win32') {
366
+ const username = process.env.USERDOMAIN && process.env.USERNAME
367
+ ? `${process.env.USERDOMAIN}\\${process.env.USERNAME}`
368
+ : process.env.USERNAME ?? os.userInfo().username;
369
+ spawnSync('icacls.exe', [
370
+ serviceDir,
371
+ '/inheritance:r',
372
+ '/grant:r',
373
+ `${username}:(OI)(CI)F`,
374
+ 'SYSTEM:(OI)(CI)F',
375
+ ], { windowsHide: true, stdio: 'ignore' });
376
+ spawnSync('icacls.exe', [
377
+ stateFile,
378
+ '/inheritance:r',
379
+ '/grant:r',
380
+ `${username}:F`,
381
+ 'SYSTEM:F',
382
+ ], { windowsHide: true, stdio: 'ignore' });
383
+ }
384
+ } catch (error) {
385
+ await handle?.close().catch(() => undefined);
386
+ await fsp.rm(temporaryPath, { force: true }).catch(() => undefined);
387
+ throw error;
388
+ }
389
+ }
390
+
334
391
  function serviceStateAlive(state) {
335
392
  return [state.apiPid, state.webPid].some((pid) => isProcessAlive(pid));
336
393
  }
@@ -340,6 +397,14 @@ function stopPid(pid) {
340
397
  return;
341
398
  }
342
399
 
400
+ if (process.platform === 'win32') {
401
+ spawn('taskkill.exe', ['/PID', String(pid), '/T'], {
402
+ windowsHide: true,
403
+ stdio: 'ignore',
404
+ }).unref();
405
+ return;
406
+ }
407
+
343
408
  try {
344
409
  process.kill(-pid, 'SIGTERM');
345
410
  return;
@@ -359,6 +424,14 @@ function forceStopPid(pid) {
359
424
  return;
360
425
  }
361
426
 
427
+ if (process.platform === 'win32') {
428
+ spawn('taskkill.exe', ['/PID', String(pid), '/T', '/F'], {
429
+ windowsHide: true,
430
+ stdio: 'ignore',
431
+ }).unref();
432
+ return;
433
+ }
434
+
362
435
  try {
363
436
  process.kill(-pid, 'SIGKILL');
364
437
  return;
@@ -412,3 +485,38 @@ function sleep(milliseconds) {
412
485
  setTimeout(resolve, milliseconds);
413
486
  });
414
487
  }
488
+
489
+ function requestLifecycleControl(state, action, timeoutMs = 2_000) {
490
+ return new Promise((resolve, reject) => {
491
+ const socket = net.createConnection(state.apiControlEndpoint);
492
+ const timer = setTimeout(() => {
493
+ socket.destroy();
494
+ reject(new Error('Lifecycle control request timed out.'));
495
+ }, timeoutMs);
496
+ let output = '';
497
+ socket.setEncoding('utf8');
498
+ socket.once('connect', () => {
499
+ socket.write(`${JSON.stringify({
500
+ action,
501
+ token: state.apiControlToken,
502
+ instanceId: state.apiInstanceId,
503
+ })}\n`);
504
+ });
505
+ socket.on('data', (chunk) => {
506
+ output += chunk;
507
+ const newline = output.indexOf('\n');
508
+ if (newline < 0) return;
509
+ clearTimeout(timer);
510
+ socket.end();
511
+ try {
512
+ resolve(JSON.parse(output.slice(0, newline)));
513
+ } catch (error) {
514
+ reject(error);
515
+ }
516
+ });
517
+ socket.once('error', (error) => {
518
+ clearTimeout(timer);
519
+ reject(error);
520
+ });
521
+ });
522
+ }