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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-codex",
3
- "version": "0.11.44",
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",
@@ -1266,7 +1266,7 @@ describe('ClaudeRuntimeAdapter', () => {
1266
1266
  message: {
1267
1267
  role: 'user',
1268
1268
  content:
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>',
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>',
1270
1270
  },
1271
1271
  parent_tool_use_id: null,
1272
1272
  uuid: '00000000-0000-4000-8000-000000000042' as any,
@@ -2380,7 +2380,7 @@ describe('ClaudeRuntimeAdapter', () => {
2380
2380
  message: {
2381
2381
  role: 'user',
2382
2382
  content:
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>',
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>',
2384
2384
  },
2385
2385
  parent_tool_use_id: null,
2386
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;
@@ -2566,12 +2564,7 @@ export class ClaudeRuntimeAdapter extends EventEmitter implements AgentRuntime {
2566
2564
 
2567
2565
  const relativePath = `./.temp/threads/${input.localThreadId}/claude-history-${safeAssetFilePart(input.messageId)}-${input.blockIndex}.${extension}`;
2568
2566
  const targetPath = path.resolve(input.workspacePath, relativePath);
2569
- const relativeToWorkspace = path.relative(input.workspacePath, targetPath);
2570
- if (
2571
- relativeToWorkspace === '' ||
2572
- relativeToWorkspace.startsWith('..') ||
2573
- path.isAbsolute(relativeToWorkspace)
2574
- ) {
2567
+ if (!isPathInside(input.workspacePath, targetPath)) {
2575
2568
  return null;
2576
2569
  }
2577
2570
 
@@ -2833,12 +2826,10 @@ async function importOptionalPackage(specifier: string) {
2833
2826
  }
2834
2827
 
2835
2828
  async function npmGlobalRoot() {
2836
- try {
2837
- const { stdout } = await execFileAsync('npm', ['root', '-g'], {
2838
- timeout: 3_000,
2839
- });
2840
- return stdout.trim() || null;
2841
- } catch {
2842
- return null;
2843
- }
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;
2844
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
+ });