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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-codex",
3
- "version": "0.11.43",
3
+ "version": "0.11.45",
4
4
  "description": "Local web supervisor for Codex workspaces and threads.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -11,6 +11,8 @@
11
11
  "bin/",
12
12
  "scripts/service-manager.mjs",
13
13
  "scripts/run-web-service.mjs",
14
+ "scripts/verify-relay-supervisor-smoke.mjs",
15
+ "scripts/windows/",
14
16
  "apps/supervisor-api/dist/",
15
17
  "apps/relay-server/dist/",
16
18
  "apps/supervisor-web/dist/",
@@ -19,12 +21,14 @@
19
21
  "packages/claude/src/",
20
22
  "packages/codex/src/",
21
23
  "packages/opencode/src/",
24
+ "packages/process-runtime/src/",
22
25
  "packages/db/src/",
23
26
  "packages/db/migrations/*.sql",
24
27
  "packages/plugin-terminal/package.json",
25
28
  "packages/plugin-terminal/plugin.json",
26
29
  "packages/plugin-terminal/src/",
27
30
  "packages/shared/src/",
31
+ "docs/windows.md",
28
32
  "README.md"
29
33
  ],
30
34
  "packageManager": "pnpm@10.11.1",
@@ -51,8 +55,10 @@
51
55
  "build:package": "pnpm --filter @remote-codex/supervisor-api build && pnpm --filter @remote-codex/relay-server build && pnpm --filter @remote-codex/supervisor-web build",
52
56
  "lint": "pnpm -r --if-present lint",
53
57
  "typecheck": "pnpm -r --if-present typecheck",
54
- "test": "NODE_ENV=test pnpm -r --if-present test",
58
+ "test": "cross-env NODE_ENV=test pnpm -r --if-present test",
55
59
  "test:e2e": "playwright test",
60
+ "verify:package": "node scripts/verify-package-install.mjs",
61
+ "verify:relay": "node scripts/verify-relay-supervisor-smoke.mjs",
56
62
  "db:migrate": "pnpm --filter @remote-codex/db db:migrate",
57
63
  "release:mobile": "node scripts/publish-mobile-release-assets.mjs",
58
64
  "impeccable": "impeccable",
@@ -65,14 +71,17 @@
65
71
  "@fastify/websocket": "^11.0.2",
66
72
  "@fontsource/noto-sans": "^5.2.10",
67
73
  "@fontsource/noto-sans-sc": "^5.2.9",
68
- "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1",
69
74
  "@modelcontextprotocol/sdk": "^1.29.0",
70
75
  "better-sqlite3": "^12.10.0",
76
+ "cross-spawn": "^7.0.6",
71
77
  "fastify": "^5.2.1",
72
78
  "marked": "^18.0.3",
73
79
  "puppeteer-core": "^24.43.1",
74
80
  "zod": "^4.4.3"
75
81
  },
82
+ "optionalDependencies": {
83
+ "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1"
84
+ },
76
85
  "devDependencies": {
77
86
  "@eslint/js": "^9.24.0",
78
87
  "@playwright/test": "^1.59.1",
@@ -80,11 +89,13 @@
80
89
  "@testing-library/jest-dom": "^6.6.3",
81
90
  "@testing-library/react": "^16.3.0",
82
91
  "@types/better-sqlite3": "^7.6.13",
92
+ "@types/cross-spawn": "^6.0.6",
83
93
  "@types/node": "^22.14.0",
84
94
  "@types/react": "^19.1.2",
85
95
  "@types/react-dom": "^19.1.2",
86
96
  "@vitejs/plugin-react": "^4.3.4",
87
97
  "concurrently": "^9.1.2",
98
+ "cross-env": "^10.1.0",
88
99
  "eslint": "^9.24.0",
89
100
  "eslint-plugin-react-hooks": "^5.2.0",
90
101
  "eslint-plugin-react-refresh": "^0.4.19",
@@ -94,6 +94,22 @@ class FakeQuery implements Query {
94
94
  supportsEffort: true,
95
95
  supportedEffortLevels: ['low', 'medium', 'high'],
96
96
  },
97
+ {
98
+ value: 'claude-fable-5[1m]',
99
+ resolvedModel: 'claude-fable-5',
100
+ displayName: 'Fable',
101
+ description: 'Fable 5 test model',
102
+ supportsEffort: true,
103
+ supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
104
+ },
105
+ {
106
+ value: 'opus[1m]',
107
+ resolvedModel: 'claude-opus-5[1m]',
108
+ displayName: 'Opus (1M context)',
109
+ description: 'Opus 5 test model',
110
+ supportsEffort: true,
111
+ supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'],
112
+ },
97
113
  ];
