livedesk 0.1.210 → 0.1.211

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.
@@ -1,604 +1,604 @@
1
- import crypto from 'node:crypto';
2
- import { access, link, mkdir } from 'node:fs/promises';
3
- import os from 'node:os';
4
- import path from 'node:path';
5
- import { spawn } from 'node:child_process';
6
- import { createRequire } from 'node:module';
7
- import { AgentProviderError } from './provider-errors.js';
8
- import { AGENT_PROVIDER_CODEX } from './agent-settings.js';
9
- import { AGENT_TOOL_NAMES } from './agent-tool-registry.js';
10
- import { createAgentPermissionPolicy, hashAgentPermissionPolicy } from './agent-permissions.js';
11
-
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 SAFE_ENV_KEYS = [
17
- 'Path',
18
- 'PATH',
19
- 'SystemRoot',
20
- 'SYSTEMROOT',
21
- 'ComSpec',
22
- 'TEMP',
23
- 'TMP',
24
- 'WINDIR',
25
- 'LANG',
26
- 'LC_ALL',
27
- 'TMPDIR'
28
- ];
29
- const LIVEDESK_MCP_TOOLS = AGENT_TOOL_NAMES;
30
-
31
- function safeText(value, max = 2400) {
32
- return String(value || '').replace(/[\0\r]/g, ' ').trim().slice(0, max);
33
- }
34
-
35
- function publicEvent(event) {
36
- const item = event?.item;
37
- return {
38
- type: String(event?.type || '').slice(0, 80),
39
- threadId: safeText(event?.thread_id, 120),
40
- item: item ? {
41
- id: safeText(item.id, 120),
42
- type: safeText(item.type, 80),
43
- server: safeText(item.server, 80),
44
- tool: safeText(item.tool, 120),
45
- status: safeText(item.status, 40),
46
- text: safeText(item.text, 600),
47
- error: safeText(item.error?.message, 400)
48
- } : undefined,
49
- message: safeText(event?.message || event?.error?.message, 500)
50
- };
51
- }
52
-
53
- function isAbortError(error) {
54
- return error?.name === 'AbortError'
55
- || error?.code === 'ABORT_ERR'
56
- || /aborted|abort/i.test(String(error?.message || ''));
57
- }
58
-
59
- function normalizeCodexError(error) {
60
- if (error instanceof AgentProviderError) return error;
61
- const message = safeText(error?.message, 800) || 'Codex run failed.';
62
- if (/out of credits|usage limit|rate limit|too many requests/i.test(message)) return new AgentProviderError('codex-usage-limit-reached', 'The Codex workspace has no remaining usage.', { status: 402, retryable: true });
63
- if (/not authenticated|not logged in|authentication|unauthorized|login required/i.test(message)) return new AgentProviderError('codex-auth-required', 'Sign in with the Codex CLI first.', { status: 401 });
64
- return new AgentProviderError('codex-run-failed', message, { status: 502 });
65
- }
66
-
67
- function codexThreadOptions(settings, workspace) {
68
- return {
69
- model: settings.codexModelId || undefined,
70
- sandboxMode: 'read-only',
71
- approvalPolicy: 'never',
72
- workingDirectory: workspace,
73
- skipGitRepoCheck: true,
74
- modelReasoningEffort: settings.codexReasoningEffort,
75
- networkAccessEnabled: false,
76
- webSearchMode: 'disabled',
77
- webSearchEnabled: false,
78
- additionalDirectories: []
79
- };
80
- }
81
-
82
- function baseCodexConfig() {
83
- return {
84
- approval_policy: 'never',
85
- sandbox_mode: 'read-only',
86
- web_search: 'disabled',
87
- features: {
88
- shell_tool: false,
89
- unified_exec: false,
90
- shell_snapshot: false,
91
- apps: false,
92
- multi_agent: false,
93
- web_search: false,
94
- skill_mcp_dependency_install: false,
95
- memories: false,
96
- plugins: false,
97
- remote_plugin: false
98
- },
99
- tools: {
100
- web_search: false,
101
- view_image: false
102
- },
103
- apps: {
104
- _default: { enabled: false }
105
- },
106
- plugins: {},
107
- mcp_servers: {}
108
- };
109
- }
110
-
111
- function codexConfig({ mcpServerPath, mcpUrl, token, workspace }) {
112
- const config = baseCodexConfig();
113
- config.mcp_servers = {
114
- livedesk: {
115
- command: process.execPath,
116
- args: [mcpServerPath],
117
- cwd: workspace,
118
- env: {
119
- LIVEDESK_AGENT_MCP_URL: mcpUrl,
120
- LIVEDESK_AGENT_MCP_TOKEN: token
121
- },
122
- enabled: true,
123
- required: true,
124
- enabled_tools: LIVEDESK_MCP_TOOLS,
125
- startup_timeout_sec: 10,
126
- tool_timeout_sec: 150
127
- }
128
- };
129
- return config;
130
- }
131
-
132
- function safeCodexEnv(codexHome) {
133
- const env = {
134
- CODEX_HOME: codexHome,
135
- HOME: codexHome,
136
- USERPROFILE: codexHome,
137
- APPDATA: path.join(codexHome, 'AppData', 'Roaming'),
138
- LOCALAPPDATA: path.join(codexHome, 'AppData', 'Local')
139
- };
140
- for (const key of SAFE_ENV_KEYS) {
141
- const value = process.env[key];
142
- if (value !== undefined && value !== '') env[key] = value;
143
- }
144
- if (!env.Path && env.PATH) env.Path = env.PATH;
145
- if (!env.PATH && env.Path) env.PATH = env.Path;
146
- return env;
147
- }
148
-
149
- async function ensureCodexHome(codexHome) {
150
- const resolvedHome = path.resolve(codexHome);
151
- const globalHome = path.resolve(os.homedir(), '.codex');
152
- if (!resolvedHome || resolvedHome === globalHome) {
153
- throw new AgentProviderError('codex-isolation-unavailable', 'Codex requires a dedicated LiveDesk home.', { status: 503 });
154
- }
155
- try {
156
- await mkdir(resolvedHome, { recursive: true, mode: 0o700 });
157
- if (await access(path.join(resolvedHome, 'config.toml')).then(() => true, () => false)) {
158
- throw new AgentProviderError('codex-environment-isolation-failed', 'The dedicated Codex home contains an unmanaged config.toml.', { status: 503 });
159
- }
160
- } catch (error) {
161
- if (error instanceof AgentProviderError) throw error;
162
- const wrapped = new AgentProviderError('codex-environment-isolation-failed', 'The dedicated Codex home could not be prepared.', { status: 503 });
163
- wrapped.cause = error;
164
- throw wrapped;
165
- }
166
- const targetAuthPath = path.join(resolvedHome, 'auth.json');
167
- try {
168
- await access(targetAuthPath);
169
- return;
170
- } catch {
171
- // Keep the SDK isolated from the user's global config while reusing the
172
- // CLI-owned auth file when the user is already signed in. The runtime
173
- // never reads or copies the credential contents.
174
- }
175
- const sourceAuthPath = path.join(os.homedir(), '.codex', 'auth.json');
176
- if (!(await access(sourceAuthPath).then(() => true, () => false))) return;
177
- try {
178
- await link(sourceAuthPath, targetAuthPath);
179
- } catch (error) {
180
- const wrapped = new AgentProviderError('codex-environment-isolation-failed', 'The Codex authentication bridge could not be prepared.', { status: 503 });
181
- wrapped.cause = error;
182
- throw wrapped;
183
- }
184
- }
185
-
186
- function runCli(cliPath, args, timeoutMs = 5000, env = process.env) {
187
- return new Promise((resolve, reject) => {
188
- const child = spawn(process.execPath, [cliPath, ...args], {
189
- env,
190
- windowsHide: true,
191
- stdio: ['ignore', 'pipe', 'pipe']
192
- });
193
- let stdout = '';
194
- let stderr = '';
195
- const timer = setTimeout(() => {
196
- child.kill();
197
- reject(Object.assign(new Error('Codex CLI status check timed out.'), { code: 'codex-status-timeout' }));
198
- }, timeoutMs);
199
- child.stdout.setEncoding('utf8');
200
- child.stderr.setEncoding('utf8');
201
- child.stdout.on('data', chunk => { stdout += chunk; });
202
- child.stderr.on('data', chunk => { stderr += chunk; });
203
- child.once('error', error => {
204
- clearTimeout(timer);
205
- reject(error);
206
- });
207
- child.once('close', code => {
208
- clearTimeout(timer);
209
- resolve({ code, stdout: stdout.trim(), stderr: stderr.trim() });
210
- });
211
- });
212
- }
213
-
214
- function isTerminalStatus(status) {
215
- return ['completed', 'failed', 'cancelled'].includes(status);
216
- }
217
-
218
- function runAgeMs(run) {
219
- return Math.max(0, Date.now() - Date.parse(run.updatedAt || run.createdAt || 0));
220
- }
221
-
222
- function codexAbortError(code, message) {
223
- return new AgentProviderError(code, message, { status: code === 'codex-run-timeout' ? 504 : 409, retryable: code === 'codex-run-timeout' });
224
- }
225
-
226
- function safeAgentDeviceIds(value) {
227
- return [...new Set((Array.isArray(value) ? value : [])
228
- .map(item => String(item || '').trim())
229
- .filter(Boolean))].slice(0, 500);
230
- }
231
-
232
- function freezeAgentPermissionPolicy(policy) {
233
- const cloned = policy && typeof policy === 'object'
234
- ? JSON.parse(JSON.stringify(policy))
235
- : createAgentPermissionPolicy({ mode: 'ask' });
236
- if (cloned.categories && typeof cloned.categories === 'object') Object.freeze(cloned.categories);
237
- return Object.freeze(cloned);
238
- }
239
-
240
- export function createCodexAgentRuntime({
241
- settingsStore,
242
- dataDir = path.join(os.homedir(), '.livedesk'),
243
- mcpServerPath,
244
- createMcpSession,
245
- destroyMcpSession,
246
- createCodexClient: injectedCodexClient,
247
- getCodexStatus: injectedCodexStatus
248
- } = {}) {
249
- const runs = new Map();
250
- let activeCount = 0;
251
- let codexModulePromise;
252
- let securityVerificationState = 'configured';
253
- const workspace = path.resolve(dataDir, 'agent-workspace');
254
- const codexHome = path.resolve(dataDir, 'codex-home');
255
-
256
- function securityStatus() {
257
- const configured = codexHome !== path.resolve(os.homedir(), '.codex')
258
- && typeof createMcpSession === 'function'
259
- && Boolean(mcpServerPath);
260
- return {
261
- isolatedCodexHome: codexHome !== path.resolve(os.homedir(), '.codex'),
262
- restrictedEnvironment: true,
263
- livedeskMcpOnly: typeof createMcpSession === 'function' && Boolean(mcpServerPath),
264
- selectedDeviceScopeEnforced: typeof createMcpSession === 'function',
265
- failClosed: true,
266
- verified: configured && securityVerificationState === 'verified',
267
- state: configured ? securityVerificationState : 'unavailable'
268
- };
269
- }
270
-
271
- async function prepareCodexHome() {
272
- try {
273
- await ensureCodexHome(codexHome);
274
- securityVerificationState = 'verified';
275
- } catch (error) {
276
- securityVerificationState = 'unavailable';
277
- throw error;
278
- }
279
- }
280
-
281
- function assertSecurityConfiguration() {
282
- const status = securityStatus();
283
- if (!status.isolatedCodexHome || !status.restrictedEnvironment || !status.livedeskMcpOnly || !status.selectedDeviceScopeEnforced) {
284
- throw new AgentProviderError('codex-unsupported-security-config', 'Codex security isolation is not available.', { status: 503 });
285
- }
286
- }
287
-
288
- async function createCodexClient(options) {
289
- if (typeof injectedCodexClient === 'function') return injectedCodexClient(options);
290
- const { Codex } = await loadCodex();
291
- return new Codex(options);
292
- }
293
-
294
- function scheduleRunCleanup(run) {
295
- const timer = setTimeout(() => {
296
- if (runs.get(run.runId) === run && isTerminalStatus(run.status) && runAgeMs(run) >= RUN_TTL_MS) {
297
- runs.delete(run.runId);
298
- }
299
- }, RUN_TTL_MS + 1000);
300
- timer.unref?.();
301
- }
302
-
303
- function pruneRuns() {
304
- for (const [runId, run] of runs) {
305
- if (isTerminalStatus(run.status) && runAgeMs(run) >= RUN_TTL_MS) runs.delete(runId);
306
- }
307
- if (runs.size < MAX_RUNS) return;
308
- for (const [runId, run] of runs) {
309
- if (!isTerminalStatus(run.status)) continue;
310
- runs.delete(runId);
311
- if (runs.size < MAX_RUNS) break;
312
- }
313
- }
314
-
315
- async function loadCodex() {
316
- if (!codexModulePromise) codexModulePromise = import('@openai/codex-sdk').catch(error => {
317
- const wrapped = new AgentProviderError('codex-sdk-not-installed', 'Codex SDK is not installed in this LiveDesk package.', { status: 503 });
318
- wrapped.cause = error;
319
- throw wrapped;
320
- });
321
- return codexModulePromise;
322
- }
323
-
324
- function cliPath() {
325
- try {
326
- return require.resolve('@openai/codex/bin/codex.js');
327
- } catch {
328
- return '';
329
- }
330
- }
331
-
332
- async function getStatus() {
333
- if (typeof injectedCodexStatus === 'function') return injectedCodexStatus();
334
- const pathToCli = cliPath();
335
- if (!pathToCli) {
336
- return { installed: false, authenticated: 'unknown', status: 'not-installed', codexPath: '' };
337
- }
338
- let authenticated = 'unknown';
339
- let detail = '';
340
- try {
341
- await prepareCodexHome();
342
- const result = await runCli(pathToCli, ['login', 'status'], 5000, safeCodexEnv(codexHome));
343
- detail = safeText(result.stdout || result.stderr, 300);
344
- authenticated = /logged in|authenticated|chatgpt/i.test(`${result.stdout}\n${result.stderr}`)
345
- ? 'signed-in'
346
- : 'not-signed-in';
347
- } catch (error) {
348
- if (error instanceof AgentProviderError) throw error;
349
- detail = safeText(error?.message, 300);
350
- }
351
- return { installed: true, authenticated, status: authenticated === 'signed-in' ? 'ready' : 'auth-required', codexPath: pathToCli, detail };
352
- }
353
-
354
- async function testConnection() {
355
- assertSecurityConfiguration();
356
- const startedAt = Date.now();
357
- const status = await getStatus();
358
- if (!status.installed) throw new AgentProviderError('codex-sdk-not-installed', 'Codex SDK is not installed.', { status: 503 });
359
- if (status.authenticated !== 'signed-in') throw new AgentProviderError('codex-auth-required', 'Sign in with the Codex CLI first.', { status: 401 });
360
- const settings = await settingsStore.get();
361
- await mkdir(workspace, { recursive: true });
362
- await prepareCodexHome();
363
- const codex = await createCodexClient({ env: safeCodexEnv(codexHome), config: baseCodexConfig() });
364
- const controller = new AbortController();
365
- const timer = setTimeout(() => controller.abort(), Math.min(30000, settings.codexTaskTimeoutMs));
366
- try {
367
- const thread = codex.startThread(codexThreadOptions(settings, workspace));
368
- const turn = await thread.run('Reply with the single word READY. Do not use tools.', { signal: controller.signal });
369
- if (!String(turn.finalResponse || '').trim()) throw new AgentProviderError('codex-empty-response', 'Codex returned an empty response.', { status: 502 });
370
- return { ok: true, modelId: settings.codexModelId || 'Codex default', modelName: settings.codexModelId || 'Codex default', latencyMs: Date.now() - startedAt, threadId: thread.id || '' };
371
- } catch (error) {
372
- throw normalizeCodexError(error);
373
- } finally {
374
- clearTimeout(timer);
375
- }
376
- }
377
-
378
- function publicRun(run) {
379
- return {
380
- runId: run.runId,
381
- status: run.status,
382
- provider: AGENT_PROVIDER_CODEX,
383
- instruction: run.instruction,
384
- threadId: run.threadId,
385
- finalResponse: run.finalResponse,
386
- error: run.error,
387
- createdAt: run.createdAt,
388
- updatedAt: run.updatedAt,
389
- toolCallCount: run.toolCallCount,
390
- permissionMode: run.permissionPolicy?.mode || 'ask',
391
- permissionPolicy: run.permissionPolicy,
392
- permissionPolicyHash: run.permissionPolicyHash,
393
- batchIds: [...run.batchIds],
394
- events: run.events.slice(-MAX_EVENTS)
395
- };
396
- }
397
-
398
- function emit(run, event) {
399
- run.events.push(publicEvent(event));
400
- if (run.events.length > MAX_EVENTS) run.events.splice(0, run.events.length - MAX_EVENTS);
401
- run.updatedAt = new Date().toISOString();
402
- if (event?.type === 'thread.started') run.threadId = safeText(event.thread_id, 120);
403
- if (event?.type === 'item.started' || event?.type === 'item.updated' || event?.type === 'item.completed') {
404
- if (event.item?.type === 'mcp_tool_call') {
405
- run.toolCallCount += event.type === 'item.started' ? 1 : 0;
406
- if (event.item?.result?.structured_content?.batchId) run.batchIds.add(event.item.result.structured_content.batchId);
407
- }
408
- }
409
- }
410
-
411
- function promptFor({ instruction, deviceIds, permissionPolicy }) {
412
- return [
413
- 'You are the LiveDesk Agent orchestrator.',
414
- 'Use only the registered livedesk.* MCP tools exposed by the LiveDesk server.',
415
- 'Never use arbitrary MCP servers, change permissions, forge approvals, request credentials, or invent a tool result.',
416
- `The Hub has fixed this run to permission mode ${permissionPolicy?.mode || 'ask'} and enforces the policy independently of your instructions.`,
417
- '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.',
418
- 'You may perform multiple safe read-only checks when the request requires a sequence. For example, find workstations without ComfyUI, then check the service status only on those workstations.',
419
- `Selected device IDs: ${JSON.stringify(deviceIds)}`,
420
- 'Return a concise Korean or English summary grounded only in tool results. Do not invent results.',
421
- `User request: ${safeText(instruction, 4000)}`
422
- ].join('\n');
423
- }
424
-
425
- async function execute(run, input) {
426
- activeCount += 1;
427
- let session;
428
- let timeoutTimer;
429
- try {
430
- assertSecurityConfiguration();
431
- const settings = await settingsStore.get();
432
- const timeoutMs = Math.max(1000, Number(settings.codexTaskTimeoutMs) || 600000);
433
- timeoutTimer = setTimeout(() => {
434
- run.abortReason = 'timeout';
435
- run.abortController.abort();
436
- try { session?.cancel(); } catch { /* best effort */ }
437
- }, timeoutMs);
438
- timeoutTimer.unref?.();
439
- await mkdir(workspace, { recursive: true });
440
- if (activeCount > settings.maxConcurrentRequests) {
441
- throw new AgentProviderError('agent-concurrency-limit', 'The LiveDesk Agent concurrency limit is reached.', { status: 429, retryable: true });
442
- }
443
- if (run.abortController.signal.aborted) {
444
- if (run.abortReason === 'timeout') throw codexAbortError('codex-run-timeout', 'The Codex task exceeded its timeout.');
445
- throw Object.assign(new Error('The Codex task was cancelled.'), { name: 'AbortError' });
446
- }
447
- const status = await getStatus();
448
- if (!status.installed) throw new AgentProviderError('codex-sdk-not-installed', 'Codex SDK is not installed.', { status: 503 });
449
- if (status.authenticated !== 'signed-in') throw new AgentProviderError('codex-auth-required', 'Sign in with the Codex CLI first.', { status: 401 });
450
- session = createMcpSession({
451
- runId: run.runId,
452
- signal: run.abortController.signal,
453
- allowedDeviceIds: input.deviceIds,
454
- maxToolCalls: run.permissionPolicy.maxToolCalls,
455
- permissionPolicy: run.permissionPolicy,
456
- onApprovalRequired: () => {
457
- if (run.status === 'running') {
458
- run.status = 'waiting-approval';
459
- run.updatedAt = new Date().toISOString();
460
- }
461
- },
462
- onApprovalResolved: () => {
463
- if (run.status === 'waiting-approval') {
464
- run.status = 'running';
465
- run.updatedAt = new Date().toISOString();
466
- }
467
- }
468
- });
469
- run.mcpToken = session.token;
470
- if (run.abortController.signal.aborted) {
471
- session.cancel();
472
- if (run.abortReason === 'timeout') throw codexAbortError('codex-run-timeout', 'The Codex task exceeded its timeout.');
473
- throw Object.assign(new Error('The Codex task was cancelled.'), { name: 'AbortError' });
474
- }
475
- await prepareCodexHome();
476
- const codex = await createCodexClient({ env: safeCodexEnv(codexHome), config: codexConfig({ mcpServerPath, mcpUrl: session.url, token: session.token, workspace }) });
477
- const threadOptions = codexThreadOptions(settings, workspace);
478
- const thread = input.resumeThreadId && settings.codexResumeSessions
479
- ? codex.resumeThread(String(input.resumeThreadId), threadOptions)
480
- : codex.startThread(threadOptions);
481
- run.status = 'running';
482
- run.updatedAt = new Date().toISOString();
483
- const streamed = await thread.runStreamed(promptFor({ instruction: run.instruction, deviceIds: input.deviceIds, permissionPolicy: run.permissionPolicy }), { signal: run.abortController.signal });
484
- for await (const event of streamed.events) {
485
- emit(run, event);
486
- if (event.type === 'turn.started') {
487
- run.turnCount += 1;
488
- if (run.turnCount > settings.codexMaxTurns) {
489
- run.abortReason = 'turn-limit';
490
- run.abortController.abort();
491
- session.cancel();
492
- throw new AgentProviderError('codex-turn-limit-reached', 'Codex exceeded the LiveDesk turn limit.', { status: 409 });
493
- }
494
- }
495
- if (run.toolCallCount > run.permissionPolicy.maxToolCalls) {
496
- run.abortReason = 'tool-limit';
497
- run.abortController.abort();
498
- session.cancel();
499
- throw new AgentProviderError('codex-tool-limit-reached', 'Codex exceeded the LiveDesk tool-call limit.', { status: 409 });
500
- }
501
- if (event.type === 'item.completed' && event.item?.type === 'agent_message') run.finalResponse = safeText(event.item.text, 4000);
502
- if (event.type === 'turn.completed') run.status = 'completed';
503
- if (event.type === 'turn.failed') throw new AgentProviderError('codex-run-failed', safeText(event.error?.message, 800) || 'Codex run failed.', { status: 502 });
504
- if (event.type === 'error') throw new AgentProviderError('codex-run-failed', safeText(event.message, 800) || 'Codex run failed.', { status: 502 });
505
- }
506
- if (run.status === 'running') run.status = 'completed';
507
- } catch (error) {
508
- if (run.abortReason === 'timeout') {
509
- run.status = 'failed';
510
- run.error = 'codex-run-timeout';
511
- } else if (run.abortReason === 'tool-limit') {
512
- run.status = 'failed';
513
- run.error = 'codex-tool-limit-reached';
514
- } else if (run.abortReason === 'turn-limit') {
515
- run.status = 'failed';
516
- run.error = 'codex-turn-limit-reached';
517
- } else if (isAbortError(error) || run.abortController.signal.aborted) {
518
- run.status = 'cancelled';
519
- run.error = 'cancelled-by-user';
520
- } else {
521
- const normalized = normalizeCodexError(error);
522
- run.status = 'failed';
523
- run.error = safeText(normalized.code || normalized.message, 240) || 'codex-run-failed';
524
- }
525
- } finally {
526
- activeCount = Math.max(0, activeCount - 1);
527
- if (session) {
528
- try { session.cancel(); } catch { /* best effort */ }
529
- destroyMcpSession(session.token);
530
- }
531
- clearTimeout(timeoutTimer);
532
- run.updatedAt = new Date().toISOString();
533
- if (isTerminalStatus(run.status)) scheduleRunCleanup(run);
534
- }
535
- }
536
-
537
- async function start(input = {}) {
538
- const instruction = safeText(input.instruction, 4000);
539
- if (!instruction) throw new AgentProviderError('agent-invalid-instruction', 'Instruction is required.', { status: 400 });
540
- const deviceIds = safeAgentDeviceIds(input.deviceIds);
541
- if (deviceIds.length === 0) throw new AgentProviderError('agent-no-target-devices', 'Select at least one connected device.', { status: 400 });
542
- const settings = await settingsStore.get();
543
- if (!settings.enabled) throw new AgentProviderError('agent-ai-disabled', 'Agent AI is disabled in Settings.', { status: 409 });
544
- assertSecurityConfiguration();
545
- pruneRuns();
546
- if (runs.size >= MAX_RUNS) throw new AgentProviderError('agent-run-limit', 'The LiveDesk Agent run history is full.', { status: 429, retryable: true });
547
- const permissionPolicy = freezeAgentPermissionPolicy(input.permissionPolicy && typeof input.permissionPolicy === 'object'
548
- ? input.permissionPolicy
549
- : createAgentPermissionPolicy({ mode: input.permissionMode || 'safe-auto', deviceIds, maxToolCalls: settings.codexMaxToolCalls }));
550
- const run = {
551
- runId: crypto.randomUUID(),
552
- status: 'queued',
553
- provider: AGENT_PROVIDER_CODEX,
554
- instruction,
555
- threadId: '',
556
- finalResponse: '',
557
- error: '',
558
- createdAt: new Date().toISOString(),
559
- updatedAt: new Date().toISOString(),
560
- toolCallCount: 0,
561
- turnCount: 0,
562
- permissionPolicy,
563
- permissionPolicyHash: hashAgentPermissionPolicy(permissionPolicy),
564
- batchIds: new Set(),
565
- events: [],
566
- abortController: new AbortController(),
567
- abortReason: '',
568
- mcpToken: ''
569
- };
570
- runs.set(run.runId, run);
571
- void execute(run, { deviceIds, resumeThreadId: input.resumeThreadId });
572
- return publicRun(run);
573
- }
574
-
575
- function get(runId) {
576
- const run = runs.get(String(runId || ''));
577
- return run ? publicRun(run) : null;
578
- }
579
-
580
- function cancel(runId) {
581
- const run = runs.get(String(runId || ''));
582
- if (!run) return null;
583
- if (isTerminalStatus(run.status)) return publicRun(run);
584
- run.abortReason = 'user';
585
- run.abortController.abort();
586
- run.status = 'cancelled';
587
- run.error = 'cancelled-by-user';
588
- run.updatedAt = new Date().toISOString();
589
- return publicRun(run);
590
- }
591
-
592
- function cancelAll() {
593
- let cancelled = 0;
594
- for (const run of runs.values()) {
595
- if (!isTerminalStatus(run.status)) {
596
- cancel(run.runId);
597
- cancelled += 1;
598
- }
599
- }
600
- return cancelled;
601
- }
602
-
603
- return { getStatus, testConnection, start, get, cancel, cancelAll, workspace, codexHome, getSecurityStatus: securityStatus };
604
- }
1
+ import crypto from 'node:crypto';
2
+ import { access, link, mkdir } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { spawn } from 'node:child_process';
6
+ import { createRequire } from 'node:module';
7
+ import { AgentProviderError } from './provider-errors.js';
8
+ import { AGENT_PROVIDER_CODEX } from './agent-settings.js';
9
+ import { AGENT_TOOL_NAMES } from './agent-tool-registry.js';
10
+ import { createAgentPermissionPolicy, hashAgentPermissionPolicy } from './agent-permissions.js';
11
+
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 SAFE_ENV_KEYS = [
17
+ 'Path',
18
+ 'PATH',
19
+ 'SystemRoot',
20
+ 'SYSTEMROOT',
21
+ 'ComSpec',
22
+ 'TEMP',
23
+ 'TMP',
24
+ 'WINDIR',
25
+ 'LANG',
26
+ 'LC_ALL',
27
+ 'TMPDIR'
28
+ ];
29
+ const LIVEDESK_MCP_TOOLS = AGENT_TOOL_NAMES;
30
+
31
+ function safeText(value, max = 2400) {
32
+ return String(value || '').replace(/[\0\r]/g, ' ').trim().slice(0, max);
33
+ }
34
+
35
+ function publicEvent(event) {
36
+ const item = event?.item;
37
+ return {
38
+ type: String(event?.type || '').slice(0, 80),
39
+ threadId: safeText(event?.thread_id, 120),
40
+ item: item ? {
41
+ id: safeText(item.id, 120),
42
+ type: safeText(item.type, 80),
43
+ server: safeText(item.server, 80),
44
+ tool: safeText(item.tool, 120),
45
+ status: safeText(item.status, 40),
46
+ text: safeText(item.text, 600),
47
+ error: safeText(item.error?.message, 400)
48
+ } : undefined,
49
+ message: safeText(event?.message || event?.error?.message, 500)
50
+ };
51
+ }
52
+
53
+ function isAbortError(error) {
54
+ return error?.name === 'AbortError'
55
+ || error?.code === 'ABORT_ERR'
56
+ || /aborted|abort/i.test(String(error?.message || ''));
57
+ }
58
+
59
+ function normalizeCodexError(error) {
60
+ if (error instanceof AgentProviderError) return error;
61
+ const message = safeText(error?.message, 800) || 'Codex run failed.';
62
+ if (/out of credits|usage limit|rate limit|too many requests/i.test(message)) return new AgentProviderError('codex-usage-limit-reached', 'The Codex workspace has no remaining usage.', { status: 402, retryable: true });
63
+ if (/not authenticated|not logged in|authentication|unauthorized|login required/i.test(message)) return new AgentProviderError('codex-auth-required', 'Sign in with the Codex CLI first.', { status: 401 });
64
+ return new AgentProviderError('codex-run-failed', message, { status: 502 });
65
+ }
66
+
67
+ function codexThreadOptions(settings, workspace) {
68
+ return {
69
+ model: settings.codexModelId || undefined,
70
+ sandboxMode: 'read-only',
71
+ approvalPolicy: 'never',
72
+ workingDirectory: workspace,
73
+ skipGitRepoCheck: true,
74
+ modelReasoningEffort: settings.codexReasoningEffort,
75
+ networkAccessEnabled: false,
76
+ webSearchMode: 'disabled',
77
+ webSearchEnabled: false,
78
+ additionalDirectories: []
79
+ };
80
+ }
81
+
82
+ function baseCodexConfig() {
83
+ return {
84
+ approval_policy: 'never',
85
+ sandbox_mode: 'read-only',
86
+ web_search: 'disabled',
87
+ features: {
88
+ shell_tool: false,
89
+ unified_exec: false,
90
+ shell_snapshot: false,
91
+ apps: false,
92
+ multi_agent: false,
93
+ web_search: false,
94
+ skill_mcp_dependency_install: false,
95
+ memories: false,
96
+ plugins: false,
97
+ remote_plugin: false
98
+ },
99
+ tools: {
100
+ web_search: false,
101
+ view_image: false
102
+ },
103
+ apps: {
104
+ _default: { enabled: false }
105
+ },
106
+ plugins: {},
107
+ mcp_servers: {}
108
+ };
109
+ }
110
+
111
+ function codexConfig({ mcpServerPath, mcpUrl, token, workspace }) {
112
+ const config = baseCodexConfig();
113
+ config.mcp_servers = {
114
+ livedesk: {
115
+ command: process.execPath,
116
+ args: [mcpServerPath],
117
+ cwd: workspace,
118
+ env: {
119
+ LIVEDESK_AGENT_MCP_URL: mcpUrl,
120
+ LIVEDESK_AGENT_MCP_TOKEN: token
121
+ },
122
+ enabled: true,
123
+ required: true,
124
+ enabled_tools: LIVEDESK_MCP_TOOLS,
125
+ startup_timeout_sec: 10,
126
+ tool_timeout_sec: 150
127
+ }
128
+ };
129
+ return config;
130
+ }
131
+
132
+ function safeCodexEnv(codexHome) {
133
+ const env = {
134
+ CODEX_HOME: codexHome,
135
+ HOME: codexHome,
136
+ USERPROFILE: codexHome,
137
+ APPDATA: path.join(codexHome, 'AppData', 'Roaming'),
138
+ LOCALAPPDATA: path.join(codexHome, 'AppData', 'Local')
139
+ };
140
+ for (const key of SAFE_ENV_KEYS) {
141
+ const value = process.env[key];
142
+ if (value !== undefined && value !== '') env[key] = value;
143
+ }
144
+ if (!env.Path && env.PATH) env.Path = env.PATH;
145
+ if (!env.PATH && env.Path) env.PATH = env.Path;
146
+ return env;
147
+ }
148
+
149
+ async function ensureCodexHome(codexHome) {
150
+ const resolvedHome = path.resolve(codexHome);
151
+ const globalHome = path.resolve(os.homedir(), '.codex');
152
+ if (!resolvedHome || resolvedHome === globalHome) {
153
+ throw new AgentProviderError('codex-isolation-unavailable', 'Codex requires a dedicated LiveDesk home.', { status: 503 });
154
+ }
155
+ try {
156
+ await mkdir(resolvedHome, { recursive: true, mode: 0o700 });
157
+ if (await access(path.join(resolvedHome, 'config.toml')).then(() => true, () => false)) {
158
+ throw new AgentProviderError('codex-environment-isolation-failed', 'The dedicated Codex home contains an unmanaged config.toml.', { status: 503 });
159
+ }
160
+ } catch (error) {
161
+ if (error instanceof AgentProviderError) throw error;
162
+ const wrapped = new AgentProviderError('codex-environment-isolation-failed', 'The dedicated Codex home could not be prepared.', { status: 503 });
163
+ wrapped.cause = error;
164
+ throw wrapped;
165
+ }
166
+ const targetAuthPath = path.join(resolvedHome, 'auth.json');
167
+ try {
168
+ await access(targetAuthPath);
169
+ return;
170
+ } catch {
171
+ // Keep the SDK isolated from the user's global config while reusing the
172
+ // CLI-owned auth file when the user is already signed in. The runtime
173
+ // never reads or copies the credential contents.
174
+ }
175
+ const sourceAuthPath = path.join(os.homedir(), '.codex', 'auth.json');
176
+ if (!(await access(sourceAuthPath).then(() => true, () => false))) return;
177
+ try {
178
+ await link(sourceAuthPath, targetAuthPath);
179
+ } catch (error) {
180
+ const wrapped = new AgentProviderError('codex-environment-isolation-failed', 'The Codex authentication bridge could not be prepared.', { status: 503 });
181
+ wrapped.cause = error;
182
+ throw wrapped;
183
+ }
184
+ }
185
+
186
+ function runCli(cliPath, args, timeoutMs = 5000, env = process.env) {
187
+ return new Promise((resolve, reject) => {
188
+ const child = spawn(process.execPath, [cliPath, ...args], {
189
+ env,
190
+ windowsHide: true,
191
+ stdio: ['ignore', 'pipe', 'pipe']
192
+ });
193
+ let stdout = '';
194
+ let stderr = '';
195
+ const timer = setTimeout(() => {
196
+ child.kill();
197
+ reject(Object.assign(new Error('Codex CLI status check timed out.'), { code: 'codex-status-timeout' }));
198
+ }, timeoutMs);
199
+ child.stdout.setEncoding('utf8');
200
+ child.stderr.setEncoding('utf8');
201
+ child.stdout.on('data', chunk => { stdout += chunk; });
202
+ child.stderr.on('data', chunk => { stderr += chunk; });
203
+ child.once('error', error => {
204
+ clearTimeout(timer);
205
+ reject(error);
206
+ });
207
+ child.once('close', code => {
208
+ clearTimeout(timer);
209
+ resolve({ code, stdout: stdout.trim(), stderr: stderr.trim() });
210
+ });
211
+ });
212
+ }
213
+
214
+ function isTerminalStatus(status) {
215
+ return ['completed', 'failed', 'cancelled'].includes(status);
216
+ }
217
+
218
+ function runAgeMs(run) {
219
+ return Math.max(0, Date.now() - Date.parse(run.updatedAt || run.createdAt || 0));
220
+ }
221
+
222
+ function codexAbortError(code, message) {
223
+ return new AgentProviderError(code, message, { status: code === 'codex-run-timeout' ? 504 : 409, retryable: code === 'codex-run-timeout' });
224
+ }
225
+
226
+ function safeAgentDeviceIds(value) {
227
+ return [...new Set((Array.isArray(value) ? value : [])
228
+ .map(item => String(item || '').trim())
229
+ .filter(Boolean))].slice(0, 500);
230
+ }
231
+
232
+ function freezeAgentPermissionPolicy(policy) {
233
+ const cloned = policy && typeof policy === 'object'
234
+ ? JSON.parse(JSON.stringify(policy))
235
+ : createAgentPermissionPolicy({ mode: 'ask' });
236
+ if (cloned.categories && typeof cloned.categories === 'object') Object.freeze(cloned.categories);
237
+ return Object.freeze(cloned);
238
+ }
239
+
240
+ export function createCodexAgentRuntime({
241
+ settingsStore,
242
+ dataDir = path.join(os.homedir(), '.livedesk'),
243
+ mcpServerPath,
244
+ createMcpSession,
245
+ destroyMcpSession,
246
+ createCodexClient: injectedCodexClient,
247
+ getCodexStatus: injectedCodexStatus
248
+ } = {}) {
249
+ const runs = new Map();
250
+ let activeCount = 0;
251
+ let codexModulePromise;
252
+ let securityVerificationState = 'configured';
253
+ const workspace = path.resolve(dataDir, 'agent-workspace');
254
+ const codexHome = path.resolve(dataDir, 'codex-home');
255
+
256
+ function securityStatus() {
257
+ const configured = codexHome !== path.resolve(os.homedir(), '.codex')
258
+ && typeof createMcpSession === 'function'
259
+ && Boolean(mcpServerPath);
260
+ return {
261
+ isolatedCodexHome: codexHome !== path.resolve(os.homedir(), '.codex'),
262
+ restrictedEnvironment: true,
263
+ livedeskMcpOnly: typeof createMcpSession === 'function' && Boolean(mcpServerPath),
264
+ selectedDeviceScopeEnforced: typeof createMcpSession === 'function',
265
+ failClosed: true,
266
+ verified: configured && securityVerificationState === 'verified',
267
+ state: configured ? securityVerificationState : 'unavailable'
268
+ };
269
+ }
270
+
271
+ async function prepareCodexHome() {
272
+ try {
273
+ await ensureCodexHome(codexHome);
274
+ securityVerificationState = 'verified';
275
+ } catch (error) {
276
+ securityVerificationState = 'unavailable';
277
+ throw error;
278
+ }
279
+ }
280
+
281
+ function assertSecurityConfiguration() {
282
+ const status = securityStatus();
283
+ if (!status.isolatedCodexHome || !status.restrictedEnvironment || !status.livedeskMcpOnly || !status.selectedDeviceScopeEnforced) {
284
+ throw new AgentProviderError('codex-unsupported-security-config', 'Codex security isolation is not available.', { status: 503 });
285
+ }
286
+ }
287
+
288
+ async function createCodexClient(options) {
289
+ if (typeof injectedCodexClient === 'function') return injectedCodexClient(options);
290
+ const { Codex } = await loadCodex();
291
+ return new Codex(options);
292
+ }
293
+
294
+ function scheduleRunCleanup(run) {
295
+ const timer = setTimeout(() => {
296
+ if (runs.get(run.runId) === run && isTerminalStatus(run.status) && runAgeMs(run) >= RUN_TTL_MS) {
297
+ runs.delete(run.runId);
298
+ }
299
+ }, RUN_TTL_MS + 1000);
300
+ timer.unref?.();
301
+ }
302
+
303
+ function pruneRuns() {
304
+ for (const [runId, run] of runs) {
305
+ if (isTerminalStatus(run.status) && runAgeMs(run) >= RUN_TTL_MS) runs.delete(runId);
306
+ }
307
+ if (runs.size < MAX_RUNS) return;
308
+ for (const [runId, run] of runs) {
309
+ if (!isTerminalStatus(run.status)) continue;
310
+ runs.delete(runId);
311
+ if (runs.size < MAX_RUNS) break;
312
+ }
313
+ }
314
+
315
+ async function loadCodex() {
316
+ if (!codexModulePromise) codexModulePromise = import('@openai/codex-sdk').catch(error => {
317
+ const wrapped = new AgentProviderError('codex-sdk-not-installed', 'Codex SDK is not installed in this LiveDesk package.', { status: 503 });
318
+ wrapped.cause = error;
319
+ throw wrapped;
320
+ });
321
+ return codexModulePromise;
322
+ }
323
+
324
+ function cliPath() {
325
+ try {
326
+ return require.resolve('@openai/codex/bin/codex.js');
327
+ } catch {
328
+ return '';
329
+ }
330
+ }
331
+
332
+ async function getStatus() {
333
+ if (typeof injectedCodexStatus === 'function') return injectedCodexStatus();
334
+ const pathToCli = cliPath();
335
+ if (!pathToCli) {
336
+ return { installed: false, authenticated: 'unknown', status: 'not-installed', codexPath: '' };
337
+ }
338
+ let authenticated = 'unknown';
339
+ let detail = '';
340
+ try {
341
+ await prepareCodexHome();
342
+ const result = await runCli(pathToCli, ['login', 'status'], 5000, safeCodexEnv(codexHome));
343
+ detail = safeText(result.stdout || result.stderr, 300);
344
+ authenticated = /logged in|authenticated|chatgpt/i.test(`${result.stdout}\n${result.stderr}`)
345
+ ? 'signed-in'
346
+ : 'not-signed-in';
347
+ } catch (error) {
348
+ if (error instanceof AgentProviderError) throw error;
349
+ detail = safeText(error?.message, 300);
350
+ }
351
+ return { installed: true, authenticated, status: authenticated === 'signed-in' ? 'ready' : 'auth-required', codexPath: pathToCli, detail };
352
+ }
353
+
354
+ async function testConnection() {
355
+ assertSecurityConfiguration();
356
+ const startedAt = Date.now();
357
+ const status = await getStatus();
358
+ if (!status.installed) throw new AgentProviderError('codex-sdk-not-installed', 'Codex SDK is not installed.', { status: 503 });
359
+ if (status.authenticated !== 'signed-in') throw new AgentProviderError('codex-auth-required', 'Sign in with the Codex CLI first.', { status: 401 });
360
+ const settings = await settingsStore.get();
361
+ await mkdir(workspace, { recursive: true });
362
+ await prepareCodexHome();
363
+ const codex = await createCodexClient({ env: safeCodexEnv(codexHome), config: baseCodexConfig() });
364
+ const controller = new AbortController();
365
+ const timer = setTimeout(() => controller.abort(), Math.min(30000, settings.codexTaskTimeoutMs));
366
+ try {
367
+ const thread = codex.startThread(codexThreadOptions(settings, workspace));
368
+ const turn = await thread.run('Reply with the single word READY. Do not use tools.', { signal: controller.signal });
369
+ if (!String(turn.finalResponse || '').trim()) throw new AgentProviderError('codex-empty-response', 'Codex returned an empty response.', { status: 502 });
370
+ return { ok: true, modelId: settings.codexModelId || 'Codex default', modelName: settings.codexModelId || 'Codex default', latencyMs: Date.now() - startedAt, threadId: thread.id || '' };
371
+ } catch (error) {
372
+ throw normalizeCodexError(error);
373
+ } finally {
374
+ clearTimeout(timer);
375
+ }
376
+ }
377
+
378
+ function publicRun(run) {
379
+ return {
380
+ runId: run.runId,
381
+ status: run.status,
382
+ provider: AGENT_PROVIDER_CODEX,
383
+ instruction: run.instruction,
384
+ threadId: run.threadId,
385
+ finalResponse: run.finalResponse,
386
+ error: run.error,
387
+ createdAt: run.createdAt,
388
+ updatedAt: run.updatedAt,
389
+ toolCallCount: run.toolCallCount,
390
+ permissionMode: run.permissionPolicy?.mode || 'ask',
391
+ permissionPolicy: run.permissionPolicy,
392
+ permissionPolicyHash: run.permissionPolicyHash,
393
+ batchIds: [...run.batchIds],
394
+ events: run.events.slice(-MAX_EVENTS)
395
+ };
396
+ }
397
+
398
+ function emit(run, event) {
399
+ run.events.push(publicEvent(event));
400
+ if (run.events.length > MAX_EVENTS) run.events.splice(0, run.events.length - MAX_EVENTS);
401
+ run.updatedAt = new Date().toISOString();
402
+ if (event?.type === 'thread.started') run.threadId = safeText(event.thread_id, 120);
403
+ if (event?.type === 'item.started' || event?.type === 'item.updated' || event?.type === 'item.completed') {
404
+ if (event.item?.type === 'mcp_tool_call') {
405
+ run.toolCallCount += event.type === 'item.started' ? 1 : 0;
406
+ if (event.item?.result?.structured_content?.batchId) run.batchIds.add(event.item.result.structured_content.batchId);
407
+ }
408
+ }
409
+ }
410
+
411
+ function promptFor({ instruction, deviceIds, permissionPolicy }) {
412
+ return [
413
+ 'You are the LiveDesk Agent orchestrator.',
414
+ 'Use only the registered livedesk.* MCP tools exposed by the LiveDesk server.',
415
+ 'Never use arbitrary MCP servers, change permissions, forge approvals, request credentials, or invent a tool result.',
416
+ `The Hub has fixed this run to permission mode ${permissionPolicy?.mode || 'ask'} and enforces the policy independently of your instructions.`,
417
+ '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.',
418
+ 'You may perform multiple safe read-only checks when the request requires a sequence. For example, find workstations without ComfyUI, then check the service status only on those workstations.',
419
+ `Selected device IDs: ${JSON.stringify(deviceIds)}`,
420
+ 'Return a concise Korean or English summary grounded only in tool results. Do not invent results.',
421
+ `User request: ${safeText(instruction, 4000)}`
422
+ ].join('\n');
423
+ }
424
+
425
+ async function execute(run, input) {
426
+ activeCount += 1;
427
+ let session;
428
+ let timeoutTimer;
429
+ try {
430
+ assertSecurityConfiguration();
431
+ const settings = await settingsStore.get();
432
+ const timeoutMs = Math.max(1000, Number(settings.codexTaskTimeoutMs) || 600000);
433
+ timeoutTimer = setTimeout(() => {
434
+ run.abortReason = 'timeout';
435
+ run.abortController.abort();
436
+ try { session?.cancel(); } catch { /* best effort */ }
437
+ }, timeoutMs);
438
+ timeoutTimer.unref?.();
439
+ await mkdir(workspace, { recursive: true });
440
+ if (activeCount > settings.maxConcurrentRequests) {
441
+ throw new AgentProviderError('agent-concurrency-limit', 'The LiveDesk Agent concurrency limit is reached.', { status: 429, retryable: true });
442
+ }
443
+ if (run.abortController.signal.aborted) {
444
+ if (run.abortReason === 'timeout') throw codexAbortError('codex-run-timeout', 'The Codex task exceeded its timeout.');
445
+ throw Object.assign(new Error('The Codex task was cancelled.'), { name: 'AbortError' });
446
+ }
447
+ const status = await getStatus();
448
+ if (!status.installed) throw new AgentProviderError('codex-sdk-not-installed', 'Codex SDK is not installed.', { status: 503 });
449
+ if (status.authenticated !== 'signed-in') throw new AgentProviderError('codex-auth-required', 'Sign in with the Codex CLI first.', { status: 401 });
450
+ session = createMcpSession({
451
+ runId: run.runId,
452
+ signal: run.abortController.signal,
453
+ allowedDeviceIds: input.deviceIds,
454
+ maxToolCalls: run.permissionPolicy.maxToolCalls,
455
+ permissionPolicy: run.permissionPolicy,
456
+ onApprovalRequired: () => {
457
+ if (run.status === 'running') {
458
+ run.status = 'waiting-approval';
459
+ run.updatedAt = new Date().toISOString();
460
+ }
461
+ },
462
+ onApprovalResolved: () => {
463
+ if (run.status === 'waiting-approval') {
464
+ run.status = 'running';
465
+ run.updatedAt = new Date().toISOString();
466
+ }
467
+ }
468
+ });
469
+ run.mcpToken = session.token;
470
+ if (run.abortController.signal.aborted) {
471
+ session.cancel();
472
+ if (run.abortReason === 'timeout') throw codexAbortError('codex-run-timeout', 'The Codex task exceeded its timeout.');
473
+ throw Object.assign(new Error('The Codex task was cancelled.'), { name: 'AbortError' });
474
+ }
475
+ await prepareCodexHome();
476
+ const codex = await createCodexClient({ env: safeCodexEnv(codexHome), config: codexConfig({ mcpServerPath, mcpUrl: session.url, token: session.token, workspace }) });
477
+ const threadOptions = codexThreadOptions(settings, workspace);
478
+ const thread = input.resumeThreadId && settings.codexResumeSessions
479
+ ? codex.resumeThread(String(input.resumeThreadId), threadOptions)
480
+ : codex.startThread(threadOptions);
481
+ run.status = 'running';
482
+ run.updatedAt = new Date().toISOString();
483
+ const streamed = await thread.runStreamed(promptFor({ instruction: run.instruction, deviceIds: input.deviceIds, permissionPolicy: run.permissionPolicy }), { signal: run.abortController.signal });
484
+ for await (const event of streamed.events) {
485
+ emit(run, event);
486
+ if (event.type === 'turn.started') {
487
+ run.turnCount += 1;
488
+ if (run.turnCount > settings.codexMaxTurns) {
489
+ run.abortReason = 'turn-limit';
490
+ run.abortController.abort();
491
+ session.cancel();
492
+ throw new AgentProviderError('codex-turn-limit-reached', 'Codex exceeded the LiveDesk turn limit.', { status: 409 });
493
+ }
494
+ }
495
+ if (run.toolCallCount > run.permissionPolicy.maxToolCalls) {
496
+ run.abortReason = 'tool-limit';
497
+ run.abortController.abort();
498
+ session.cancel();
499
+ throw new AgentProviderError('codex-tool-limit-reached', 'Codex exceeded the LiveDesk tool-call limit.', { status: 409 });
500
+ }
501
+ if (event.type === 'item.completed' && event.item?.type === 'agent_message') run.finalResponse = safeText(event.item.text, 4000);
502
+ if (event.type === 'turn.completed') run.status = 'completed';
503
+ if (event.type === 'turn.failed') throw new AgentProviderError('codex-run-failed', safeText(event.error?.message, 800) || 'Codex run failed.', { status: 502 });
504
+ if (event.type === 'error') throw new AgentProviderError('codex-run-failed', safeText(event.message, 800) || 'Codex run failed.', { status: 502 });
505
+ }
506
+ if (run.status === 'running') run.status = 'completed';
507
+ } catch (error) {
508
+ if (run.abortReason === 'timeout') {
509
+ run.status = 'failed';
510
+ run.error = 'codex-run-timeout';
511
+ } else if (run.abortReason === 'tool-limit') {
512
+ run.status = 'failed';
513
+ run.error = 'codex-tool-limit-reached';
514
+ } else if (run.abortReason === 'turn-limit') {
515
+ run.status = 'failed';
516
+ run.error = 'codex-turn-limit-reached';
517
+ } else if (isAbortError(error) || run.abortController.signal.aborted) {
518
+ run.status = 'cancelled';
519
+ run.error = 'cancelled-by-user';
520
+ } else {
521
+ const normalized = normalizeCodexError(error);
522
+ run.status = 'failed';
523
+ run.error = safeText(normalized.code || normalized.message, 240) || 'codex-run-failed';
524
+ }
525
+ } finally {
526
+ activeCount = Math.max(0, activeCount - 1);
527
+ if (session) {
528
+ try { session.cancel(); } catch { /* best effort */ }
529
+ destroyMcpSession(session.token);
530
+ }
531
+ clearTimeout(timeoutTimer);
532
+ run.updatedAt = new Date().toISOString();
533
+ if (isTerminalStatus(run.status)) scheduleRunCleanup(run);
534
+ }
535
+ }
536
+
537
+ async function start(input = {}) {
538
+ const instruction = safeText(input.instruction, 4000);
539
+ if (!instruction) throw new AgentProviderError('agent-invalid-instruction', 'Instruction is required.', { status: 400 });
540
+ const deviceIds = safeAgentDeviceIds(input.deviceIds);
541
+ if (deviceIds.length === 0) throw new AgentProviderError('agent-no-target-devices', 'Select at least one connected device.', { status: 400 });
542
+ const settings = await settingsStore.get();
543
+ if (!settings.enabled) throw new AgentProviderError('agent-ai-disabled', 'Agent AI is disabled in Settings.', { status: 409 });
544
+ assertSecurityConfiguration();
545
+ pruneRuns();
546
+ if (runs.size >= MAX_RUNS) throw new AgentProviderError('agent-run-limit', 'The LiveDesk Agent run history is full.', { status: 429, retryable: true });
547
+ const permissionPolicy = freezeAgentPermissionPolicy(input.permissionPolicy && typeof input.permissionPolicy === 'object'
548
+ ? input.permissionPolicy
549
+ : createAgentPermissionPolicy({ mode: input.permissionMode || 'safe-auto', deviceIds, maxToolCalls: settings.codexMaxToolCalls }));
550
+ const run = {
551
+ runId: crypto.randomUUID(),
552
+ status: 'queued',
553
+ provider: AGENT_PROVIDER_CODEX,
554
+ instruction,
555
+ threadId: '',
556
+ finalResponse: '',
557
+ error: '',
558
+ createdAt: new Date().toISOString(),
559
+ updatedAt: new Date().toISOString(),
560
+ toolCallCount: 0,
561
+ turnCount: 0,
562
+ permissionPolicy,
563
+ permissionPolicyHash: hashAgentPermissionPolicy(permissionPolicy),
564
+ batchIds: new Set(),
565
+ events: [],
566
+ abortController: new AbortController(),
567
+ abortReason: '',
568
+ mcpToken: ''
569
+ };
570
+ runs.set(run.runId, run);
571
+ void execute(run, { deviceIds, resumeThreadId: input.resumeThreadId });
572
+ return publicRun(run);
573
+ }
574
+
575
+ function get(runId) {
576
+ const run = runs.get(String(runId || ''));
577
+ return run ? publicRun(run) : null;
578
+ }
579
+
580
+ function cancel(runId) {
581
+ const run = runs.get(String(runId || ''));
582
+ if (!run) return null;
583
+ if (isTerminalStatus(run.status)) return publicRun(run);
584
+ run.abortReason = 'user';
585
+ run.abortController.abort();
586
+ run.status = 'cancelled';
587
+ run.error = 'cancelled-by-user';
588
+ run.updatedAt = new Date().toISOString();
589
+ return publicRun(run);
590
+ }
591
+
592
+ function cancelAll() {
593
+ let cancelled = 0;
594
+ for (const run of runs.values()) {
595
+ if (!isTerminalStatus(run.status)) {
596
+ cancel(run.runId);
597
+ cancelled += 1;
598
+ }
599
+ }
600
+ return cancelled;
601
+ }
602
+
603
+ return { getStatus, testConnection, start, get, cancel, cancelAll, workspace, codexHome, getSecurityStatus: securityStatus };
604
+ }