@livedesk/hub 0.1.41 → 0.1.43
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/package.json +3 -3
- package/src/agents/agent-device-scope.js +29 -29
- package/src/agents/agent-manager.js +103 -103
- package/src/agents/agent-permission-store.js +78 -78
- package/src/agents/agent-runtime-error.js +9 -9
- package/src/agents/agent-settings.js +71 -71
- package/src/agents/codex-agent-runtime.js +594 -594
- package/src/agents/codex-mcp-server.js +100 -100
- package/src/console-relay.js +415 -0
- package/src/filesystem/directory-reader.js +187 -187
- package/src/filesystem/hub-filesystem.js +78 -78
- package/src/filesystem/path-registry.js +78 -78
- package/src/filesystem/roots.js +115 -115
- package/src/frame-packet-contract.mjs +427 -427
- package/src/http/hub-ui-session.js +119 -119
- package/src/live-capture-transition-retry.mjs +297 -297
- package/src/live-desk-update.js +167 -108
- package/src/live-stream-monitor-contract.js +38 -38
- package/src/lzo1x.js +109 -109
- package/src/mode4-atlas-pool.js +137 -137
- package/src/mode4-atlas-sizing.js +32 -32
- package/src/mode4-atlas-worker.js +599 -599
- package/src/mode4-atlas.js +306 -306
- package/src/remote-audio-subscription-contract.mjs +109 -109
- package/src/remote-audio-subscription-contract.test.mjs +72 -72
- package/src/remote-hub.js +144 -144
- package/src/server.js +244 -222
- package/src/settings/effective-device-policy.js +16 -16
- package/src/transport/udp-protocol.js +453 -453
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
import crypto from 'node:crypto';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
3
|
-
import { access, link, mkdir, stat, unlink } from 'node:fs/promises';
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { access, link, mkdir, stat, unlink } from 'node:fs/promises';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
|
-
import { createRequire } from 'node:module';
|
|
8
|
-
import { AgentRuntimeError } from './agent-runtime-error.js';
|
|
9
|
-
import { AGENT_TOOL_NAMES } from './agent-tool-registry.js';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { AgentRuntimeError } from './agent-runtime-error.js';
|
|
9
|
+
import { AGENT_TOOL_NAMES } from './agent-tool-registry.js';
|
|
10
10
|
import { createAgentPermissionPolicy, hashAgentPermissionPolicy } from './agent-permissions.js';
|
|
11
11
|
|
|
12
12
|
const require = createRequire(import.meta.url);
|
|
13
|
-
const MAX_EVENTS = 240;
|
|
14
|
-
const MAX_RUNS = 500;
|
|
15
|
-
const RUN_TTL_MS = 60 * 60 * 1000;
|
|
16
|
-
const MAX_CODEX_CLI_OUTPUT_BYTES = 64 * 1024;
|
|
17
|
-
const MAX_CODEX_CLI_OUTPUT_CHUNKS = 256;
|
|
18
|
-
const CODEX_CLI_TERMINATE_GRACE_MS = 200;
|
|
19
|
-
const CODEX_CLI_FORCE_CLOSE_MS = 1000;
|
|
20
|
-
const activeCliProcessOwners = new Map();
|
|
21
|
-
let nextCliProcessOwnerId = 0;
|
|
22
|
-
let bundledCodexCliPathCache;
|
|
23
|
-
const CODEX_NATIVE_TARGETS = Object.freeze({
|
|
24
|
-
'linux:x64': ['@openai/codex-linux-x64', 'x86_64-unknown-linux-musl', 'codex'],
|
|
25
|
-
'linux:arm64': ['@openai/codex-linux-arm64', 'aarch64-unknown-linux-musl', 'codex'],
|
|
26
|
-
'darwin:x64': ['@openai/codex-darwin-x64', 'x86_64-apple-darwin', 'codex'],
|
|
27
|
-
'darwin:arm64': ['@openai/codex-darwin-arm64', 'aarch64-apple-darwin', 'codex'],
|
|
28
|
-
'win32:x64': ['@openai/codex-win32-x64', 'x86_64-pc-windows-msvc', 'codex.exe'],
|
|
29
|
-
'win32:arm64': ['@openai/codex-win32-arm64', 'aarch64-pc-windows-msvc', 'codex.exe']
|
|
30
|
-
});
|
|
31
|
-
const SAFE_ENV_KEYS = [
|
|
13
|
+
const MAX_EVENTS = 240;
|
|
14
|
+
const MAX_RUNS = 500;
|
|
15
|
+
const RUN_TTL_MS = 60 * 60 * 1000;
|
|
16
|
+
const MAX_CODEX_CLI_OUTPUT_BYTES = 64 * 1024;
|
|
17
|
+
const MAX_CODEX_CLI_OUTPUT_CHUNKS = 256;
|
|
18
|
+
const CODEX_CLI_TERMINATE_GRACE_MS = 200;
|
|
19
|
+
const CODEX_CLI_FORCE_CLOSE_MS = 1000;
|
|
20
|
+
const activeCliProcessOwners = new Map();
|
|
21
|
+
let nextCliProcessOwnerId = 0;
|
|
22
|
+
let bundledCodexCliPathCache;
|
|
23
|
+
const CODEX_NATIVE_TARGETS = Object.freeze({
|
|
24
|
+
'linux:x64': ['@openai/codex-linux-x64', 'x86_64-unknown-linux-musl', 'codex'],
|
|
25
|
+
'linux:arm64': ['@openai/codex-linux-arm64', 'aarch64-unknown-linux-musl', 'codex'],
|
|
26
|
+
'darwin:x64': ['@openai/codex-darwin-x64', 'x86_64-apple-darwin', 'codex'],
|
|
27
|
+
'darwin:arm64': ['@openai/codex-darwin-arm64', 'aarch64-apple-darwin', 'codex'],
|
|
28
|
+
'win32:x64': ['@openai/codex-win32-x64', 'x86_64-pc-windows-msvc', 'codex.exe'],
|
|
29
|
+
'win32:arm64': ['@openai/codex-win32-arm64', 'aarch64-pc-windows-msvc', 'codex.exe']
|
|
30
|
+
});
|
|
31
|
+
const SAFE_ENV_KEYS = [
|
|
32
32
|
'Path',
|
|
33
33
|
'PATH',
|
|
34
34
|
'SystemRoot',
|
|
@@ -71,23 +71,23 @@ function isAbortError(error) {
|
|
|
71
71
|
|| /aborted|abort/i.test(String(error?.message || ''));
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
function normalizeCodexError(error) {
|
|
75
|
-
const message = safeText(error?.message, 800) || 'Codex run failed.';
|
|
76
|
-
if (error instanceof AgentRuntimeError && error.code !== 'codex-run-failed') return error;
|
|
77
|
-
if (/out of credits|usage limit|rate limit|too many requests/i.test(message)) return new AgentRuntimeError('codex-usage-limit-reached', 'The Codex workspace has no remaining usage.', { status: 402, retryable: true });
|
|
78
|
-
if (/refresh[_ -]?token[_ -]?(?:already[_ -]?)?used|token refresh|refresh credential/i.test(message)) return new AgentRuntimeError('codex-auth-stale', 'The saved Codex sign-in is stale. LiveDesk refreshed its Codex authentication bridge and will retry.', { status: 401, retryable: true });
|
|
79
|
-
if (/not authenticated|not logged in|authentication|unauthorized|login required/i.test(message)) return new AgentRuntimeError('codex-auth-required', 'Sign in with the Codex CLI first.', { status: 401 });
|
|
80
|
-
if (/user cancelled MCP tool call/i.test(message)) return new AgentRuntimeError('codex-mcp-tool-rejected', 'Codex rejected the LiveDesk MCP tool before it reached the Hub.', { status: 502 });
|
|
81
|
-
return new AgentRuntimeError('codex-run-failed', message, { status: 502, retryable: true });
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function codexThreadOptions(workspace) {
|
|
85
|
-
return {
|
|
86
|
-
sandboxMode: 'read-only',
|
|
87
|
-
approvalPolicy: 'never',
|
|
88
|
-
workingDirectory: workspace,
|
|
89
|
-
skipGitRepoCheck: true,
|
|
90
|
-
networkAccessEnabled: false,
|
|
74
|
+
function normalizeCodexError(error) {
|
|
75
|
+
const message = safeText(error?.message, 800) || 'Codex run failed.';
|
|
76
|
+
if (error instanceof AgentRuntimeError && error.code !== 'codex-run-failed') return error;
|
|
77
|
+
if (/out of credits|usage limit|rate limit|too many requests/i.test(message)) return new AgentRuntimeError('codex-usage-limit-reached', 'The Codex workspace has no remaining usage.', { status: 402, retryable: true });
|
|
78
|
+
if (/refresh[_ -]?token[_ -]?(?:already[_ -]?)?used|token refresh|refresh credential/i.test(message)) return new AgentRuntimeError('codex-auth-stale', 'The saved Codex sign-in is stale. LiveDesk refreshed its Codex authentication bridge and will retry.', { status: 401, retryable: true });
|
|
79
|
+
if (/not authenticated|not logged in|authentication|unauthorized|login required/i.test(message)) return new AgentRuntimeError('codex-auth-required', 'Sign in with the Codex CLI first.', { status: 401 });
|
|
80
|
+
if (/user cancelled MCP tool call/i.test(message)) return new AgentRuntimeError('codex-mcp-tool-rejected', 'Codex rejected the LiveDesk MCP tool before it reached the Hub.', { status: 502 });
|
|
81
|
+
return new AgentRuntimeError('codex-run-failed', message, { status: 502, retryable: true });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function codexThreadOptions(workspace) {
|
|
85
|
+
return {
|
|
86
|
+
sandboxMode: 'read-only',
|
|
87
|
+
approvalPolicy: 'never',
|
|
88
|
+
workingDirectory: workspace,
|
|
89
|
+
skipGitRepoCheck: true,
|
|
90
|
+
networkAccessEnabled: false,
|
|
91
91
|
webSearchMode: 'disabled',
|
|
92
92
|
webSearchEnabled: false,
|
|
93
93
|
additionalDirectories: []
|
|
@@ -161,342 +161,342 @@ function safeCodexEnv(codexHome) {
|
|
|
161
161
|
return env;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
function sameFile(left, right) {
|
|
165
|
-
return left.dev === right.dev && left.ino === right.ino;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
async function replaceAuthHardLink(sourceAuthPath, targetAuthPath) {
|
|
169
|
-
await unlink(targetAuthPath).catch(error => {
|
|
170
|
-
if (error?.code !== 'ENOENT') throw error;
|
|
171
|
-
});
|
|
172
|
-
try {
|
|
173
|
-
await link(sourceAuthPath, targetAuthPath);
|
|
174
|
-
} catch (error) {
|
|
175
|
-
if (error?.code !== 'EEXIST') throw error;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
async function ensureCodexHome(codexHome, globalCodexHome, { preferGlobalAuth = false } = {}) {
|
|
180
|
-
const resolvedHome = path.resolve(codexHome);
|
|
181
|
-
const globalHome = path.resolve(globalCodexHome);
|
|
182
|
-
if (!resolvedHome || resolvedHome === globalHome) {
|
|
183
|
-
throw new AgentRuntimeError('codex-isolation-unavailable', 'Codex requires a dedicated LiveDesk home.', { status: 503 });
|
|
184
|
-
}
|
|
164
|
+
function sameFile(left, right) {
|
|
165
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function replaceAuthHardLink(sourceAuthPath, targetAuthPath) {
|
|
169
|
+
await unlink(targetAuthPath).catch(error => {
|
|
170
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
171
|
+
});
|
|
172
|
+
try {
|
|
173
|
+
await link(sourceAuthPath, targetAuthPath);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function ensureCodexHome(codexHome, globalCodexHome, { preferGlobalAuth = false } = {}) {
|
|
180
|
+
const resolvedHome = path.resolve(codexHome);
|
|
181
|
+
const globalHome = path.resolve(globalCodexHome);
|
|
182
|
+
if (!resolvedHome || resolvedHome === globalHome) {
|
|
183
|
+
throw new AgentRuntimeError('codex-isolation-unavailable', 'Codex requires a dedicated LiveDesk home.', { status: 503 });
|
|
184
|
+
}
|
|
185
185
|
try {
|
|
186
186
|
await mkdir(resolvedHome, { recursive: true, mode: 0o700 });
|
|
187
187
|
if (await access(path.join(resolvedHome, 'config.toml')).then(() => true, () => false)) {
|
|
188
|
-
throw new AgentRuntimeError('codex-environment-isolation-failed', 'The dedicated Codex home contains an unmanaged config.toml.', { status: 503 });
|
|
188
|
+
throw new AgentRuntimeError('codex-environment-isolation-failed', 'The dedicated Codex home contains an unmanaged config.toml.', { status: 503 });
|
|
189
189
|
}
|
|
190
190
|
} catch (error) {
|
|
191
|
-
if (error instanceof AgentRuntimeError) throw error;
|
|
192
|
-
const wrapped = new AgentRuntimeError('codex-environment-isolation-failed', 'The dedicated Codex home could not be prepared.', { status: 503 });
|
|
191
|
+
if (error instanceof AgentRuntimeError) throw error;
|
|
192
|
+
const wrapped = new AgentRuntimeError('codex-environment-isolation-failed', 'The dedicated Codex home could not be prepared.', { status: 503 });
|
|
193
193
|
wrapped.cause = error;
|
|
194
194
|
throw wrapped;
|
|
195
|
-
}
|
|
196
|
-
const targetAuthPath = path.join(resolvedHome, 'auth.json');
|
|
197
|
-
const sourceAuthPath = path.join(globalHome, 'auth.json');
|
|
198
|
-
if (!(await access(sourceAuthPath).then(() => true, () => false))) return;
|
|
199
|
-
try {
|
|
200
|
-
const sourceStatus = await stat(sourceAuthPath);
|
|
201
|
-
const targetStatus = await stat(targetAuthPath).catch(error => {
|
|
202
|
-
if (error?.code === 'ENOENT') return null;
|
|
203
|
-
throw error;
|
|
204
|
-
});
|
|
205
|
-
if (!targetStatus) {
|
|
206
|
-
await link(sourceAuthPath, targetAuthPath);
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
if (sameFile(sourceStatus, targetStatus)) return;
|
|
210
|
-
|
|
211
|
-
// Codex may atomically replace auth.json while refreshing a token. That
|
|
212
|
-
// breaks a hard link even though both paths still exist. Follow the newer
|
|
213
|
-
// credential owner, or explicitly recover from a stale-refresh failure,
|
|
214
|
-
// without ever reading or copying credential contents.
|
|
215
|
-
if (preferGlobalAuth || sourceStatus.mtimeMs > targetStatus.mtimeMs || targetStatus.size === 0) {
|
|
216
|
-
await replaceAuthHardLink(sourceAuthPath, targetAuthPath);
|
|
217
|
-
}
|
|
218
|
-
} catch (error) {
|
|
219
|
-
const wrapped = new AgentRuntimeError('codex-environment-isolation-failed', 'The Codex authentication bridge could not be prepared.', { status: 503 });
|
|
220
|
-
wrapped.cause = error;
|
|
195
|
+
}
|
|
196
|
+
const targetAuthPath = path.join(resolvedHome, 'auth.json');
|
|
197
|
+
const sourceAuthPath = path.join(globalHome, 'auth.json');
|
|
198
|
+
if (!(await access(sourceAuthPath).then(() => true, () => false))) return;
|
|
199
|
+
try {
|
|
200
|
+
const sourceStatus = await stat(sourceAuthPath);
|
|
201
|
+
const targetStatus = await stat(targetAuthPath).catch(error => {
|
|
202
|
+
if (error?.code === 'ENOENT') return null;
|
|
203
|
+
throw error;
|
|
204
|
+
});
|
|
205
|
+
if (!targetStatus) {
|
|
206
|
+
await link(sourceAuthPath, targetAuthPath);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (sameFile(sourceStatus, targetStatus)) return;
|
|
210
|
+
|
|
211
|
+
// Codex may atomically replace auth.json while refreshing a token. That
|
|
212
|
+
// breaks a hard link even though both paths still exist. Follow the newer
|
|
213
|
+
// credential owner, or explicitly recover from a stale-refresh failure,
|
|
214
|
+
// without ever reading or copying credential contents.
|
|
215
|
+
if (preferGlobalAuth || sourceStatus.mtimeMs > targetStatus.mtimeMs || targetStatus.size === 0) {
|
|
216
|
+
await replaceAuthHardLink(sourceAuthPath, targetAuthPath);
|
|
217
|
+
}
|
|
218
|
+
} catch (error) {
|
|
219
|
+
const wrapped = new AgentRuntimeError('codex-environment-isolation-failed', 'The Codex authentication bridge could not be prepared.', { status: 503 });
|
|
220
|
+
wrapped.cause = error;
|
|
221
221
|
throw wrapped;
|
|
222
222
|
}
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
function boundedCliOption(value, fallback, minimum = 1) {
|
|
226
|
-
const number = Number(value);
|
|
227
|
-
if (!Number.isFinite(number)) return fallback;
|
|
228
|
-
return Math.min(fallback, Math.max(minimum, Math.floor(number)));
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export function resolveBundledCodexCliPath() {
|
|
232
|
-
if (bundledCodexCliPathCache !== undefined) return bundledCodexCliPathCache;
|
|
233
|
-
const target = CODEX_NATIVE_TARGETS[`${process.platform}:${process.arch}`];
|
|
234
|
-
if (!target) {
|
|
235
|
-
bundledCodexCliPathCache = '';
|
|
236
|
-
return bundledCodexCliPathCache;
|
|
237
|
-
}
|
|
238
|
-
const [platformPackage, targetTriple, executableName] = target;
|
|
239
|
-
try {
|
|
240
|
-
const packageJsonPath = require.resolve(`${platformPackage}/package.json`);
|
|
241
|
-
const executablePath = path.join(
|
|
242
|
-
path.dirname(packageJsonPath),
|
|
243
|
-
'vendor',
|
|
244
|
-
targetTriple,
|
|
245
|
-
'bin',
|
|
246
|
-
executableName
|
|
247
|
-
);
|
|
248
|
-
if (existsSync(executablePath)) {
|
|
249
|
-
bundledCodexCliPathCache = executablePath;
|
|
250
|
-
return bundledCodexCliPathCache;
|
|
251
|
-
}
|
|
252
|
-
} catch {
|
|
253
|
-
// Older Codex packages may keep the platform payload under the portable
|
|
254
|
-
// package. That fallback remains a native executable, never the JS wrapper.
|
|
255
|
-
}
|
|
256
|
-
try {
|
|
257
|
-
const portableCliPath = require.resolve('@openai/codex/bin/codex.js');
|
|
258
|
-
const executablePath = path.join(
|
|
259
|
-
path.dirname(portableCliPath),
|
|
260
|
-
'..',
|
|
261
|
-
'vendor',
|
|
262
|
-
targetTriple,
|
|
263
|
-
'bin',
|
|
264
|
-
executableName
|
|
265
|
-
);
|
|
266
|
-
bundledCodexCliPathCache = existsSync(executablePath) ? executablePath : '';
|
|
267
|
-
return bundledCodexCliPathCache;
|
|
268
|
-
} catch {
|
|
269
|
-
bundledCodexCliPathCache = '';
|
|
270
|
-
return bundledCodexCliPathCache;
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
export function getCodexCliProcessOwnerSnapshot() {
|
|
275
|
-
const owners = [...activeCliProcessOwners.values()];
|
|
276
|
-
return {
|
|
277
|
-
activeOwners: owners.length,
|
|
278
|
-
activeProcesses: owners.filter(owner => owner.child !== null).length,
|
|
279
|
-
activeStdoutStreams: owners.filter(owner => owner.stdoutStream !== null).length,
|
|
280
|
-
activeStderrStreams: owners.filter(owner => owner.stderrStream !== null).length,
|
|
281
|
-
activeListeners: owners.reduce((total, owner) => total + owner.listenerCount, 0),
|
|
282
|
-
activeTimers: owners.reduce((total, owner) => total
|
|
283
|
-
+ Number(owner.timeoutTimer !== null)
|
|
284
|
-
+ Number(owner.forceKillTimer !== null)
|
|
285
|
-
+ Number(owner.forceCloseTimer !== null), 0),
|
|
286
|
-
retainedOutputBytes: owners.reduce((total, owner) => total + owner.retainedOutputBytes, 0)
|
|
287
|
-
};
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
export function runCli(
|
|
291
|
-
cliPath,
|
|
292
|
-
args,
|
|
293
|
-
timeoutMs = 5000,
|
|
294
|
-
env = process.env,
|
|
295
|
-
lifecycleOptions = {}
|
|
296
|
-
) {
|
|
297
|
-
const maxOutputBytes = boundedCliOption(
|
|
298
|
-
lifecycleOptions.maxOutputBytes,
|
|
299
|
-
MAX_CODEX_CLI_OUTPUT_BYTES
|
|
300
|
-
);
|
|
301
|
-
const terminateGraceMs = boundedCliOption(
|
|
302
|
-
lifecycleOptions.terminateGraceMs,
|
|
303
|
-
CODEX_CLI_TERMINATE_GRACE_MS
|
|
304
|
-
);
|
|
305
|
-
const forceCloseMs = boundedCliOption(
|
|
306
|
-
lifecycleOptions.forceCloseMs,
|
|
307
|
-
CODEX_CLI_FORCE_CLOSE_MS
|
|
308
|
-
);
|
|
309
|
-
const boundedTimeoutMs = Math.max(1, Math.floor(Number(timeoutMs) || 5000));
|
|
310
|
-
return new Promise((resolve, reject) => {
|
|
311
|
-
let child;
|
|
312
|
-
try {
|
|
313
|
-
const usesNodeLauncher = /\.(?:c|m)?js$/i.test(cliPath);
|
|
314
|
-
child = spawn(usesNodeLauncher ? process.execPath : cliPath, [
|
|
315
|
-
...(usesNodeLauncher ? [cliPath] : []),
|
|
316
|
-
...args
|
|
317
|
-
], {
|
|
318
|
-
env,
|
|
319
|
-
windowsHide: true,
|
|
320
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
321
|
-
});
|
|
322
|
-
} catch (error) {
|
|
323
|
-
reject(error);
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
const owner = {
|
|
328
|
-
id: ++nextCliProcessOwnerId,
|
|
329
|
-
child,
|
|
330
|
-
stdoutStream: child.stdout,
|
|
331
|
-
stderrStream: child.stderr,
|
|
332
|
-
stdoutChunks: [],
|
|
333
|
-
stderrChunks: [],
|
|
334
|
-
stdoutBytes: 0,
|
|
335
|
-
stderrBytes: 0,
|
|
336
|
-
retainedOutputBytes: 0,
|
|
337
|
-
listenerCount: 6,
|
|
338
|
-
acceptingOutput: true,
|
|
339
|
-
failure: null,
|
|
340
|
-
settled: false,
|
|
341
|
-
timeoutTimer: null,
|
|
342
|
-
forceKillTimer: null,
|
|
343
|
-
forceCloseTimer: null
|
|
344
|
-
};
|
|
345
|
-
activeCliProcessOwners.set(owner.id, owner);
|
|
346
|
-
|
|
347
|
-
const clearOwnerTimer = key => {
|
|
348
|
-
if (owner[key] === null) return;
|
|
349
|
-
clearTimeout(owner[key]);
|
|
350
|
-
owner[key] = null;
|
|
351
|
-
};
|
|
352
|
-
|
|
353
|
-
const removeOutputDataListeners = () => {
|
|
354
|
-
if (!owner.acceptingOutput) return;
|
|
355
|
-
owner.acceptingOutput = false;
|
|
356
|
-
owner.stdoutStream?.removeListener('data', onStdoutData);
|
|
357
|
-
owner.stderrStream?.removeListener('data', onStderrData);
|
|
358
|
-
owner.listenerCount = Math.max(0, owner.listenerCount - 2);
|
|
359
|
-
owner.stdoutStream?.resume();
|
|
360
|
-
owner.stderrStream?.resume();
|
|
361
|
-
};
|
|
362
|
-
|
|
363
|
-
const cleanupOwner = () => {
|
|
364
|
-
clearOwnerTimer('timeoutTimer');
|
|
365
|
-
clearOwnerTimer('forceKillTimer');
|
|
366
|
-
clearOwnerTimer('forceCloseTimer');
|
|
367
|
-
owner.child?.removeListener('error', onChildError);
|
|
368
|
-
owner.child?.removeListener('close', onChildClose);
|
|
369
|
-
owner.stdoutStream?.removeListener('data', onStdoutData);
|
|
370
|
-
owner.stdoutStream?.removeListener('error', onStdoutError);
|
|
371
|
-
owner.stderrStream?.removeListener('data', onStderrData);
|
|
372
|
-
owner.stderrStream?.removeListener('error', onStderrError);
|
|
373
|
-
owner.stdoutStream?.destroy();
|
|
374
|
-
owner.stderrStream?.destroy();
|
|
375
|
-
owner.child?.unref?.();
|
|
376
|
-
owner.child = null;
|
|
377
|
-
owner.stdoutStream = null;
|
|
378
|
-
owner.stderrStream = null;
|
|
379
|
-
owner.stdoutChunks.length = 0;
|
|
380
|
-
owner.stderrChunks.length = 0;
|
|
381
|
-
owner.stdoutBytes = 0;
|
|
382
|
-
owner.stderrBytes = 0;
|
|
383
|
-
owner.retainedOutputBytes = 0;
|
|
384
|
-
owner.listenerCount = 0;
|
|
385
|
-
activeCliProcessOwners.delete(owner.id);
|
|
386
|
-
};
|
|
387
|
-
|
|
388
|
-
const finish = code => {
|
|
389
|
-
if (owner.settled) return;
|
|
390
|
-
owner.settled = true;
|
|
391
|
-
const failure = owner.failure;
|
|
392
|
-
const result = failure ? null : {
|
|
393
|
-
code,
|
|
394
|
-
stdout: Buffer.concat(owner.stdoutChunks, owner.stdoutBytes).toString('utf8').trim(),
|
|
395
|
-
stderr: Buffer.concat(owner.stderrChunks, owner.stderrBytes).toString('utf8').trim()
|
|
396
|
-
};
|
|
397
|
-
cleanupOwner();
|
|
398
|
-
if (failure) reject(failure);
|
|
399
|
-
else resolve(result);
|
|
400
|
-
};
|
|
401
|
-
|
|
402
|
-
const killExactChild = signal => {
|
|
403
|
-
if (owner.child === null || owner.child.exitCode !== null || owner.child.signalCode !== null) return;
|
|
404
|
-
try {
|
|
405
|
-
owner.child.kill(signal);
|
|
406
|
-
} catch {
|
|
407
|
-
// The bounded forced-close owner below still releases every local
|
|
408
|
-
// handle if the OS reports that this exact child already disappeared.
|
|
409
|
-
}
|
|
410
|
-
};
|
|
411
|
-
|
|
412
|
-
const failAfterExactChildStops = error => {
|
|
413
|
-
if (owner.failure || owner.settled) return;
|
|
414
|
-
owner.failure = error;
|
|
415
|
-
clearOwnerTimer('timeoutTimer');
|
|
416
|
-
removeOutputDataListeners();
|
|
417
|
-
killExactChild('SIGTERM');
|
|
418
|
-
owner.forceKillTimer = setTimeout(() => {
|
|
419
|
-
owner.forceKillTimer = null;
|
|
420
|
-
killExactChild('SIGKILL');
|
|
421
|
-
owner.forceCloseTimer = setTimeout(() => {
|
|
422
|
-
owner.forceCloseTimer = null;
|
|
423
|
-
error.cleanupTimedOut = true;
|
|
424
|
-
killExactChild('SIGKILL');
|
|
425
|
-
finish(null);
|
|
426
|
-
}, forceCloseMs);
|
|
427
|
-
owner.forceCloseTimer.unref?.();
|
|
428
|
-
}, terminateGraceMs);
|
|
429
|
-
owner.forceKillTimer.unref?.();
|
|
430
|
-
};
|
|
431
|
-
|
|
432
|
-
const retainOutput = (channel, chunk) => {
|
|
433
|
-
if (!owner.acceptingOutput || owner.settled) return;
|
|
434
|
-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
435
|
-
const nextBytes = owner.retainedOutputBytes + buffer.length;
|
|
436
|
-
const nextChunks = owner.stdoutChunks.length + owner.stderrChunks.length + 1;
|
|
437
|
-
if (nextBytes > maxOutputBytes || nextChunks > MAX_CODEX_CLI_OUTPUT_CHUNKS) {
|
|
438
|
-
failAfterExactChildStops(Object.assign(
|
|
439
|
-
new Error(`Codex CLI status output exceeded its bounded retention budget.`),
|
|
440
|
-
{
|
|
441
|
-
code: 'codex-status-output-limit',
|
|
442
|
-
maxOutputBytes,
|
|
443
|
-
observedOutputBytes: nextBytes,
|
|
444
|
-
maxOutputChunks: MAX_CODEX_CLI_OUTPUT_CHUNKS,
|
|
445
|
-
observedOutputChunks: nextChunks
|
|
446
|
-
}
|
|
447
|
-
));
|
|
448
|
-
return;
|
|
449
|
-
}
|
|
450
|
-
owner.retainedOutputBytes = nextBytes;
|
|
451
|
-
if (channel === 'stdout') {
|
|
452
|
-
owner.stdoutChunks.push(buffer);
|
|
453
|
-
owner.stdoutBytes += buffer.length;
|
|
454
|
-
} else {
|
|
455
|
-
owner.stderrChunks.push(buffer);
|
|
456
|
-
owner.stderrBytes += buffer.length;
|
|
457
|
-
}
|
|
458
|
-
};
|
|
459
|
-
|
|
460
|
-
function onStdoutData(chunk) {
|
|
461
|
-
retainOutput('stdout', chunk);
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
function onStderrData(chunk) {
|
|
465
|
-
retainOutput('stderr', chunk);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
function onStdoutError(error) {
|
|
469
|
-
failAfterExactChildStops(error);
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
function onStderrError(error) {
|
|
473
|
-
failAfterExactChildStops(error);
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
function onChildError(error) {
|
|
477
|
-
failAfterExactChildStops(error);
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
function onChildClose(code) {
|
|
481
|
-
finish(code);
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
child.stdout.on('data', onStdoutData);
|
|
485
|
-
child.stdout.once('error', onStdoutError);
|
|
486
|
-
child.stderr.on('data', onStderrData);
|
|
487
|
-
child.stderr.once('error', onStderrError);
|
|
488
|
-
child.once('error', onChildError);
|
|
489
|
-
child.once('close', onChildClose);
|
|
490
|
-
owner.timeoutTimer = setTimeout(() => {
|
|
491
|
-
owner.timeoutTimer = null;
|
|
492
|
-
failAfterExactChildStops(Object.assign(
|
|
493
|
-
new Error('Codex CLI status check timed out.'),
|
|
494
|
-
{ code: 'codex-status-timeout' }
|
|
495
|
-
));
|
|
496
|
-
}, boundedTimeoutMs);
|
|
497
|
-
owner.timeoutTimer.unref?.();
|
|
498
|
-
});
|
|
499
|
-
}
|
|
225
|
+
function boundedCliOption(value, fallback, minimum = 1) {
|
|
226
|
+
const number = Number(value);
|
|
227
|
+
if (!Number.isFinite(number)) return fallback;
|
|
228
|
+
return Math.min(fallback, Math.max(minimum, Math.floor(number)));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function resolveBundledCodexCliPath() {
|
|
232
|
+
if (bundledCodexCliPathCache !== undefined) return bundledCodexCliPathCache;
|
|
233
|
+
const target = CODEX_NATIVE_TARGETS[`${process.platform}:${process.arch}`];
|
|
234
|
+
if (!target) {
|
|
235
|
+
bundledCodexCliPathCache = '';
|
|
236
|
+
return bundledCodexCliPathCache;
|
|
237
|
+
}
|
|
238
|
+
const [platformPackage, targetTriple, executableName] = target;
|
|
239
|
+
try {
|
|
240
|
+
const packageJsonPath = require.resolve(`${platformPackage}/package.json`);
|
|
241
|
+
const executablePath = path.join(
|
|
242
|
+
path.dirname(packageJsonPath),
|
|
243
|
+
'vendor',
|
|
244
|
+
targetTriple,
|
|
245
|
+
'bin',
|
|
246
|
+
executableName
|
|
247
|
+
);
|
|
248
|
+
if (existsSync(executablePath)) {
|
|
249
|
+
bundledCodexCliPathCache = executablePath;
|
|
250
|
+
return bundledCodexCliPathCache;
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
// Older Codex packages may keep the platform payload under the portable
|
|
254
|
+
// package. That fallback remains a native executable, never the JS wrapper.
|
|
255
|
+
}
|
|
256
|
+
try {
|
|
257
|
+
const portableCliPath = require.resolve('@openai/codex/bin/codex.js');
|
|
258
|
+
const executablePath = path.join(
|
|
259
|
+
path.dirname(portableCliPath),
|
|
260
|
+
'..',
|
|
261
|
+
'vendor',
|
|
262
|
+
targetTriple,
|
|
263
|
+
'bin',
|
|
264
|
+
executableName
|
|
265
|
+
);
|
|
266
|
+
bundledCodexCliPathCache = existsSync(executablePath) ? executablePath : '';
|
|
267
|
+
return bundledCodexCliPathCache;
|
|
268
|
+
} catch {
|
|
269
|
+
bundledCodexCliPathCache = '';
|
|
270
|
+
return bundledCodexCliPathCache;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function getCodexCliProcessOwnerSnapshot() {
|
|
275
|
+
const owners = [...activeCliProcessOwners.values()];
|
|
276
|
+
return {
|
|
277
|
+
activeOwners: owners.length,
|
|
278
|
+
activeProcesses: owners.filter(owner => owner.child !== null).length,
|
|
279
|
+
activeStdoutStreams: owners.filter(owner => owner.stdoutStream !== null).length,
|
|
280
|
+
activeStderrStreams: owners.filter(owner => owner.stderrStream !== null).length,
|
|
281
|
+
activeListeners: owners.reduce((total, owner) => total + owner.listenerCount, 0),
|
|
282
|
+
activeTimers: owners.reduce((total, owner) => total
|
|
283
|
+
+ Number(owner.timeoutTimer !== null)
|
|
284
|
+
+ Number(owner.forceKillTimer !== null)
|
|
285
|
+
+ Number(owner.forceCloseTimer !== null), 0),
|
|
286
|
+
retainedOutputBytes: owners.reduce((total, owner) => total + owner.retainedOutputBytes, 0)
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function runCli(
|
|
291
|
+
cliPath,
|
|
292
|
+
args,
|
|
293
|
+
timeoutMs = 5000,
|
|
294
|
+
env = process.env,
|
|
295
|
+
lifecycleOptions = {}
|
|
296
|
+
) {
|
|
297
|
+
const maxOutputBytes = boundedCliOption(
|
|
298
|
+
lifecycleOptions.maxOutputBytes,
|
|
299
|
+
MAX_CODEX_CLI_OUTPUT_BYTES
|
|
300
|
+
);
|
|
301
|
+
const terminateGraceMs = boundedCliOption(
|
|
302
|
+
lifecycleOptions.terminateGraceMs,
|
|
303
|
+
CODEX_CLI_TERMINATE_GRACE_MS
|
|
304
|
+
);
|
|
305
|
+
const forceCloseMs = boundedCliOption(
|
|
306
|
+
lifecycleOptions.forceCloseMs,
|
|
307
|
+
CODEX_CLI_FORCE_CLOSE_MS
|
|
308
|
+
);
|
|
309
|
+
const boundedTimeoutMs = Math.max(1, Math.floor(Number(timeoutMs) || 5000));
|
|
310
|
+
return new Promise((resolve, reject) => {
|
|
311
|
+
let child;
|
|
312
|
+
try {
|
|
313
|
+
const usesNodeLauncher = /\.(?:c|m)?js$/i.test(cliPath);
|
|
314
|
+
child = spawn(usesNodeLauncher ? process.execPath : cliPath, [
|
|
315
|
+
...(usesNodeLauncher ? [cliPath] : []),
|
|
316
|
+
...args
|
|
317
|
+
], {
|
|
318
|
+
env,
|
|
319
|
+
windowsHide: true,
|
|
320
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
321
|
+
});
|
|
322
|
+
} catch (error) {
|
|
323
|
+
reject(error);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const owner = {
|
|
328
|
+
id: ++nextCliProcessOwnerId,
|
|
329
|
+
child,
|
|
330
|
+
stdoutStream: child.stdout,
|
|
331
|
+
stderrStream: child.stderr,
|
|
332
|
+
stdoutChunks: [],
|
|
333
|
+
stderrChunks: [],
|
|
334
|
+
stdoutBytes: 0,
|
|
335
|
+
stderrBytes: 0,
|
|
336
|
+
retainedOutputBytes: 0,
|
|
337
|
+
listenerCount: 6,
|
|
338
|
+
acceptingOutput: true,
|
|
339
|
+
failure: null,
|
|
340
|
+
settled: false,
|
|
341
|
+
timeoutTimer: null,
|
|
342
|
+
forceKillTimer: null,
|
|
343
|
+
forceCloseTimer: null
|
|
344
|
+
};
|
|
345
|
+
activeCliProcessOwners.set(owner.id, owner);
|
|
346
|
+
|
|
347
|
+
const clearOwnerTimer = key => {
|
|
348
|
+
if (owner[key] === null) return;
|
|
349
|
+
clearTimeout(owner[key]);
|
|
350
|
+
owner[key] = null;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const removeOutputDataListeners = () => {
|
|
354
|
+
if (!owner.acceptingOutput) return;
|
|
355
|
+
owner.acceptingOutput = false;
|
|
356
|
+
owner.stdoutStream?.removeListener('data', onStdoutData);
|
|
357
|
+
owner.stderrStream?.removeListener('data', onStderrData);
|
|
358
|
+
owner.listenerCount = Math.max(0, owner.listenerCount - 2);
|
|
359
|
+
owner.stdoutStream?.resume();
|
|
360
|
+
owner.stderrStream?.resume();
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const cleanupOwner = () => {
|
|
364
|
+
clearOwnerTimer('timeoutTimer');
|
|
365
|
+
clearOwnerTimer('forceKillTimer');
|
|
366
|
+
clearOwnerTimer('forceCloseTimer');
|
|
367
|
+
owner.child?.removeListener('error', onChildError);
|
|
368
|
+
owner.child?.removeListener('close', onChildClose);
|
|
369
|
+
owner.stdoutStream?.removeListener('data', onStdoutData);
|
|
370
|
+
owner.stdoutStream?.removeListener('error', onStdoutError);
|
|
371
|
+
owner.stderrStream?.removeListener('data', onStderrData);
|
|
372
|
+
owner.stderrStream?.removeListener('error', onStderrError);
|
|
373
|
+
owner.stdoutStream?.destroy();
|
|
374
|
+
owner.stderrStream?.destroy();
|
|
375
|
+
owner.child?.unref?.();
|
|
376
|
+
owner.child = null;
|
|
377
|
+
owner.stdoutStream = null;
|
|
378
|
+
owner.stderrStream = null;
|
|
379
|
+
owner.stdoutChunks.length = 0;
|
|
380
|
+
owner.stderrChunks.length = 0;
|
|
381
|
+
owner.stdoutBytes = 0;
|
|
382
|
+
owner.stderrBytes = 0;
|
|
383
|
+
owner.retainedOutputBytes = 0;
|
|
384
|
+
owner.listenerCount = 0;
|
|
385
|
+
activeCliProcessOwners.delete(owner.id);
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const finish = code => {
|
|
389
|
+
if (owner.settled) return;
|
|
390
|
+
owner.settled = true;
|
|
391
|
+
const failure = owner.failure;
|
|
392
|
+
const result = failure ? null : {
|
|
393
|
+
code,
|
|
394
|
+
stdout: Buffer.concat(owner.stdoutChunks, owner.stdoutBytes).toString('utf8').trim(),
|
|
395
|
+
stderr: Buffer.concat(owner.stderrChunks, owner.stderrBytes).toString('utf8').trim()
|
|
396
|
+
};
|
|
397
|
+
cleanupOwner();
|
|
398
|
+
if (failure) reject(failure);
|
|
399
|
+
else resolve(result);
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const killExactChild = signal => {
|
|
403
|
+
if (owner.child === null || owner.child.exitCode !== null || owner.child.signalCode !== null) return;
|
|
404
|
+
try {
|
|
405
|
+
owner.child.kill(signal);
|
|
406
|
+
} catch {
|
|
407
|
+
// The bounded forced-close owner below still releases every local
|
|
408
|
+
// handle if the OS reports that this exact child already disappeared.
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const failAfterExactChildStops = error => {
|
|
413
|
+
if (owner.failure || owner.settled) return;
|
|
414
|
+
owner.failure = error;
|
|
415
|
+
clearOwnerTimer('timeoutTimer');
|
|
416
|
+
removeOutputDataListeners();
|
|
417
|
+
killExactChild('SIGTERM');
|
|
418
|
+
owner.forceKillTimer = setTimeout(() => {
|
|
419
|
+
owner.forceKillTimer = null;
|
|
420
|
+
killExactChild('SIGKILL');
|
|
421
|
+
owner.forceCloseTimer = setTimeout(() => {
|
|
422
|
+
owner.forceCloseTimer = null;
|
|
423
|
+
error.cleanupTimedOut = true;
|
|
424
|
+
killExactChild('SIGKILL');
|
|
425
|
+
finish(null);
|
|
426
|
+
}, forceCloseMs);
|
|
427
|
+
owner.forceCloseTimer.unref?.();
|
|
428
|
+
}, terminateGraceMs);
|
|
429
|
+
owner.forceKillTimer.unref?.();
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const retainOutput = (channel, chunk) => {
|
|
433
|
+
if (!owner.acceptingOutput || owner.settled) return;
|
|
434
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
435
|
+
const nextBytes = owner.retainedOutputBytes + buffer.length;
|
|
436
|
+
const nextChunks = owner.stdoutChunks.length + owner.stderrChunks.length + 1;
|
|
437
|
+
if (nextBytes > maxOutputBytes || nextChunks > MAX_CODEX_CLI_OUTPUT_CHUNKS) {
|
|
438
|
+
failAfterExactChildStops(Object.assign(
|
|
439
|
+
new Error(`Codex CLI status output exceeded its bounded retention budget.`),
|
|
440
|
+
{
|
|
441
|
+
code: 'codex-status-output-limit',
|
|
442
|
+
maxOutputBytes,
|
|
443
|
+
observedOutputBytes: nextBytes,
|
|
444
|
+
maxOutputChunks: MAX_CODEX_CLI_OUTPUT_CHUNKS,
|
|
445
|
+
observedOutputChunks: nextChunks
|
|
446
|
+
}
|
|
447
|
+
));
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
owner.retainedOutputBytes = nextBytes;
|
|
451
|
+
if (channel === 'stdout') {
|
|
452
|
+
owner.stdoutChunks.push(buffer);
|
|
453
|
+
owner.stdoutBytes += buffer.length;
|
|
454
|
+
} else {
|
|
455
|
+
owner.stderrChunks.push(buffer);
|
|
456
|
+
owner.stderrBytes += buffer.length;
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
function onStdoutData(chunk) {
|
|
461
|
+
retainOutput('stdout', chunk);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function onStderrData(chunk) {
|
|
465
|
+
retainOutput('stderr', chunk);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function onStdoutError(error) {
|
|
469
|
+
failAfterExactChildStops(error);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function onStderrError(error) {
|
|
473
|
+
failAfterExactChildStops(error);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function onChildError(error) {
|
|
477
|
+
failAfterExactChildStops(error);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function onChildClose(code) {
|
|
481
|
+
finish(code);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
child.stdout.on('data', onStdoutData);
|
|
485
|
+
child.stdout.once('error', onStdoutError);
|
|
486
|
+
child.stderr.on('data', onStderrData);
|
|
487
|
+
child.stderr.once('error', onStderrError);
|
|
488
|
+
child.once('error', onChildError);
|
|
489
|
+
child.once('close', onChildClose);
|
|
490
|
+
owner.timeoutTimer = setTimeout(() => {
|
|
491
|
+
owner.timeoutTimer = null;
|
|
492
|
+
failAfterExactChildStops(Object.assign(
|
|
493
|
+
new Error('Codex CLI status check timed out.'),
|
|
494
|
+
{ code: 'codex-status-timeout' }
|
|
495
|
+
));
|
|
496
|
+
}, boundedTimeoutMs);
|
|
497
|
+
owner.timeoutTimer.unref?.();
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
500
|
|
|
501
501
|
function isTerminalStatus(status) {
|
|
502
502
|
return ['completed', 'failed', 'cancelled'].includes(status);
|
|
@@ -507,7 +507,7 @@ function runAgeMs(run) {
|
|
|
507
507
|
}
|
|
508
508
|
|
|
509
509
|
function codexAbortError(code, message) {
|
|
510
|
-
return new AgentRuntimeError(code, message, { status: code === 'codex-run-timeout' ? 504 : 409, retryable: code === 'codex-run-timeout' });
|
|
510
|
+
return new AgentRuntimeError(code, message, { status: code === 'codex-run-timeout' ? 504 : 409, retryable: code === 'codex-run-timeout' });
|
|
511
511
|
}
|
|
512
512
|
|
|
513
513
|
function safeAgentDeviceIds(value) {
|
|
@@ -528,41 +528,41 @@ export function createCodexAgentRuntime({
|
|
|
528
528
|
settingsStore,
|
|
529
529
|
dataDir = path.join(os.homedir(), '.livedesk'),
|
|
530
530
|
mcpServerPath,
|
|
531
|
-
createMcpSession,
|
|
532
|
-
destroyMcpSession,
|
|
533
|
-
createCodexClient: injectedCodexClient,
|
|
534
|
-
getCodexStatus: injectedCodexStatus,
|
|
535
|
-
onProgress,
|
|
536
|
-
globalCodexHome = path.join(os.homedir(), '.codex')
|
|
537
|
-
} = {}) {
|
|
531
|
+
createMcpSession,
|
|
532
|
+
destroyMcpSession,
|
|
533
|
+
createCodexClient: injectedCodexClient,
|
|
534
|
+
getCodexStatus: injectedCodexStatus,
|
|
535
|
+
onProgress,
|
|
536
|
+
globalCodexHome = path.join(os.homedir(), '.codex')
|
|
537
|
+
} = {}) {
|
|
538
538
|
const runs = new Map();
|
|
539
539
|
let activeCount = 0;
|
|
540
540
|
let codexModulePromise;
|
|
541
541
|
let securityVerificationState = 'configured';
|
|
542
|
-
const workspace = path.resolve(dataDir, 'agent-workspace');
|
|
543
|
-
const codexHome = path.resolve(dataDir, 'codex-home');
|
|
544
|
-
const resolvedGlobalCodexHome = path.resolve(globalCodexHome);
|
|
545
|
-
let codexHomePreparation = Promise.resolve();
|
|
546
|
-
|
|
547
|
-
function reportProgress(run, stage, message, extra = {}) {
|
|
548
|
-
run.stage = safeText(stage, 80);
|
|
549
|
-
run.progressMessage = safeText(message, 500);
|
|
550
|
-
if (typeof onProgress !== 'function') return;
|
|
551
|
-
try {
|
|
552
|
-
onProgress({
|
|
553
|
-
runId: run.runId,
|
|
554
|
-
deviceIds: [...run.deviceIds],
|
|
555
|
-
stage: safeText(stage, 80),
|
|
556
|
-
message: safeText(message, 500),
|
|
557
|
-
status: run.status,
|
|
558
|
-
...extra
|
|
559
|
-
});
|
|
560
|
-
} catch {
|
|
561
|
-
// Client-side developer progress is best effort and must never affect a
|
|
562
|
-
// Codex run or its enforced permission boundary.
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
|
|
542
|
+
const workspace = path.resolve(dataDir, 'agent-workspace');
|
|
543
|
+
const codexHome = path.resolve(dataDir, 'codex-home');
|
|
544
|
+
const resolvedGlobalCodexHome = path.resolve(globalCodexHome);
|
|
545
|
+
let codexHomePreparation = Promise.resolve();
|
|
546
|
+
|
|
547
|
+
function reportProgress(run, stage, message, extra = {}) {
|
|
548
|
+
run.stage = safeText(stage, 80);
|
|
549
|
+
run.progressMessage = safeText(message, 500);
|
|
550
|
+
if (typeof onProgress !== 'function') return;
|
|
551
|
+
try {
|
|
552
|
+
onProgress({
|
|
553
|
+
runId: run.runId,
|
|
554
|
+
deviceIds: [...run.deviceIds],
|
|
555
|
+
stage: safeText(stage, 80),
|
|
556
|
+
message: safeText(message, 500),
|
|
557
|
+
status: run.status,
|
|
558
|
+
...extra
|
|
559
|
+
});
|
|
560
|
+
} catch {
|
|
561
|
+
// Client-side developer progress is best effort and must never affect a
|
|
562
|
+
// Codex run or its enforced permission boundary.
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
566
|
function securityStatus() {
|
|
567
567
|
const configured = codexHome !== path.resolve(os.homedir(), '.codex')
|
|
568
568
|
&& typeof createMcpSession === 'function'
|
|
@@ -578,24 +578,24 @@ export function createCodexAgentRuntime({
|
|
|
578
578
|
};
|
|
579
579
|
}
|
|
580
580
|
|
|
581
|
-
async function prepareCodexHome(options = {}) {
|
|
582
|
-
const operation = codexHomePreparation.then(async () => {
|
|
583
|
-
try {
|
|
584
|
-
await ensureCodexHome(codexHome, resolvedGlobalCodexHome, options);
|
|
585
|
-
securityVerificationState = 'verified';
|
|
586
|
-
} catch (error) {
|
|
587
|
-
securityVerificationState = 'unavailable';
|
|
588
|
-
throw error;
|
|
589
|
-
}
|
|
590
|
-
});
|
|
591
|
-
codexHomePreparation = operation.catch(() => undefined);
|
|
592
|
-
return operation;
|
|
581
|
+
async function prepareCodexHome(options = {}) {
|
|
582
|
+
const operation = codexHomePreparation.then(async () => {
|
|
583
|
+
try {
|
|
584
|
+
await ensureCodexHome(codexHome, resolvedGlobalCodexHome, options);
|
|
585
|
+
securityVerificationState = 'verified';
|
|
586
|
+
} catch (error) {
|
|
587
|
+
securityVerificationState = 'unavailable';
|
|
588
|
+
throw error;
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
codexHomePreparation = operation.catch(() => undefined);
|
|
592
|
+
return operation;
|
|
593
593
|
}
|
|
594
594
|
|
|
595
595
|
function assertSecurityConfiguration() {
|
|
596
596
|
const status = securityStatus();
|
|
597
597
|
if (!status.isolatedCodexHome || !status.restrictedEnvironment || !status.livedeskMcpOnly || !status.selectedDeviceScopeEnforced) {
|
|
598
|
-
throw new AgentRuntimeError('codex-unsupported-security-config', 'Codex security isolation is not available.', { status: 503 });
|
|
598
|
+
throw new AgentRuntimeError('codex-unsupported-security-config', 'Codex security isolation is not available.', { status: 503 });
|
|
599
599
|
}
|
|
600
600
|
}
|
|
601
601
|
|
|
@@ -628,16 +628,16 @@ export function createCodexAgentRuntime({
|
|
|
628
628
|
|
|
629
629
|
async function loadCodex() {
|
|
630
630
|
if (!codexModulePromise) codexModulePromise = import('@openai/codex-sdk').catch(error => {
|
|
631
|
-
const wrapped = new AgentRuntimeError('codex-sdk-not-installed', 'Codex SDK is not installed in this LiveDesk package.', { status: 503 });
|
|
631
|
+
const wrapped = new AgentRuntimeError('codex-sdk-not-installed', 'Codex SDK is not installed in this LiveDesk package.', { status: 503 });
|
|
632
632
|
wrapped.cause = error;
|
|
633
633
|
throw wrapped;
|
|
634
634
|
});
|
|
635
635
|
return codexModulePromise;
|
|
636
636
|
}
|
|
637
637
|
|
|
638
|
-
function cliPath() {
|
|
639
|
-
return resolveBundledCodexCliPath();
|
|
640
|
-
}
|
|
638
|
+
function cliPath() {
|
|
639
|
+
return resolveBundledCodexCliPath();
|
|
640
|
+
}
|
|
641
641
|
|
|
642
642
|
async function getStatus() {
|
|
643
643
|
if (typeof injectedCodexStatus === 'function') return injectedCodexStatus();
|
|
@@ -655,7 +655,7 @@ export function createCodexAgentRuntime({
|
|
|
655
655
|
? 'signed-in'
|
|
656
656
|
: 'not-signed-in';
|
|
657
657
|
} catch (error) {
|
|
658
|
-
if (error instanceof AgentRuntimeError) throw error;
|
|
658
|
+
if (error instanceof AgentRuntimeError) throw error;
|
|
659
659
|
detail = safeText(error?.message, 300);
|
|
660
660
|
}
|
|
661
661
|
return { installed: true, authenticated, status: authenticated === 'signed-in' ? 'ready' : 'auth-required', codexPath: pathToCli, detail };
|
|
@@ -665,8 +665,8 @@ export function createCodexAgentRuntime({
|
|
|
665
665
|
assertSecurityConfiguration();
|
|
666
666
|
const startedAt = Date.now();
|
|
667
667
|
const status = await getStatus();
|
|
668
|
-
if (!status.installed) throw new AgentRuntimeError('codex-sdk-not-installed', 'Codex SDK is not installed.', { status: 503 });
|
|
669
|
-
if (status.authenticated !== 'signed-in') throw new AgentRuntimeError('codex-auth-required', 'Sign in with the Codex CLI first.', { status: 401 });
|
|
668
|
+
if (!status.installed) throw new AgentRuntimeError('codex-sdk-not-installed', 'Codex SDK is not installed.', { status: 503 });
|
|
669
|
+
if (status.authenticated !== 'signed-in') throw new AgentRuntimeError('codex-auth-required', 'Sign in with the Codex CLI first.', { status: 401 });
|
|
670
670
|
const settings = await settingsStore.get();
|
|
671
671
|
await mkdir(workspace, { recursive: true });
|
|
672
672
|
await prepareCodexHome();
|
|
@@ -674,10 +674,10 @@ export function createCodexAgentRuntime({
|
|
|
674
674
|
const controller = new AbortController();
|
|
675
675
|
const timer = setTimeout(() => controller.abort(), Math.min(30000, settings.codexTaskTimeoutMs));
|
|
676
676
|
try {
|
|
677
|
-
const thread = codex.startThread(codexThreadOptions(workspace));
|
|
678
|
-
const turn = await thread.run('Reply with the single word READY. Do not use tools.', { signal: controller.signal });
|
|
679
|
-
if (!String(turn.finalResponse || '').trim()) throw new AgentRuntimeError('codex-empty-response', 'Codex returned an empty response.', { status: 502 });
|
|
680
|
-
return { ok: true, latencyMs: Date.now() - startedAt, threadId: thread.id || '' };
|
|
677
|
+
const thread = codex.startThread(codexThreadOptions(workspace));
|
|
678
|
+
const turn = await thread.run('Reply with the single word READY. Do not use tools.', { signal: controller.signal });
|
|
679
|
+
if (!String(turn.finalResponse || '').trim()) throw new AgentRuntimeError('codex-empty-response', 'Codex returned an empty response.', { status: 502 });
|
|
680
|
+
return { ok: true, latencyMs: Date.now() - startedAt, threadId: thread.id || '' };
|
|
681
681
|
} catch (error) {
|
|
682
682
|
throw normalizeCodexError(error);
|
|
683
683
|
} finally {
|
|
@@ -686,18 +686,18 @@ export function createCodexAgentRuntime({
|
|
|
686
686
|
}
|
|
687
687
|
|
|
688
688
|
function publicRun(run) {
|
|
689
|
-
return {
|
|
690
|
-
runId: run.runId,
|
|
691
|
-
status: run.status,
|
|
692
|
-
instruction: run.instruction,
|
|
689
|
+
return {
|
|
690
|
+
runId: run.runId,
|
|
691
|
+
status: run.status,
|
|
692
|
+
instruction: run.instruction,
|
|
693
693
|
threadId: run.threadId,
|
|
694
|
-
finalResponse: run.finalResponse,
|
|
695
|
-
error: run.error,
|
|
696
|
-
errorMessage: run.errorMessage,
|
|
697
|
-
errorStage: run.errorStage,
|
|
698
|
-
stage: run.stage,
|
|
699
|
-
progressMessage: run.progressMessage,
|
|
700
|
-
createdAt: run.createdAt,
|
|
694
|
+
finalResponse: run.finalResponse,
|
|
695
|
+
error: run.error,
|
|
696
|
+
errorMessage: run.errorMessage,
|
|
697
|
+
errorStage: run.errorStage,
|
|
698
|
+
stage: run.stage,
|
|
699
|
+
progressMessage: run.progressMessage,
|
|
700
|
+
createdAt: run.createdAt,
|
|
701
701
|
updatedAt: run.updatedAt,
|
|
702
702
|
toolCallCount: run.toolCallCount,
|
|
703
703
|
permissionMode: run.permissionPolicy?.mode || 'ask',
|
|
@@ -728,20 +728,20 @@ export function createCodexAgentRuntime({
|
|
|
728
728
|
'Never use arbitrary MCP servers, change permissions, forge approvals, request credentials, or invent a tool result.',
|
|
729
729
|
`The Hub has fixed this run to permission mode ${permissionPolicy?.mode || 'ask'} and enforces the policy independently of your instructions.`,
|
|
730
730
|
'Use only the selected connected device IDs below. If the Hub asks for user approval, wait for that approval result and do not work around it.',
|
|
731
|
-
'You may perform multiple safe read-only checks when the request requires a sequence. For example, find Clients missing a named process, then check a related service only on those Clients.',
|
|
731
|
+
'You may perform multiple safe read-only checks when the request requires a sequence. For example, find Clients missing a named process, then check a related service only on those Clients.',
|
|
732
732
|
`Selected device IDs: ${JSON.stringify(deviceIds)}`,
|
|
733
733
|
'Return a concise Korean or English summary grounded only in tool results. Do not invent results.',
|
|
734
734
|
`User request: ${safeText(instruction, 4000)}`
|
|
735
735
|
].join('\n');
|
|
736
736
|
}
|
|
737
737
|
|
|
738
|
-
async function execute(run, input) {
|
|
739
|
-
activeCount += 1;
|
|
740
|
-
let session;
|
|
741
|
-
let timeoutTimer;
|
|
742
|
-
try {
|
|
743
|
-
reportProgress(run, 'checking-codex', 'Checking Codex SDK installation and sign-in.');
|
|
744
|
-
assertSecurityConfiguration();
|
|
738
|
+
async function execute(run, input) {
|
|
739
|
+
activeCount += 1;
|
|
740
|
+
let session;
|
|
741
|
+
let timeoutTimer;
|
|
742
|
+
try {
|
|
743
|
+
reportProgress(run, 'checking-codex', 'Checking Codex SDK installation and sign-in.');
|
|
744
|
+
assertSecurityConfiguration();
|
|
745
745
|
const settings = await settingsStore.get();
|
|
746
746
|
const timeoutMs = Math.max(1000, Number(settings.codexTaskTimeoutMs) || 600000);
|
|
747
747
|
timeoutTimer = setTimeout(() => {
|
|
@@ -752,16 +752,16 @@ export function createCodexAgentRuntime({
|
|
|
752
752
|
timeoutTimer.unref?.();
|
|
753
753
|
await mkdir(workspace, { recursive: true });
|
|
754
754
|
if (activeCount > settings.maxConcurrentRequests) {
|
|
755
|
-
throw new AgentRuntimeError('agent-concurrency-limit', 'The LiveDesk Agent concurrency limit is reached.', { status: 429, retryable: true });
|
|
755
|
+
throw new AgentRuntimeError('agent-concurrency-limit', 'The LiveDesk Agent concurrency limit is reached.', { status: 429, retryable: true });
|
|
756
756
|
}
|
|
757
757
|
if (run.abortController.signal.aborted) {
|
|
758
758
|
if (run.abortReason === 'timeout') throw codexAbortError('codex-run-timeout', 'The Codex task exceeded its timeout.');
|
|
759
759
|
throw Object.assign(new Error('The Codex task was cancelled.'), { name: 'AbortError' });
|
|
760
760
|
}
|
|
761
|
-
const status = await getStatus();
|
|
762
|
-
if (!status.installed) throw new AgentRuntimeError('codex-sdk-not-installed', 'Codex SDK is not installed.', { status: 503 });
|
|
763
|
-
if (status.authenticated !== 'signed-in') throw new AgentRuntimeError('codex-auth-required', 'Sign in with the Codex CLI first.', { status: 401 });
|
|
764
|
-
reportProgress(run, 'preparing-tools', 'Codex is ready. Preparing the LiveDesk tools for this Client.');
|
|
761
|
+
const status = await getStatus();
|
|
762
|
+
if (!status.installed) throw new AgentRuntimeError('codex-sdk-not-installed', 'Codex SDK is not installed.', { status: 503 });
|
|
763
|
+
if (status.authenticated !== 'signed-in') throw new AgentRuntimeError('codex-auth-required', 'Sign in with the Codex CLI first.', { status: 401 });
|
|
764
|
+
reportProgress(run, 'preparing-tools', 'Codex is ready. Preparing the LiveDesk tools for this Client.');
|
|
765
765
|
session = createMcpSession({
|
|
766
766
|
runId: run.runId,
|
|
767
767
|
signal: run.abortController.signal,
|
|
@@ -787,120 +787,120 @@ export function createCodexAgentRuntime({
|
|
|
787
787
|
if (run.abortReason === 'timeout') throw codexAbortError('codex-run-timeout', 'The Codex task exceeded its timeout.');
|
|
788
788
|
throw Object.assign(new Error('The Codex task was cancelled.'), { name: 'AbortError' });
|
|
789
789
|
}
|
|
790
|
-
const threadOptions = codexThreadOptions(workspace);
|
|
791
|
-
run.status = 'running';
|
|
792
|
-
run.updatedAt = new Date().toISOString();
|
|
793
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
794
|
-
let turnCompleted = false;
|
|
795
|
-
try {
|
|
796
|
-
await prepareCodexHome({ preferGlobalAuth: attempt > 0 && run.authRecoveryRequested === true });
|
|
797
|
-
reportProgress(run, 'thinking', attempt === 0 ? 'Codex is analyzing the request.' : 'Codex is retrying the request with a refreshed runtime.');
|
|
798
|
-
const codex = await createCodexClient({ env: safeCodexEnv(codexHome), config: codexConfig({ mcpServerPath, mcpUrl: session.url, token: session.token, workspace }) });
|
|
799
|
-
const thread = attempt === 0 && input.resumeThreadId && settings.codexResumeSessions
|
|
800
|
-
? codex.resumeThread(String(input.resumeThreadId), threadOptions)
|
|
801
|
-
: codex.startThread(threadOptions);
|
|
802
|
-
const streamed = await thread.runStreamed(promptFor({ instruction: run.instruction, deviceIds: input.deviceIds, permissionPolicy: run.permissionPolicy }), { signal: run.abortController.signal });
|
|
803
|
-
for await (const event of streamed.events) {
|
|
804
|
-
emit(run, event);
|
|
805
|
-
if (event.type === 'item.started' && event.item?.type === 'mcp_tool_call') {
|
|
806
|
-
reportProgress(run, 'tool-running', `Running ${safeText(event.item.tool, 120) || 'a LiveDesk tool'}.`, {
|
|
807
|
-
toolName: safeText(event.item.tool, 120)
|
|
808
|
-
});
|
|
809
|
-
}
|
|
810
|
-
if (event.type === 'turn.started') {
|
|
811
|
-
run.turnCount += 1;
|
|
812
|
-
if (run.turnCount > settings.codexMaxTurns) {
|
|
813
|
-
run.abortReason = 'turn-limit';
|
|
814
|
-
run.abortController.abort();
|
|
815
|
-
session.cancel();
|
|
816
|
-
throw new AgentRuntimeError('codex-turn-limit-reached', 'Codex exceeded the LiveDesk turn limit.', { status: 409 });
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
if (run.toolCallCount > run.permissionPolicy.maxToolCalls) {
|
|
820
|
-
run.abortReason = 'tool-limit';
|
|
821
|
-
run.abortController.abort();
|
|
822
|
-
session.cancel();
|
|
823
|
-
throw new AgentRuntimeError('codex-tool-limit-reached', 'Codex exceeded the LiveDesk tool-call limit.', { status: 409 });
|
|
824
|
-
}
|
|
825
|
-
if (event.type === 'item.completed' && event.item?.type === 'mcp_tool_call' && event.item?.status === 'failed') {
|
|
826
|
-
throw new Error(safeText(event.item?.error?.message, 800) || `LiveDesk tool ${safeText(event.item?.tool, 120) || 'call'} failed.`);
|
|
827
|
-
}
|
|
828
|
-
if (event.type === 'item.completed' && event.item?.type === 'agent_message') run.finalResponse = safeText(event.item.text, 4000);
|
|
829
|
-
if (event.type === 'turn.completed') turnCompleted = true;
|
|
830
|
-
if (event.type === 'turn.failed') throw new Error(safeText(event.error?.message, 800) || 'Codex run failed.');
|
|
831
|
-
if (event.type === 'error') throw new Error(safeText(event.message, 800) || 'Codex run failed.');
|
|
832
|
-
}
|
|
833
|
-
if (!turnCompleted) {
|
|
834
|
-
throw new AgentRuntimeError(
|
|
835
|
-
'codex-incomplete-turn',
|
|
836
|
-
'Codex ended without a completed turn.',
|
|
837
|
-
{ status: 502 }
|
|
838
|
-
);
|
|
839
|
-
}
|
|
840
|
-
if (!safeText(run.finalResponse, 4000)) {
|
|
841
|
-
throw new AgentRuntimeError(
|
|
842
|
-
'codex-empty-response',
|
|
843
|
-
'Codex completed without a final response.',
|
|
844
|
-
{ status: 502 }
|
|
845
|
-
);
|
|
846
|
-
}
|
|
847
|
-
// Terminal state is externally observable. Publish it only after the
|
|
848
|
-
// complete event stream has drained and its final response is owned.
|
|
849
|
-
run.status = 'completed';
|
|
850
|
-
break;
|
|
851
|
-
} catch (error) {
|
|
852
|
-
const normalized = normalizeCodexError(error);
|
|
853
|
-
const retryBeforeTools = attempt === 0
|
|
854
|
-
&& run.toolCallCount === 0
|
|
855
|
-
&& run.abortController.signal.aborted !== true
|
|
856
|
-
&& normalized.retryable === true;
|
|
857
|
-
if (!retryBeforeTools) throw normalized;
|
|
858
|
-
run.authRecoveryRequested = ['codex-auth-stale', 'codex-auth-required'].includes(normalized.code);
|
|
859
|
-
run.finalResponse = '';
|
|
860
|
-
run.status = 'running';
|
|
861
|
-
reportProgress(
|
|
862
|
-
run,
|
|
863
|
-
'recovering',
|
|
864
|
-
`Codex did not start cleanly (${normalized.code}). Refreshing the runtime and retrying once.`,
|
|
865
|
-
{ errorCode: normalized.code, errorMessage: safeText(normalized.message, 500), attempt: 2 }
|
|
866
|
-
);
|
|
867
|
-
await new Promise(resolve => setTimeout(resolve, 250));
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
if (run.status !== 'completed') {
|
|
871
|
-
throw new AgentRuntimeError(
|
|
872
|
-
'codex-incomplete-turn',
|
|
873
|
-
'Codex did not reach a completed terminal state.',
|
|
874
|
-
{ status: 502 }
|
|
875
|
-
);
|
|
876
|
-
}
|
|
790
|
+
const threadOptions = codexThreadOptions(workspace);
|
|
791
|
+
run.status = 'running';
|
|
792
|
+
run.updatedAt = new Date().toISOString();
|
|
793
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
794
|
+
let turnCompleted = false;
|
|
795
|
+
try {
|
|
796
|
+
await prepareCodexHome({ preferGlobalAuth: attempt > 0 && run.authRecoveryRequested === true });
|
|
797
|
+
reportProgress(run, 'thinking', attempt === 0 ? 'Codex is analyzing the request.' : 'Codex is retrying the request with a refreshed runtime.');
|
|
798
|
+
const codex = await createCodexClient({ env: safeCodexEnv(codexHome), config: codexConfig({ mcpServerPath, mcpUrl: session.url, token: session.token, workspace }) });
|
|
799
|
+
const thread = attempt === 0 && input.resumeThreadId && settings.codexResumeSessions
|
|
800
|
+
? codex.resumeThread(String(input.resumeThreadId), threadOptions)
|
|
801
|
+
: codex.startThread(threadOptions);
|
|
802
|
+
const streamed = await thread.runStreamed(promptFor({ instruction: run.instruction, deviceIds: input.deviceIds, permissionPolicy: run.permissionPolicy }), { signal: run.abortController.signal });
|
|
803
|
+
for await (const event of streamed.events) {
|
|
804
|
+
emit(run, event);
|
|
805
|
+
if (event.type === 'item.started' && event.item?.type === 'mcp_tool_call') {
|
|
806
|
+
reportProgress(run, 'tool-running', `Running ${safeText(event.item.tool, 120) || 'a LiveDesk tool'}.`, {
|
|
807
|
+
toolName: safeText(event.item.tool, 120)
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
if (event.type === 'turn.started') {
|
|
811
|
+
run.turnCount += 1;
|
|
812
|
+
if (run.turnCount > settings.codexMaxTurns) {
|
|
813
|
+
run.abortReason = 'turn-limit';
|
|
814
|
+
run.abortController.abort();
|
|
815
|
+
session.cancel();
|
|
816
|
+
throw new AgentRuntimeError('codex-turn-limit-reached', 'Codex exceeded the LiveDesk turn limit.', { status: 409 });
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
if (run.toolCallCount > run.permissionPolicy.maxToolCalls) {
|
|
820
|
+
run.abortReason = 'tool-limit';
|
|
821
|
+
run.abortController.abort();
|
|
822
|
+
session.cancel();
|
|
823
|
+
throw new AgentRuntimeError('codex-tool-limit-reached', 'Codex exceeded the LiveDesk tool-call limit.', { status: 409 });
|
|
824
|
+
}
|
|
825
|
+
if (event.type === 'item.completed' && event.item?.type === 'mcp_tool_call' && event.item?.status === 'failed') {
|
|
826
|
+
throw new Error(safeText(event.item?.error?.message, 800) || `LiveDesk tool ${safeText(event.item?.tool, 120) || 'call'} failed.`);
|
|
827
|
+
}
|
|
828
|
+
if (event.type === 'item.completed' && event.item?.type === 'agent_message') run.finalResponse = safeText(event.item.text, 4000);
|
|
829
|
+
if (event.type === 'turn.completed') turnCompleted = true;
|
|
830
|
+
if (event.type === 'turn.failed') throw new Error(safeText(event.error?.message, 800) || 'Codex run failed.');
|
|
831
|
+
if (event.type === 'error') throw new Error(safeText(event.message, 800) || 'Codex run failed.');
|
|
832
|
+
}
|
|
833
|
+
if (!turnCompleted) {
|
|
834
|
+
throw new AgentRuntimeError(
|
|
835
|
+
'codex-incomplete-turn',
|
|
836
|
+
'Codex ended without a completed turn.',
|
|
837
|
+
{ status: 502 }
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
if (!safeText(run.finalResponse, 4000)) {
|
|
841
|
+
throw new AgentRuntimeError(
|
|
842
|
+
'codex-empty-response',
|
|
843
|
+
'Codex completed without a final response.',
|
|
844
|
+
{ status: 502 }
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
// Terminal state is externally observable. Publish it only after the
|
|
848
|
+
// complete event stream has drained and its final response is owned.
|
|
849
|
+
run.status = 'completed';
|
|
850
|
+
break;
|
|
851
|
+
} catch (error) {
|
|
852
|
+
const normalized = normalizeCodexError(error);
|
|
853
|
+
const retryBeforeTools = attempt === 0
|
|
854
|
+
&& run.toolCallCount === 0
|
|
855
|
+
&& run.abortController.signal.aborted !== true
|
|
856
|
+
&& normalized.retryable === true;
|
|
857
|
+
if (!retryBeforeTools) throw normalized;
|
|
858
|
+
run.authRecoveryRequested = ['codex-auth-stale', 'codex-auth-required'].includes(normalized.code);
|
|
859
|
+
run.finalResponse = '';
|
|
860
|
+
run.status = 'running';
|
|
861
|
+
reportProgress(
|
|
862
|
+
run,
|
|
863
|
+
'recovering',
|
|
864
|
+
`Codex did not start cleanly (${normalized.code}). Refreshing the runtime and retrying once.`,
|
|
865
|
+
{ errorCode: normalized.code, errorMessage: safeText(normalized.message, 500), attempt: 2 }
|
|
866
|
+
);
|
|
867
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
if (run.status !== 'completed') {
|
|
871
|
+
throw new AgentRuntimeError(
|
|
872
|
+
'codex-incomplete-turn',
|
|
873
|
+
'Codex did not reach a completed terminal state.',
|
|
874
|
+
{ status: 502 }
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
877
|
} catch (error) {
|
|
878
|
-
if (run.abortReason === 'timeout') {
|
|
879
|
-
run.status = 'failed';
|
|
880
|
-
run.error = 'codex-run-timeout';
|
|
881
|
-
run.errorMessage = 'The Codex task exceeded its timeout.';
|
|
882
|
-
run.errorStage = run.stage || 'unknown';
|
|
883
|
-
} else if (run.abortReason === 'tool-limit') {
|
|
884
|
-
run.status = 'failed';
|
|
885
|
-
run.error = 'codex-tool-limit-reached';
|
|
886
|
-
run.errorMessage = 'Codex exceeded the LiveDesk tool-call limit.';
|
|
887
|
-
run.errorStage = run.stage || 'unknown';
|
|
888
|
-
} else if (run.abortReason === 'turn-limit') {
|
|
889
|
-
run.status = 'failed';
|
|
890
|
-
run.error = 'codex-turn-limit-reached';
|
|
891
|
-
run.errorMessage = 'Codex exceeded the LiveDesk turn limit.';
|
|
892
|
-
run.errorStage = run.stage || 'unknown';
|
|
893
|
-
} else if (isAbortError(error) || run.abortController.signal.aborted) {
|
|
894
|
-
run.status = 'cancelled';
|
|
895
|
-
run.error = 'cancelled-by-user';
|
|
896
|
-
run.errorMessage = 'The Codex request was cancelled.';
|
|
897
|
-
run.errorStage = run.stage || 'unknown';
|
|
878
|
+
if (run.abortReason === 'timeout') {
|
|
879
|
+
run.status = 'failed';
|
|
880
|
+
run.error = 'codex-run-timeout';
|
|
881
|
+
run.errorMessage = 'The Codex task exceeded its timeout.';
|
|
882
|
+
run.errorStage = run.stage || 'unknown';
|
|
883
|
+
} else if (run.abortReason === 'tool-limit') {
|
|
884
|
+
run.status = 'failed';
|
|
885
|
+
run.error = 'codex-tool-limit-reached';
|
|
886
|
+
run.errorMessage = 'Codex exceeded the LiveDesk tool-call limit.';
|
|
887
|
+
run.errorStage = run.stage || 'unknown';
|
|
888
|
+
} else if (run.abortReason === 'turn-limit') {
|
|
889
|
+
run.status = 'failed';
|
|
890
|
+
run.error = 'codex-turn-limit-reached';
|
|
891
|
+
run.errorMessage = 'Codex exceeded the LiveDesk turn limit.';
|
|
892
|
+
run.errorStage = run.stage || 'unknown';
|
|
893
|
+
} else if (isAbortError(error) || run.abortController.signal.aborted) {
|
|
894
|
+
run.status = 'cancelled';
|
|
895
|
+
run.error = 'cancelled-by-user';
|
|
896
|
+
run.errorMessage = 'The Codex request was cancelled.';
|
|
897
|
+
run.errorStage = run.stage || 'unknown';
|
|
898
898
|
} else {
|
|
899
|
-
const normalized = normalizeCodexError(error);
|
|
900
|
-
run.status = 'failed';
|
|
901
|
-
run.error = safeText(normalized.code, 240) || 'codex-run-failed';
|
|
902
|
-
run.errorMessage = safeText(normalized.message, 800) || 'Codex run failed.';
|
|
903
|
-
run.errorStage = run.stage || 'unknown';
|
|
899
|
+
const normalized = normalizeCodexError(error);
|
|
900
|
+
run.status = 'failed';
|
|
901
|
+
run.error = safeText(normalized.code, 240) || 'codex-run-failed';
|
|
902
|
+
run.errorMessage = safeText(normalized.message, 800) || 'Codex run failed.';
|
|
903
|
+
run.errorStage = run.stage || 'unknown';
|
|
904
904
|
}
|
|
905
905
|
} finally {
|
|
906
906
|
activeCount = Math.max(0, activeCount - 1);
|
|
@@ -908,44 +908,44 @@ export function createCodexAgentRuntime({
|
|
|
908
908
|
try { session.cancel(); } catch { /* best effort */ }
|
|
909
909
|
destroyMcpSession(session.token);
|
|
910
910
|
}
|
|
911
|
-
clearTimeout(timeoutTimer);
|
|
912
|
-
run.updatedAt = new Date().toISOString();
|
|
913
|
-
reportProgress(
|
|
914
|
-
run,
|
|
915
|
-
run.status,
|
|
916
|
-
run.status === 'completed'
|
|
917
|
-
? 'Codex completed the request.'
|
|
918
|
-
: run.status === 'cancelled'
|
|
919
|
-
? 'The Codex request was cancelled.'
|
|
920
|
-
: `Codex stopped (${run.error || 'codex-run-failed'}): ${run.errorMessage || 'Codex run failed.'}`
|
|
921
|
-
);
|
|
922
|
-
if (isTerminalStatus(run.status)) scheduleRunCleanup(run);
|
|
911
|
+
clearTimeout(timeoutTimer);
|
|
912
|
+
run.updatedAt = new Date().toISOString();
|
|
913
|
+
reportProgress(
|
|
914
|
+
run,
|
|
915
|
+
run.status,
|
|
916
|
+
run.status === 'completed'
|
|
917
|
+
? 'Codex completed the request.'
|
|
918
|
+
: run.status === 'cancelled'
|
|
919
|
+
? 'The Codex request was cancelled.'
|
|
920
|
+
: `Codex stopped (${run.error || 'codex-run-failed'}): ${run.errorMessage || 'Codex run failed.'}`
|
|
921
|
+
);
|
|
922
|
+
if (isTerminalStatus(run.status)) scheduleRunCleanup(run);
|
|
923
923
|
}
|
|
924
924
|
}
|
|
925
925
|
|
|
926
926
|
async function start(input = {}) {
|
|
927
927
|
const instruction = safeText(input.instruction, 4000);
|
|
928
|
-
if (!instruction) throw new AgentRuntimeError('agent-invalid-instruction', 'Instruction is required.', { status: 400 });
|
|
928
|
+
if (!instruction) throw new AgentRuntimeError('agent-invalid-instruction', 'Instruction is required.', { status: 400 });
|
|
929
929
|
const deviceIds = safeAgentDeviceIds(input.deviceIds);
|
|
930
|
-
if (deviceIds.length === 0) throw new AgentRuntimeError('agent-no-target-devices', 'Select at least one connected device.', { status: 400 });
|
|
930
|
+
if (deviceIds.length === 0) throw new AgentRuntimeError('agent-no-target-devices', 'Select at least one connected device.', { status: 400 });
|
|
931
931
|
const settings = await settingsStore.get();
|
|
932
|
-
if (!settings.enabled) throw new AgentRuntimeError('agent-disabled', 'Codex Agent is disabled in Settings.', { status: 409 });
|
|
932
|
+
if (!settings.enabled) throw new AgentRuntimeError('agent-disabled', 'Codex Agent is disabled in Settings.', { status: 409 });
|
|
933
933
|
assertSecurityConfiguration();
|
|
934
934
|
pruneRuns();
|
|
935
|
-
if (runs.size >= MAX_RUNS) throw new AgentRuntimeError('agent-run-limit', 'The LiveDesk Agent run history is full.', { status: 429, retryable: true });
|
|
935
|
+
if (runs.size >= MAX_RUNS) throw new AgentRuntimeError('agent-run-limit', 'The LiveDesk Agent run history is full.', { status: 429, retryable: true });
|
|
936
936
|
const permissionPolicy = freezeAgentPermissionPolicy(input.permissionPolicy && typeof input.permissionPolicy === 'object'
|
|
937
937
|
? input.permissionPolicy
|
|
938
938
|
: createAgentPermissionPolicy({ mode: input.permissionMode || 'safe-auto', deviceIds, maxToolCalls: settings.codexMaxToolCalls }));
|
|
939
|
-
const run = {
|
|
940
|
-
runId: crypto.randomUUID(),
|
|
941
|
-
status: 'queued',
|
|
942
|
-
instruction,
|
|
943
|
-
deviceIds,
|
|
944
|
-
threadId: '',
|
|
945
|
-
finalResponse: '',
|
|
946
|
-
error: '',
|
|
947
|
-
errorMessage: '',
|
|
948
|
-
errorStage: '',
|
|
939
|
+
const run = {
|
|
940
|
+
runId: crypto.randomUUID(),
|
|
941
|
+
status: 'queued',
|
|
942
|
+
instruction,
|
|
943
|
+
deviceIds,
|
|
944
|
+
threadId: '',
|
|
945
|
+
finalResponse: '',
|
|
946
|
+
error: '',
|
|
947
|
+
errorMessage: '',
|
|
948
|
+
errorStage: '',
|
|
949
949
|
createdAt: new Date().toISOString(),
|
|
950
950
|
updatedAt: new Date().toISOString(),
|
|
951
951
|
toolCallCount: 0,
|
|
@@ -955,15 +955,15 @@ export function createCodexAgentRuntime({
|
|
|
955
955
|
batchIds: new Set(),
|
|
956
956
|
events: [],
|
|
957
957
|
abortController: new AbortController(),
|
|
958
|
-
abortReason: '',
|
|
959
|
-
authRecoveryRequested: false,
|
|
960
|
-
stage: 'queued',
|
|
961
|
-
progressMessage: '',
|
|
962
|
-
mcpToken: ''
|
|
963
|
-
};
|
|
964
|
-
runs.set(run.runId, run);
|
|
965
|
-
reportProgress(run, 'queued', 'Codex request queued on the Hub.');
|
|
966
|
-
void execute(run, { deviceIds, resumeThreadId: input.resumeThreadId });
|
|
958
|
+
abortReason: '',
|
|
959
|
+
authRecoveryRequested: false,
|
|
960
|
+
stage: 'queued',
|
|
961
|
+
progressMessage: '',
|
|
962
|
+
mcpToken: ''
|
|
963
|
+
};
|
|
964
|
+
runs.set(run.runId, run);
|
|
965
|
+
reportProgress(run, 'queued', 'Codex request queued on the Hub.');
|
|
966
|
+
void execute(run, { deviceIds, resumeThreadId: input.resumeThreadId });
|
|
967
967
|
return publicRun(run);
|
|
968
968
|
}
|
|
969
969
|
|