livedesk 0.1.205 → 0.1.207

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.
@@ -0,0 +1,29 @@
1
+ function normalizeIds(value) {
2
+ const source = Array.isArray(value) ? value : [];
3
+ return [...new Set(source.map(item => String(item || '').trim()).filter(Boolean))].slice(0, 500);
4
+ }
5
+
6
+ export function createAgentDeviceScope(allowedDeviceIds, fallbackDeviceIds = []) {
7
+ const requested = normalizeIds(allowedDeviceIds);
8
+ const fallback = normalizeIds(fallbackDeviceIds);
9
+ return new Set(requested.length > 0 ? requested : fallback);
10
+ }
11
+
12
+ export function selectAgentDeviceIds({ allowedDeviceIds, requestedDeviceIds, connectedDeviceIds }) {
13
+ const allowed = allowedDeviceIds instanceof Set ? allowedDeviceIds : new Set(normalizeIds(allowedDeviceIds));
14
+ const requested = normalizeIds(requestedDeviceIds);
15
+ const connected = new Set(normalizeIds(connectedDeviceIds));
16
+ const candidates = requested.length > 0 ? requested : [...allowed];
17
+ return candidates.filter(deviceId => allowed.has(deviceId) && connected.has(deviceId));
18
+ }
19
+
20
+ export function resolveAgentTargetIds({ allowedDeviceIds, requestedDeviceIds, connectedDeviceIds }) {
21
+ const requested = normalizeIds(requestedDeviceIds);
22
+ const allowed = allowedDeviceIds instanceof Set ? allowedDeviceIds : new Set(normalizeIds(allowedDeviceIds));
23
+ const deviceIds = selectAgentDeviceIds({ allowedDeviceIds: allowed, requestedDeviceIds: requested, connectedDeviceIds });
24
+ if (deviceIds.length > 0) return { deviceIds, error: '' };
25
+ if (requested.length > 0 && requested.every(deviceId => !allowed.has(deviceId))) {
26
+ return { deviceIds: [], error: 'agent-target-outside-selection' };
27
+ }
28
+ return { deviceIds: [], error: 'agent-no-target-devices' };
29
+ }
@@ -37,13 +37,19 @@ export function createAgentManager({
37
37
  async function getCodexStatus() {
38
38
  if (!runtime) return { installed: false, authenticated: 'unknown', status: 'unavailable', codexPath: '' };
39
39
  if (statusCache.value && statusCache.expiresAt > Date.now()) return statusCache.value;
40
- const value = publicCodexStatus(await runtime.getStatus());
40
+ let value;
41
+ try {
42
+ value = publicCodexStatus(await runtime.getStatus());
43
+ } catch (error) {
44
+ value = publicCodexStatus({ installed: true, authenticated: 'unknown', status: error?.code || 'security-unavailable', detail: error?.message || 'Codex security isolation is unavailable.' });
45
+ }
41
46
  statusCache = { value, expiresAt: Date.now() + 10000 };
42
47
  return value;
43
48
  }
44
49
 
45
50
  async function publicSettings() {
46
51
  const current = await settings.get();
52
+ const { codexMaxTurns: _codexMaxTurns, ...exposedSettings } = current;
47
53
  let hasApiKey = false;
48
54
  let apiKeyPreview = '';
49
55
  let secretStoreAvailable = true;
@@ -57,8 +63,15 @@ export function createAgentManager({
57
63
  }
58
64
  }
59
65
  const codex = await getCodexStatus();
66
+ const securityStatus = runtime?.getSecurityStatus?.() || {
67
+ isolatedCodexHome: false,
68
+ restrictedEnvironment: false,
69
+ livedeskMcpOnly: false,
70
+ selectedDeviceScopeEnforced: false,
71
+ failClosed: true
72
+ };
60
73
  return {
61
- ...current,
74
+ ...exposedSettings,
62
75
  hasApiKey,
63
76
  apiKeyPreview,
64
77
  secretStoreAvailable,
@@ -66,7 +79,8 @@ export function createAgentManager({
66
79
  codexAuth: codex.authenticated,
67
80
  codexStatus: codex.status,
68
81
  codexPath: codex.codexPath,
69
- agentWorkspacePath: current.codexWorkingDirectory
82
+ agentWorkspacePath: current.codexWorkingDirectory,
83
+ securityStatus
70
84
  };
71
85
  }
72
86
 
@@ -136,7 +150,9 @@ export function createAgentManager({
136
150
  const { current, active } = await currentProvider();
137
151
  if (!current.enabled || !current.generateSummary) throw new AgentProviderError('agent-summary-disabled', 'Agent summaries are disabled in Settings.', { status: 409 });
138
152
  if (current.provider === AGENT_PROVIDER_CODEX) {
139
- return { summary: String(input?.summary?.results?.length ? `${input.summary.completed || 0} completed, ${input.summary.failed || 0} failed.` : 'LiveDesk Agent run completed.').slice(0, 1200), modelId: current.codexModelId || 'Codex default' };
153
+ const source = input && typeof input === 'object' ? input : {};
154
+ const results = Array.isArray(source.results) ? source.results : [];
155
+ return { summary: String(results.length ? `${source.completed || 0} completed, ${source.failed || 0} failed.` : 'LiveDesk Agent run completed.').slice(0, 1200), modelId: current.codexModelId || 'Codex default' };
140
156
  }
141
157
  const source = input && typeof input === 'object' ? input : {};
142
158
  const results = Array.isArray(source.results) ? source.results : [];
@@ -98,11 +98,9 @@ function normalizeBaseUrl(value) {
98
98
  }
99
99
 
100
100
  function normalizeWorkingDirectory(value) {
101
- const candidate = stringValue(value, DEFAULT_AGENT_WORKSPACE, 500);
102
- if (!candidate || candidate.includes('\0')) {
103
- throw new AgentSettingsError('agent-invalid-working-directory', 'Codex working directory is invalid.');
104
- }
105
- return path.resolve(candidate);
101
+ // The agent runtime owns this directory. Do not allow a browser/API caller
102
+ // to move Codex into a user project or an arbitrary filesystem root.
103
+ return DEFAULT_AGENT_WORKSPACE;
106
104
  }
107
105
 
108
106
  export function normalizeAgentSettings(value = {}) {
@@ -1,5 +1,5 @@
1
1
  import crypto from 'node:crypto';
2
- import { mkdir } from 'node:fs/promises';
2
+ import { access, link, mkdir } from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { spawn } from 'node:child_process';
@@ -9,6 +9,30 @@ import { AGENT_PROVIDER_CODEX } from './agent-settings.js';
9
9
 
10
10
  const require = createRequire(import.meta.url);
11
11
  const MAX_EVENTS = 240;
12
+ const MAX_RUNS = 500;
13
+ const RUN_TTL_MS = 60 * 60 * 1000;
14
+ const SAFE_ENV_KEYS = [
15
+ 'Path',
16
+ 'PATH',
17
+ 'SystemRoot',
18
+ 'SYSTEMROOT',
19
+ 'ComSpec',
20
+ 'TEMP',
21
+ 'TMP',
22
+ 'WINDIR',
23
+ 'LANG',
24
+ 'LC_ALL',
25
+ 'TMPDIR'
26
+ ];
27
+ const LIVEDESK_MCP_TOOLS = [
28
+ 'livedesk.list_devices',
29
+ 'livedesk.get_system_health',
30
+ 'livedesk.get_gpu_status',
31
+ 'livedesk.get_disk_status',
32
+ 'livedesk.list_processes',
33
+ 'livedesk.get_service_status',
34
+ 'livedesk.collect_diagnostics'
35
+ ];
12
36
 
13
37
  function safeText(value, max = 2400) {
14
38
  return String(value || '').replace(/[\0\r]/g, ' ').trim().slice(0, max);
@@ -61,42 +85,114 @@ function codexThreadOptions(settings, workspace) {
61
85
  };
62
86
  }
63
87
 
64
- function codexConfig({ mcpServerPath, mcpUrl, token, workspace }) {
88
+ function baseCodexConfig() {
65
89
  return {
66
90
  approval_policy: 'never',
67
91
  sandbox_mode: 'read-only',
68
92
  web_search: 'disabled',
69
93
  features: {
70
94
  shell_tool: false,
71
- web_search: false,
95
+ unified_exec: false,
96
+ shell_snapshot: false,
72
97
  apps: false,
73
- multi_agent: false
98
+ multi_agent: false,
99
+ web_search: false,
100
+ skill_mcp_dependency_install: false,
101
+ memories: false,
102
+ plugins: false,
103
+ remote_plugin: false
74
104
  },
75
105
  tools: {
76
106
  web_search: false,
77
107
  view_image: false
78
108
  },
79
- mcp_servers: {
80
- livedesk: {
81
- command: process.execPath,
82
- args: [mcpServerPath],
83
- cwd: workspace,
84
- env: {
85
- LIVEDESK_AGENT_MCP_URL: mcpUrl,
86
- LIVEDESK_AGENT_MCP_TOKEN: token
87
- },
88
- enabled: true,
89
- required: true,
90
- startup_timeout_sec: 10,
91
- tool_timeout_sec: 150
92
- }
109
+ apps: {
110
+ _default: { enabled: false }
111
+ },
112
+ plugins: {},
113
+ mcp_servers: {}
114
+ };
115
+ }
116
+
117
+ function codexConfig({ mcpServerPath, mcpUrl, token, workspace }) {
118
+ const config = baseCodexConfig();
119
+ config.mcp_servers = {
120
+ livedesk: {
121
+ command: process.execPath,
122
+ args: [mcpServerPath],
123
+ cwd: workspace,
124
+ env: {
125
+ LIVEDESK_AGENT_MCP_URL: mcpUrl,
126
+ LIVEDESK_AGENT_MCP_TOKEN: token
127
+ },
128
+ enabled: true,
129
+ required: true,
130
+ enabled_tools: LIVEDESK_MCP_TOOLS,
131
+ startup_timeout_sec: 10,
132
+ tool_timeout_sec: 150
93
133
  }
94
134
  };
135
+ return config;
95
136
  }
96
137
 
97
- function runCli(cliPath, args, timeoutMs = 5000) {
138
+ function safeCodexEnv(codexHome) {
139
+ const env = {
140
+ CODEX_HOME: codexHome,
141
+ HOME: codexHome,
142
+ USERPROFILE: codexHome,
143
+ APPDATA: path.join(codexHome, 'AppData', 'Roaming'),
144
+ LOCALAPPDATA: path.join(codexHome, 'AppData', 'Local')
145
+ };
146
+ for (const key of SAFE_ENV_KEYS) {
147
+ const value = process.env[key];
148
+ if (value !== undefined && value !== '') env[key] = value;
149
+ }
150
+ if (!env.Path && env.PATH) env.Path = env.PATH;
151
+ if (!env.PATH && env.Path) env.PATH = env.Path;
152
+ return env;
153
+ }
154
+
155
+ async function ensureCodexHome(codexHome) {
156
+ const resolvedHome = path.resolve(codexHome);
157
+ const globalHome = path.resolve(os.homedir(), '.codex');
158
+ if (!resolvedHome || resolvedHome === globalHome) {
159
+ throw new AgentProviderError('codex-isolation-unavailable', 'Codex requires a dedicated LiveDesk home.', { status: 503 });
160
+ }
161
+ try {
162
+ await mkdir(resolvedHome, { recursive: true, mode: 0o700 });
163
+ if (await access(path.join(resolvedHome, 'config.toml')).then(() => true, () => false)) {
164
+ throw new AgentProviderError('codex-environment-isolation-failed', 'The dedicated Codex home contains an unmanaged config.toml.', { status: 503 });
165
+ }
166
+ } catch (error) {
167
+ if (error instanceof AgentProviderError) throw error;
168
+ const wrapped = new AgentProviderError('codex-environment-isolation-failed', 'The dedicated Codex home could not be prepared.', { status: 503 });
169
+ wrapped.cause = error;
170
+ throw wrapped;
171
+ }
172
+ const targetAuthPath = path.join(resolvedHome, 'auth.json');
173
+ try {
174
+ await access(targetAuthPath);
175
+ return;
176
+ } catch {
177
+ // Keep the SDK isolated from the user's global config while reusing the
178
+ // CLI-owned auth file when the user is already signed in. The runtime
179
+ // never reads or copies the credential contents.
180
+ }
181
+ const sourceAuthPath = path.join(os.homedir(), '.codex', 'auth.json');
182
+ if (!(await access(sourceAuthPath).then(() => true, () => false))) return;
183
+ try {
184
+ await link(sourceAuthPath, targetAuthPath);
185
+ } catch (error) {
186
+ const wrapped = new AgentProviderError('codex-environment-isolation-failed', 'The Codex authentication bridge could not be prepared.', { status: 503 });
187
+ wrapped.cause = error;
188
+ throw wrapped;
189
+ }
190
+ }
191
+
192
+ function runCli(cliPath, args, timeoutMs = 5000, env = process.env) {
98
193
  return new Promise((resolve, reject) => {
99
194
  const child = spawn(process.execPath, [cliPath, ...args], {
195
+ env,
100
196
  windowsHide: true,
101
197
  stdio: ['ignore', 'pipe', 'pipe']
102
198
  });
@@ -121,17 +217,82 @@ function runCli(cliPath, args, timeoutMs = 5000) {
121
217
  });
122
218
  }
123
219
 
220
+ function isTerminalStatus(status) {
221
+ return ['completed', 'failed', 'cancelled'].includes(status);
222
+ }
223
+
224
+ function runAgeMs(run) {
225
+ return Math.max(0, Date.now() - Date.parse(run.updatedAt || run.createdAt || 0));
226
+ }
227
+
228
+ function codexAbortError(code, message) {
229
+ return new AgentProviderError(code, message, { status: code === 'codex-run-timeout' ? 504 : 409, retryable: code === 'codex-run-timeout' });
230
+ }
231
+
232
+ function safeAgentDeviceIds(value) {
233
+ return [...new Set((Array.isArray(value) ? value : [])
234
+ .map(item => String(item || '').trim())
235
+ .filter(Boolean))].slice(0, 500);
236
+ }
237
+
124
238
  export function createCodexAgentRuntime({
125
239
  settingsStore,
126
240
  dataDir = path.join(os.homedir(), '.livedesk'),
127
241
  mcpServerPath,
128
242
  createMcpSession,
129
- destroyMcpSession
243
+ destroyMcpSession,
244
+ createCodexClient: injectedCodexClient,
245
+ getCodexStatus: injectedCodexStatus
130
246
  } = {}) {
131
247
  const runs = new Map();
132
248
  let activeCount = 0;
133
249
  let codexModulePromise;
134
250
  const workspace = path.resolve(dataDir, 'agent-workspace');
251
+ const codexHome = path.resolve(dataDir, 'codex-home');
252
+
253
+ function securityStatus() {
254
+ return {
255
+ isolatedCodexHome: codexHome !== path.resolve(os.homedir(), '.codex'),
256
+ restrictedEnvironment: true,
257
+ livedeskMcpOnly: typeof createMcpSession === 'function' && Boolean(mcpServerPath),
258
+ selectedDeviceScopeEnforced: typeof createMcpSession === 'function',
259
+ failClosed: true
260
+ };
261
+ }
262
+
263
+ function assertSecurityConfiguration() {
264
+ const status = securityStatus();
265
+ if (!status.isolatedCodexHome || !status.restrictedEnvironment || !status.livedeskMcpOnly || !status.selectedDeviceScopeEnforced) {
266
+ throw new AgentProviderError('codex-unsupported-security-config', 'Codex security isolation is not available.', { status: 503 });
267
+ }
268
+ }
269
+
270
+ async function createCodexClient(options) {
271
+ if (typeof injectedCodexClient === 'function') return injectedCodexClient(options);
272
+ const { Codex } = await loadCodex();
273
+ return new Codex(options);
274
+ }
275
+
276
+ function scheduleRunCleanup(run) {
277
+ const timer = setTimeout(() => {
278
+ if (runs.get(run.runId) === run && isTerminalStatus(run.status) && runAgeMs(run) >= RUN_TTL_MS) {
279
+ runs.delete(run.runId);
280
+ }
281
+ }, RUN_TTL_MS + 1000);
282
+ timer.unref?.();
283
+ }
284
+
285
+ function pruneRuns() {
286
+ for (const [runId, run] of runs) {
287
+ if (isTerminalStatus(run.status) && runAgeMs(run) >= RUN_TTL_MS) runs.delete(runId);
288
+ }
289
+ if (runs.size < MAX_RUNS) return;
290
+ for (const [runId, run] of runs) {
291
+ if (!isTerminalStatus(run.status)) continue;
292
+ runs.delete(runId);
293
+ if (runs.size < MAX_RUNS) break;
294
+ }
295
+ }
135
296
 
136
297
  async function loadCodex() {
137
298
  if (!codexModulePromise) codexModulePromise = import('@openai/codex-sdk').catch(error => {
@@ -151,6 +312,7 @@ export function createCodexAgentRuntime({
151
312
  }
152
313
 
153
314
  async function getStatus() {
315
+ if (typeof injectedCodexStatus === 'function') return injectedCodexStatus();
154
316
  const pathToCli = cliPath();
155
317
  if (!pathToCli) {
156
318
  return { installed: false, authenticated: 'unknown', status: 'not-installed', codexPath: '' };
@@ -158,26 +320,29 @@ export function createCodexAgentRuntime({
158
320
  let authenticated = 'unknown';
159
321
  let detail = '';
160
322
  try {
161
- const result = await runCli(pathToCli, ['login', 'status']);
323
+ await ensureCodexHome(codexHome);
324
+ const result = await runCli(pathToCli, ['login', 'status'], 5000, safeCodexEnv(codexHome));
162
325
  detail = safeText(result.stdout || result.stderr, 300);
163
326
  authenticated = /logged in|authenticated|chatgpt/i.test(`${result.stdout}\n${result.stderr}`)
164
327
  ? 'signed-in'
165
328
  : 'not-signed-in';
166
329
  } catch (error) {
330
+ if (error instanceof AgentProviderError) throw error;
167
331
  detail = safeText(error?.message, 300);
168
332
  }
169
333
  return { installed: true, authenticated, status: authenticated === 'signed-in' ? 'ready' : 'auth-required', codexPath: pathToCli, detail };
170
334
  }
171
335
 
172
336
  async function testConnection() {
337
+ assertSecurityConfiguration();
173
338
  const startedAt = Date.now();
174
339
  const status = await getStatus();
175
340
  if (!status.installed) throw new AgentProviderError('codex-sdk-not-installed', 'Codex SDK is not installed.', { status: 503 });
176
341
  if (status.authenticated !== 'signed-in') throw new AgentProviderError('codex-auth-required', 'Sign in with the Codex CLI first.', { status: 401 });
177
342
  const settings = await settingsStore.get();
178
343
  await mkdir(workspace, { recursive: true });
179
- const { Codex } = await loadCodex();
180
- const codex = new Codex({ config: { approval_policy: 'never', sandbox_mode: 'read-only', web_search: 'disabled', features: { shell_tool: false, web_search: false, apps: false, multi_agent: false }, tools: { web_search: false, view_image: false } } });
344
+ await ensureCodexHome(codexHome);
345
+ const codex = await createCodexClient({ env: safeCodexEnv(codexHome), config: baseCodexConfig() });
181
346
  const controller = new AbortController();
182
347
  const timer = setTimeout(() => controller.abort(), Math.min(30000, settings.codexTaskTimeoutMs));
183
348
  try {
@@ -238,19 +403,37 @@ export function createCodexAgentRuntime({
238
403
  async function execute(run, input) {
239
404
  activeCount += 1;
240
405
  let session;
406
+ let timeoutTimer;
241
407
  try {
408
+ assertSecurityConfiguration();
242
409
  const settings = await settingsStore.get();
410
+ const timeoutMs = Math.max(1000, Number(settings.codexTaskTimeoutMs) || 600000);
411
+ timeoutTimer = setTimeout(() => {
412
+ run.abortReason = 'timeout';
413
+ run.abortController.abort();
414
+ try { session?.cancel(); } catch { /* best effort */ }
415
+ }, timeoutMs);
416
+ timeoutTimer.unref?.();
243
417
  await mkdir(workspace, { recursive: true });
244
418
  if (activeCount > settings.maxConcurrentRequests) {
245
419
  throw new AgentProviderError('agent-concurrency-limit', 'The LiveDesk Agent concurrency limit is reached.', { status: 429, retryable: true });
246
420
  }
421
+ if (run.abortController.signal.aborted) {
422
+ if (run.abortReason === 'timeout') throw codexAbortError('codex-run-timeout', 'The Codex task exceeded its timeout.');
423
+ throw Object.assign(new Error('The Codex task was cancelled.'), { name: 'AbortError' });
424
+ }
247
425
  const status = await getStatus();
248
426
  if (!status.installed) throw new AgentProviderError('codex-sdk-not-installed', 'Codex SDK is not installed.', { status: 503 });
249
427
  if (status.authenticated !== 'signed-in') throw new AgentProviderError('codex-auth-required', 'Sign in with the Codex CLI first.', { status: 401 });
250
- session = createMcpSession({ runId: run.runId, signal: run.abortController.signal });
428
+ session = createMcpSession({ runId: run.runId, signal: run.abortController.signal, allowedDeviceIds: input.deviceIds });
251
429
  run.mcpToken = session.token;
252
- const { Codex } = await loadCodex();
253
- const codex = new Codex({ config: codexConfig({ mcpServerPath, mcpUrl: session.url, token: session.token, workspace }) });
430
+ if (run.abortController.signal.aborted) {
431
+ session.cancel();
432
+ if (run.abortReason === 'timeout') throw codexAbortError('codex-run-timeout', 'The Codex task exceeded its timeout.');
433
+ throw Object.assign(new Error('The Codex task was cancelled.'), { name: 'AbortError' });
434
+ }
435
+ await ensureCodexHome(codexHome);
436
+ const codex = await createCodexClient({ env: safeCodexEnv(codexHome), config: codexConfig({ mcpServerPath, mcpUrl: session.url, token: session.token, workspace }) });
254
437
  const threadOptions = codexThreadOptions(settings, workspace);
255
438
  const thread = input.resumeThreadId && settings.codexResumeSessions
256
439
  ? codex.resumeThread(String(input.resumeThreadId), threadOptions)
@@ -260,7 +443,17 @@ export function createCodexAgentRuntime({
260
443
  const streamed = await thread.runStreamed(promptFor({ instruction: run.instruction, deviceIds: input.deviceIds }), { signal: run.abortController.signal });
261
444
  for await (const event of streamed.events) {
262
445
  emit(run, event);
446
+ if (event.type === 'turn.started') {
447
+ run.turnCount += 1;
448
+ if (run.turnCount > settings.codexMaxTurns) {
449
+ run.abortReason = 'turn-limit';
450
+ run.abortController.abort();
451
+ session.cancel();
452
+ throw new AgentProviderError('codex-turn-limit-reached', 'Codex exceeded the LiveDesk turn limit.', { status: 409 });
453
+ }
454
+ }
263
455
  if (run.toolCallCount > settings.codexMaxToolCalls) {
456
+ run.abortReason = 'tool-limit';
264
457
  run.abortController.abort();
265
458
  session.cancel();
266
459
  throw new AgentProviderError('codex-tool-limit-reached', 'Codex exceeded the LiveDesk tool-call limit.', { status: 409 });
@@ -272,7 +465,16 @@ export function createCodexAgentRuntime({
272
465
  }
273
466
  if (run.status === 'running') run.status = 'completed';
274
467
  } catch (error) {
275
- if (isAbortError(error) || run.abortController.signal.aborted) {
468
+ if (run.abortReason === 'timeout') {
469
+ run.status = 'failed';
470
+ run.error = 'codex-run-timeout';
471
+ } else if (run.abortReason === 'tool-limit') {
472
+ run.status = 'failed';
473
+ run.error = 'codex-tool-limit-reached';
474
+ } else if (run.abortReason === 'turn-limit') {
475
+ run.status = 'failed';
476
+ run.error = 'codex-turn-limit-reached';
477
+ } else if (isAbortError(error) || run.abortController.signal.aborted) {
276
478
  run.status = 'cancelled';
277
479
  run.error = 'cancelled-by-user';
278
480
  } else {
@@ -286,15 +488,22 @@ export function createCodexAgentRuntime({
286
488
  try { session.cancel(); } catch { /* best effort */ }
287
489
  destroyMcpSession(session.token);
288
490
  }
491
+ clearTimeout(timeoutTimer);
289
492
  run.updatedAt = new Date().toISOString();
493
+ if (isTerminalStatus(run.status)) scheduleRunCleanup(run);
290
494
  }
291
495
  }
292
496
 
293
497
  async function start(input = {}) {
294
498
  const instruction = safeText(input.instruction, 4000);
295
499
  if (!instruction) throw new AgentProviderError('agent-invalid-instruction', 'Instruction is required.', { status: 400 });
500
+ const deviceIds = safeAgentDeviceIds(input.deviceIds);
501
+ if (deviceIds.length === 0) throw new AgentProviderError('agent-no-target-devices', 'Select at least one connected device.', { status: 400 });
296
502
  const settings = await settingsStore.get();
297
503
  if (!settings.enabled) throw new AgentProviderError('agent-ai-disabled', 'Agent AI is disabled in Settings.', { status: 409 });
504
+ assertSecurityConfiguration();
505
+ pruneRuns();
506
+ if (runs.size >= MAX_RUNS) throw new AgentProviderError('agent-run-limit', 'The LiveDesk Agent run history is full.', { status: 429, retryable: true });
298
507
  const run = {
299
508
  runId: crypto.randomUUID(),
300
509
  status: 'queued',
@@ -306,13 +515,15 @@ export function createCodexAgentRuntime({
306
515
  createdAt: new Date().toISOString(),
307
516
  updatedAt: new Date().toISOString(),
308
517
  toolCallCount: 0,
518
+ turnCount: 0,
309
519
  batchIds: new Set(),
310
520
  events: [],
311
521
  abortController: new AbortController(),
522
+ abortReason: '',
312
523
  mcpToken: ''
313
524
  };
314
525
  runs.set(run.runId, run);
315
- void execute(run, { deviceIds: Array.isArray(input.deviceIds) ? input.deviceIds.slice(0, 500) : [], resumeThreadId: input.resumeThreadId });
526
+ void execute(run, { deviceIds, resumeThreadId: input.resumeThreadId });
316
527
  return publicRun(run);
317
528
  }
318
529
 
@@ -324,6 +535,8 @@ export function createCodexAgentRuntime({
324
535
  function cancel(runId) {
325
536
  const run = runs.get(String(runId || ''));
326
537
  if (!run) return null;
538
+ if (isTerminalStatus(run.status)) return publicRun(run);
539
+ run.abortReason = 'user';
327
540
  run.abortController.abort();
328
541
  run.status = 'cancelled';
329
542
  run.error = 'cancelled-by-user';
@@ -331,5 +544,5 @@ export function createCodexAgentRuntime({
331
544
  return publicRun(run);
332
545
  }
333
546
 
334
- return { getStatus, testConnection, start, get, cancel, workspace };
547
+ return { getStatus, testConnection, start, get, cancel, workspace, codexHome, getSecurityStatus: securityStatus };
335
548
  }
@@ -1832,6 +1832,11 @@ export function createRemoteHub(options = {}) {
1832
1832
  if (!batch) {
1833
1833
  return { ok: false, error: 'task-not-found' };
1834
1834
  }
1835
+ if (!['running', 'queued'].includes(batch.status)) {
1836
+ return { ok: true, ...serializeTaskBatch(batch) };
1837
+ }
1838
+
1839
+ batch.cancelRequested = true;
1835
1840
 
1836
1841
  for (const item of batch.results) {
1837
1842
  if (!['targeted', 'queued', 'running'].includes(item.status)) {
@@ -2821,6 +2826,7 @@ export function createRemoteHub(options = {}) {
2821
2826
  completed: 0,
2822
2827
  failed: targets.length > 0 ? 0 : 1,
2823
2828
  timedOut: 0,
2829
+ cancelRequested: false,
2824
2830
  results: targets.map(deviceId => ({
2825
2831
  deviceId,
2826
2832
  deviceName: safeString(devices.get(deviceId)?.deviceName || devices.get(deviceId)?.hostname || deviceId, 160),
@@ -2890,7 +2896,9 @@ export function createRemoteHub(options = {}) {
2890
2896
  batch.pending = results.filter(item => ['targeted', 'queued', 'running'].includes(item.status)).length;
2891
2897
  batch.updatedAt = new Date().toISOString();
2892
2898
 
2893
- if (batch.total === 0) {
2899
+ if (batch.cancelRequested && batch.pending === 0) {
2900
+ batch.status = 'cancelled';
2901
+ } else if (batch.total === 0) {
2894
2902
  batch.status = 'failed';
2895
2903
  } else if (batch.completed + batch.failed + batch.cancelled >= batch.total) {
2896
2904
  batch.status = batch.failed > 0 ? 'completed-with-failures' : 'completed';
@@ -2919,6 +2927,10 @@ export function createRemoteHub(options = {}) {
2919
2927
  return null;
2920
2928
  }
2921
2929
 
2930
+ if (batch.cancelRequested && item.status === 'cancelled') {
2931
+ return batch;
2932
+ }
2933
+
2922
2934
  item.ok = true;
2923
2935
  item.status = safeString(task.status, 40) || 'queued';
2924
2936
  item.stage = safeString(task.stage, 160) || (item.status === 'running' ? 'Working' : '');