98
114
  }
99
115
  async supportedAgents(): Promise<any[]> {
@@ -832,16 +848,28 @@ describe('ClaudeRuntimeAdapter', () => {
832
848
  }),
833
849
  expect.objectContaining({
834
850
  model: 'fable',
835
- displayName: 'Claude Fable',
851
+ displayName: 'Fable · 5',
836
852
  defaultReasoningEffort: 'medium',
837
853
  supportedReasoningEfforts: expect.arrayContaining([
838
854
  expect.objectContaining({ reasoningEffort: 'max' }),
839
855
  ]),
840
856
  }),
857
+ expect.objectContaining({
858
+ model: 'opus',
859
+ displayName: 'Opus · 5',
860
+ }),
861
+ expect.objectContaining({
862
+ model: 'opus[1m]',
863
+ displayName: 'Opus · 5 (1M context)',
864
+ }),
865
+ ]),
866
+ );
867
+ await expect(adapter.listModels()).resolves.not.toEqual(
868
+ expect.arrayContaining([
869
+ expect.objectContaining({ model: 'claude-fable-5[1m]' }),
841
870
  ]),
842
871
  );
843
872
  expect(queries[0]?.closed).toBe(true);
844
- await adapter.listModels();
845
873
  expect(queries).toHaveLength(1);
846
874
 
847
875
  await adapter.startSession({
@@ -1238,7 +1266,7 @@ describe('ClaudeRuntimeAdapter', () => {
1238
1266
  message: {
1239
1267
  role: 'user',
1240
1268
  content:
1241
- '<task-notification>\\n<task-id>task-1</task-id>\\n<tool-use-id>toolu_agent_xml</tool-use-id>\\n<status>completed</status>\\n<summary>Agent \"Audit docs\" finished</summary>\\n<result>Found one stale runbook.</result>\\n</task-notification>',
1269
+ '<task-notification>\\n<task-id>task-1</task-id>\\n<tool-use-id>toolu_agent_xml</tool-use-id>\\n<status>completed</status>\\n<summary>Agent "Audit docs" finished</summary>\\n<result>Found one stale runbook.</result>\\n</task-notification>',
1242
1270
  },
1243
1271
  parent_tool_use_id: null,
1244
1272
  uuid: '00000000-0000-4000-8000-000000000042' as any,
@@ -2352,7 +2380,7 @@ describe('ClaudeRuntimeAdapter', () => {
2352
2380
  message: {
2353
2381
  role: 'user',
2354
2382
  content:
2355
- '<task-notification>\\n<task-id>task-a</task-id>\\n<tool-use-id>toolu_task_a</tool-use-id>\\n<status>completed</status>\\n<summary>Agent \"Audit infra docs\" finished</summary>\\n<result>Infra docs need one migration checklist.</result>\\n</task-notification>',
2383
+ '<task-notification>\\n<task-id>task-a</task-id>\\n<tool-use-id>toolu_task_a</tool-use-id>\\n<status>completed</status>\\n<summary>Agent "Audit infra docs" finished</summary>\\n<result>Infra docs need one migration checklist.</result>\\n</task-notification>',
2356
2384
  },
2357
2385
  parent_tool_use_id: null,
2358
2386
  },
@@ -1,11 +1,9 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { EventEmitter } from 'node:events';
3
- import { execFile } from 'node:child_process';
4
3
  import fs from 'node:fs/promises';
5
4
  import { createRequire } from 'node:module';
6
5
  import path from 'node:path';
7
6
  import { pathToFileURL } from 'node:url';
8
- import { promisify } from 'node:util';
9
7
 
10
8
  import type {
11
9
  AgentActionQuestion,
@@ -58,8 +56,9 @@ import {
58
56
  userMessageHistoryItem,
59
57
  userMessageToHistoryItem,
60
58
  } from './historyItems';
59
+ import { runProcess } from '../../process-runtime/src/index';
60
+ import { isPathInside } from '../../workspace/src/index';
61
61
 
62
- const execFileAsync = promisify(execFile);
63
62
 
64
63
  type ClaudePromptInput = string | AsyncIterable<SDKUserMessage>;
65
64
  type ClaudeMessageContent =
@@ -379,8 +378,7 @@ function resolvePromptAssetPath(assetPath: string, cwd: string | null | undefine
379
378
  const resolvedPath = path.isAbsolute(assetPath)
380
379
  ? path.normalize(assetPath)
381
380
  : path.resolve(cwd, assetPath);
382
- const relativePath = path.relative(cwd, resolvedPath);
383
- if (relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath))) {
381
+ if (isPathInside(cwd, resolvedPath)) {
384
382
  return resolvedPath;
385
383
  }
386
384
  return null;
@@ -760,9 +758,12 @@ function versionedAlias(
760
758
  }
761
759
 
762
760
  function withClaudeCodeModelAliases(models: AgentModel[]) {
763
- const output = [...models];
764
- const discoveredSonnet = output.find((model) => /^Sonnet · /.test(model.displayName));
765
- const discoveredFable = output.find((model) => /^Fable · /.test(model.displayName));
761
+ const discoveredSonnet = models.find((model) => /^Sonnet · /.test(model.displayName));
762
+ const discoveredFable = models.find((model) => /^Fable · /.test(model.displayName));
763
+ const discoveredOpus = models.find((model) => /^Opus · /.test(model.displayName));
764
+ const output = models.filter(
765
+ (model) => model !== discoveredFable || discoveredFable.model === 'fable',
766
+ );
766
767
  const defaultSonnet = versionedAlias(DEFAULT_CLAUDE_MODELS[0]!, discoveredSonnet);
767
768
  const oneMillionSonnet = versionedAlias(
768
769
  DEFAULT_CLAUDE_MODELS[1]!,
@@ -770,6 +771,7 @@ function withClaudeCodeModelAliases(models: AgentModel[]) {
770
771
  ' (1M context)',
771
772
  );
772
773
  const fable = versionedAlias(DEFAULT_CLAUDE_MODELS[2]!, discoveredFable);
774
+ const opus = versionedAlias(DEFAULT_CLAUDE_MODELS[3]!, discoveredOpus);
773
775
  const hasSonnetAlias = output.some((model) => model.model === 'sonnet');
774
776
  if (!hasSonnetAlias) {
775
777
  output.unshift(defaultSonnet);
@@ -780,6 +782,10 @@ function withClaudeCodeModelAliases(models: AgentModel[]) {
780
782
  if (!output.some((model) => model.model === 'fable')) {
781
783
  output.splice(2, 0, fable);
782
784
  }
785
+ if (!output.some((model) => model.model === 'opus')) {
786
+ const discoveredOpusIndex = output.findIndex((model) => /^Opus · /.test(model.displayName));
787
+ output.splice(discoveredOpusIndex >= 0 ? discoveredOpusIndex : output.length, 0, opus);
788
+ }
783
789
  return output.map((model, index) => ({
784
790
  ...model,
785
791
  isDefault: index === 0,
@@ -2558,12 +2564,7 @@ export class ClaudeRuntimeAdapter extends EventEmitter implements AgentRuntime {
2558
2564
 
2559
2565
  const relativePath = `./.temp/threads/${input.localThreadId}/claude-history-${safeAssetFilePart(input.messageId)}-${input.blockIndex}.${extension}`;
2560
2566
  const targetPath = path.resolve(input.workspacePath, relativePath);
2561
- const relativeToWorkspace = path.relative(input.workspacePath, targetPath);
2562
- if (
2563
- relativeToWorkspace === '' ||
2564
- relativeToWorkspace.startsWith('..') ||
2565
- path.isAbsolute(relativeToWorkspace)
2566
- ) {
2567
+ if (!isPathInside(input.workspacePath, targetPath)) {
2567
2568
  return null;
2568
2569
  }
2569
2570
 
@@ -2825,12 +2826,10 @@ async function importOptionalPackage(specifier: string) {
2825
2826
  }
2826
2827
 
2827
2828
  async function npmGlobalRoot() {
2828
- try {
2829
- const { stdout } = await execFileAsync('npm', ['root', '-g'], {
2830
- timeout: 3_000,
2831
- });
2832
- return stdout.trim() || null;
2833
- } catch {
2834
- return null;
2835
- }
2829
+ const result = await runProcess({
2830
+ command: 'npm',
2831
+ args: ['root', '-g'],
2832
+ timeoutMs: 3_000,
2833
+ });
2834
+ return result.code === 0 ? result.stdout.trim() || null : null;
2836
2835
  }
@@ -1,5 +1,8 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import { spawn } from 'node:child_process';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
3
6
  import { PassThrough, Writable } from 'node:stream';
4
7
 
5
8
  import { describe, expect, it } from 'vitest';
@@ -80,6 +83,8 @@ class ScriptedChild extends EventEmitter {
80
83
  }
81
84
 
82
85
  describe('CodexAppServerManager', () => {
86
+ const windowsIt = it.runIf(process.platform === 'win32');
87
+
83
88
  it('starts against a newline-delimited JSON-RPC process', async () => {
84
89
  const script = [
85
90
  "const readline=require('node:readline');",
@@ -117,6 +122,48 @@ describe('CodexAppServerManager', () => {
117
122
  await manager.stop();
118
123
  });
119
124
 
125
+ windowsIt('starts an npm-style Codex command shim from a path with spaces', async () => {
126
+ const directory = await fs.mkdtemp(
127
+ path.join(os.tmpdir(), 'remote-codex app server shim '),
128
+ );
129
+ const scriptPath = path.join(directory, 'fake-codex.cjs');
130
+ const shimPath = path.join(directory, 'fake-codex.cmd');
131
+ const script = [
132
+ "const readline = require('node:readline');",
133
+ 'const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });',
134
+ "rl.on('line', (line) => {",
135
+ ' const message = JSON.parse(line);',
136
+ " if (message.method === 'initialize') {",
137
+ " process.stdout.write(JSON.stringify({ id: message.id, result: { userAgent: 'fake-cmd', codexHome: 'C:/tmp', platformFamily: 'windows', platformOs: 'windows' } }) + '\\n');",
138
+ ' }',
139
+ '});',
140
+ ].join('\n');
141
+
142
+ await fs.writeFile(scriptPath, script);
143
+ await fs.writeFile(
144
+ shimPath,
145
+ `@echo off\r\n"${process.execPath}" "%~dp0fake-codex.cjs" %*\r\n`,
146
+ );
147
+
148
+ const manager = new CodexAppServerManager({
149
+ command: shimPath,
150
+ startupTimeoutMs: 3_000,
151
+ clientInfo: {
152
+ name: 'test',
153
+ title: 'test',
154
+ version: '0.1.0',
155
+ },
156
+ });
157
+
158
+ try {
159
+ await manager.start();
160
+ expect(manager.getStatus().state).toBe('ready');
161
+ } finally {
162
+ await manager.stop();
163
+ await fs.rm(directory, { recursive: true, force: true });
164
+ }
165
+ });
166
+
120
167
  it('surfaces spawn failures as a failed status instead of crashing', async () => {
121
168
  class FailingChild extends EventEmitter {
122
169
  stdout = new PassThrough();
@@ -1,6 +1,8 @@
1
- import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process';
1
+ import { ChildProcessWithoutNullStreams } from 'node:child_process';
2
2
  import { EventEmitter } from 'node:events';
3
3
 
4
+ import { spawnProcess as spawnCrossPlatformProcess } from '../../process-runtime/src/index';
5
+
4
6
  import { JsonRpcClient, JsonRpcClientError } from './jsonrpc';
5
7
  import {
6
8
  AppServerStatusSnapshot,
@@ -250,7 +252,9 @@ export class CodexAppServerManager extends EventEmitter {
250
252
  this.spawnProcess =
251
253
  options.spawnProcess ??
252
254
  ((command: string, args: string[]) =>
253
- spawn(command, args, {
255
+ spawnCrossPlatformProcess({
256
+ command,
257
+ args,
254
258
  stdio: 'pipe'
255
259
  }) as unknown as ChildProcessWithoutNullStreams);
256
260
  }
@@ -1,4 +1,5 @@
1
1
  import { EventEmitter } from 'node:events';
2
+ import path from 'node:path';
2
3
 
3
4
  import { describe, expect, it } from 'vitest';
4
5
 
@@ -120,7 +121,10 @@ describe('CodexRuntimeAdapter', () => {
120
121
  { type: 'text', text: 'Inspect this ', text_elements: [] },
121
122
  {
122
123
  type: 'localImage',
123
- path: '/tmp/workspace/.temp/threads/thread-1/photo.png',
124
+ path: path.join(
125
+ path.resolve('/tmp/workspace'),
126
+ '.temp/threads/thread-1/photo.png',
127
+ ),
124
128
  },
125
129
  { type: 'text', text: ' then summarize.', text_elements: [] },
126
130
  ],
@@ -141,7 +145,10 @@ describe('CodexRuntimeAdapter', () => {
141
145
  { type: 'text', text: 'Inspect ', text_elements: [] },
142
146
  {
143
147
  type: 'localImage',
144
- path: '/tmp/workspace/.temp/threads/thread-1/photo.heic',
148
+ path: path.join(
149
+ path.resolve('/tmp/workspace'),
150
+ '.temp/threads/thread-1/photo.heic',
151
+ ),
145
152
  },
146
153
  { type: 'text', text: '.', text_elements: [] },
147
154
  ]);
@@ -1,5 +1,7 @@
1
1
  import path from 'node:path';
2
2
 
3
+ import { isPathInside } from '../../workspace/src/index';
4
+
3
5
  import type {
4
6
  AgentHistoryItem,
5
7
  AgentTurn,
@@ -163,15 +165,14 @@ function displayPath(pathValue: string | null, options: OpenCodeHistoryItemMappi
163
165
 
164
166
  const root = path.resolve(options.workspacePath);
165
167
  const absolutePath = path.resolve(pathValue);
168
+ if (!isPathInside(root, absolutePath)) {
169
+ return pathValue;
170
+ }
166
171
  const relativePath = path.relative(root, absolutePath);
167
- if (
168
- !relativePath ||
169
- relativePath.startsWith('..') ||
170
- path.isAbsolute(relativePath)
171
- ) {
172
+ if (!relativePath) {
172
173
  return pathValue;
173
174
  }
174
- return relativePath;
175
+ return relativePath.split(path.sep).join('/');
175
176
  }
176
177
 
177
178
  function toolIsLowInformationPatch(
@@ -1,11 +1,9 @@
1
1
  import { EventEmitter } from 'node:events';
2
- import { execFile } from 'node:child_process';
3
2
  import fs from 'node:fs/promises';
4
3
  import net from 'node:net';
5
4
  import { createRequire } from 'node:module';
6
5
  import path from 'node:path';
7
6
  import { pathToFileURL } from 'node:url';
8
- import { promisify } from 'node:util';
9
7
 
10
8
  import type {
11
9
  AgentHistoryItem,
@@ -37,8 +35,8 @@ import {
37
35
  openCodeMessageToHistoryItems,
38
36
  openCodeMessagesToPlanUpdate,
39
37
  } from './historyItems';
38
+ import { runProcess } from '../../process-runtime/src/index';
40
39
 
41
- const execFileAsync = promisify(execFile);
42
40
  const openCodeWaitTimeoutMs = 1_500;
43
41
  const openCodePromptPollIntervalMs = 500;
44
42
  const openCodePromptTimeoutMs = 120_000;
@@ -1476,12 +1474,10 @@ function resolveOptionalPackage(
1476
1474
  }
1477
1475
 
1478
1476
  async function npmGlobalRoot() {
1479
- try {
1480
- const { stdout } = await execFileAsync('npm', ['root', '-g'], {
1481
- timeout: 3_000,
1482
- });
1483
- return stdout.trim() || null;
1484
- } catch {
1485
- return null;
1486
- }
1477
+ const result = await runProcess({
1478
+ command: 'npm',
1479
+ args: ['root', '-g'],
1480
+ timeoutMs: 3_000,
1481
+ });
1482
+ return result.code === 0 ? result.stdout.trim() || null : null;
1487
1483
  }
@@ -0,0 +1,132 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, describe, expect, it } from 'vitest';
5
+
6
+ import { parseCommandLine, resolveExecutable, runProcess } from './index';
7
+
8
+ const temporaryDirectories: string[] = [];
9
+
10
+ afterEach(async () => {
11
+ await Promise.all(
12
+ temporaryDirectories.splice(0).map((directory) =>
13
+ fs.rm(directory, { recursive: true, force: true }),
14
+ ),
15
+ );
16
+ });
17
+
18
+ describe('runProcess', () => {
19
+ const windowsIt = it.runIf(process.platform === 'win32');
20
+
21
+ it('preserves argv values without shell interpretation', async () => {
22
+ const argument = 'space & (parentheses) 中文';
23
+ const result = await runProcess({
24
+ command: process.execPath,
25
+ args: ['-e', 'process.stdout.write(process.argv[1])', argument],
26
+ });
27
+
28
+ expect(result.code).toBe(0);
29
+ expect(result.stdout).toBe(argument);
30
+ expect(result.spawnError).toBeNull();
31
+ });
32
+
33
+ it('returns stderr and a non-zero exit code', async () => {
34
+ const result = await runProcess({
35
+ command: process.execPath,
36
+ args: ['-e', "process.stderr.write('failure'); process.exit(7)"],
37
+ });
38
+
39
+ expect(result.code).toBe(7);
40
+ expect(result.stderr).toBe('failure');
41
+ });
42
+
43
+ it('reports a missing executable', async () => {
44
+ const result = await runProcess({
45
+ command: `remote-codex-missing-${Date.now()}`,
46
+ });
47
+
48
+ expect(result.code).toBeNull();
49
+ expect(result.spawnError).not.toBeNull();
50
+ });
51
+
52
+ it('terminates a timed out process', async () => {
53
+ const result = await runProcess({
54
+ command: process.execPath,
55
+ args: ['-e', 'setInterval(() => {}, 1000)'],
56
+ timeoutMs: 50,
57
+ });
58
+
59
+ expect(result.timedOut).toBe(true);
60
+ });
61
+
62
+ it('bounds captured output', async () => {
63
+ const result = await runProcess({
64
+ command: process.execPath,
65
+ args: ['-e', "process.stdout.write('x'.repeat(100))"],
66
+ maxOutputBytes: 10,
67
+ });
68
+
69
+ expect(result.stdout).toBe('x'.repeat(10));
70
+ expect(result.outputTruncated).toBe(true);
71
+ });
72
+
73
+ windowsIt('executes an npm-style command shim from a path with spaces', async () => {
74
+ const directory = await fs.mkdtemp(
75
+ path.join(os.tmpdir(), 'remote-codex command shim '),
76
+ );
77
+ temporaryDirectories.push(directory);
78
+ const shimPath = path.join(directory, 'fake-codex.cmd');
79
+ await fs.writeFile(shimPath, '@echo off\r\necho %~1\r\n');
80
+
81
+ const result = await runProcess({
82
+ command: shimPath,
83
+ args: ['shim executed'],
84
+ timeoutMs: 3_000,
85
+ });
86
+
87
+ expect(result.code).toBe(0);
88
+ expect(result.stdout.trim()).toBe('shim executed');
89
+ expect(result.spawnError).toBeNull();
90
+ });
91
+ });
92
+
93
+ describe('parseCommandLine', () => {
94
+ it('parses built-in npm and quoted node commands without a shell', () => {
95
+ expect(parseCommandLine('npm install -g @openai/codex@latest')).toEqual({
96
+ command: 'npm',
97
+ args: ['install', '-g', '@openai/codex@latest'],
98
+ });
99
+ expect(parseCommandLine('node -e "process.exit(0)"')).toEqual({
100
+ command: 'node',
101
+ args: ['-e', 'process.exit(0)'],
102
+ });
103
+ });
104
+
105
+ it('does not interpret shell operators', () => {
106
+ expect(parseCommandLine('tool first && second')).toEqual({
107
+ command: 'tool',
108
+ args: ['first', '&&', 'second'],
109
+ });
110
+ });
111
+ });
112
+
113
+ describe('resolveExecutable', () => {
114
+ it('resolves an executable from PATH', async () => {
115
+ const resolved = await resolveExecutable(process.execPath);
116
+ expect(resolved).toBe(path.resolve(process.execPath));
117
+ });
118
+
119
+ it('uses Windows PATHEXT semantics for command shims', async () => {
120
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'remote-codex process '));
121
+ temporaryDirectories.push(directory);
122
+ const shimPath = path.join(directory, 'codex.CMD');
123
+ await fs.writeFile(shimPath, '@echo off\r\n');
124
+
125
+ const resolved = await resolveExecutable('codex', {
126
+ env: { PATH: directory, PATHEXT: '.EXE;.CMD' },
127
+ platform: 'win32',
128
+ });
129
+
130
+ expect(resolved?.toLowerCase()).toBe(shimPath.toLowerCase());
131
+ });
132
+ });