livedesk 0.1.206 → 0.1.208

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.
@@ -16,3 +16,14 @@ export function selectAgentDeviceIds({ allowedDeviceIds, requestedDeviceIds, con
16
16
  const candidates = requested.length > 0 ? requested : [...allowed];
17
17
  return candidates.filter(deviceId => allowed.has(deviceId) && connected.has(deviceId));
18
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,17 @@ 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
+ verified: false,
73
+ state: 'unavailable'
74
+ };
60
75
  return {
61
- ...current,
76
+ ...exposedSettings,
62
77
  hasApiKey,
63
78
  apiKeyPreview,
64
79
  secretStoreAvailable,
@@ -66,7 +81,8 @@ export function createAgentManager({
66
81
  codexAuth: codex.authenticated,
67
82
  codexStatus: codex.status,
68
83
  codexPath: codex.codexPath,
69
- agentWorkspacePath: current.codexWorkingDirectory
84
+ agentWorkspacePath: current.codexWorkingDirectory,
85
+ securityStatus
70
86
  };
71
87
  }
72
88
 
@@ -19,10 +19,6 @@ const SAFE_ENV_KEYS = [
19
19
  'ComSpec',
20
20
  'TEMP',
21
21
  'TMP',
22
- 'USERPROFILE',
23
- 'HOME',
24
- 'APPDATA',
25
- 'LOCALAPPDATA',
26
22
  'WINDIR',
27
23
  'LANG',
28
24
  'LC_ALL',
@@ -103,6 +99,7 @@ function baseCodexConfig() {
103
99
  web_search: false,
104
100
  skill_mcp_dependency_install: false,
105
101
  memories: false,
102
+ plugins: false,
106
103
  remote_plugin: false
107
104
  },
108
105
  tools: {
@@ -139,21 +136,40 @@ function codexConfig({ mcpServerPath, mcpUrl, token, workspace }) {
139
136
  }
140
137
 
141
138
  function safeCodexEnv(codexHome) {
142
- const env = { CODEX_HOME: 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
+ };
143
146
  for (const key of SAFE_ENV_KEYS) {
144
147
  const value = process.env[key];
145
148
  if (value !== undefined && value !== '') env[key] = value;
146
149
  }
147
- if (!env.HOME) env.HOME = os.homedir();
148
- if (!env.USERPROFILE && process.platform === 'win32') env.USERPROFILE = os.homedir();
149
150
  if (!env.Path && env.PATH) env.Path = env.PATH;
150
151
  if (!env.PATH && env.Path) env.PATH = env.Path;
151
152
  return env;
152
153
  }
153
154
 
154
155
  async function ensureCodexHome(codexHome) {
155
- await mkdir(codexHome, { recursive: true, mode: 0o700 });
156
- const targetAuthPath = path.join(codexHome, 'auth.json');
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');
157
173
  try {
158
174
  await access(targetAuthPath);
159
175
  return;
@@ -162,11 +178,14 @@ async function ensureCodexHome(codexHome) {
162
178
  // CLI-owned auth file when the user is already signed in. The runtime
163
179
  // never reads or copies the credential contents.
164
180
  }
181
+ const sourceAuthPath = path.join(os.homedir(), '.codex', 'auth.json');
182
+ if (!(await access(sourceAuthPath).then(() => true, () => false))) return;
165
183
  try {
166
- await link(path.join(os.homedir(), '.codex', 'auth.json'), targetAuthPath);
167
- } catch {
168
- // A fresh installation may not have a CLI auth file yet. Codex will then
169
- // report the normal auth-required state through getStatus().
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;
170
189
  }
171
190
  }
172
191
 
@@ -207,7 +226,7 @@ function runAgeMs(run) {
207
226
  }
208
227
 
209
228
  function codexAbortError(code, message) {
210
- return new AgentProviderError(code, message, { status: code === 'codex-task-timeout' ? 504 : 409, retryable: code === 'codex-task-timeout' });
229
+ return new AgentProviderError(code, message, { status: code === 'codex-run-timeout' ? 504 : 409, retryable: code === 'codex-run-timeout' });
211
230
  }
212
231
 
213
232
  function safeAgentDeviceIds(value) {
@@ -221,14 +240,55 @@ export function createCodexAgentRuntime({
221
240
  dataDir = path.join(os.homedir(), '.livedesk'),
222
241
  mcpServerPath,
223
242
  createMcpSession,
224
- destroyMcpSession
243
+ destroyMcpSession,
244
+ createCodexClient: injectedCodexClient,
245
+ getCodexStatus: injectedCodexStatus
225
246
  } = {}) {
226
247
  const runs = new Map();
227
248
  let activeCount = 0;
228
249
  let codexModulePromise;
250
+ let securityVerificationState = 'configured';
229
251
  const workspace = path.resolve(dataDir, 'agent-workspace');
230
252
  const codexHome = path.resolve(dataDir, 'codex-home');
231
253
 
254
+ function securityStatus() {
255
+ const configured = codexHome !== path.resolve(os.homedir(), '.codex')
256
+ && typeof createMcpSession === 'function'
257
+ && Boolean(mcpServerPath);
258
+ return {
259
+ isolatedCodexHome: codexHome !== path.resolve(os.homedir(), '.codex'),
260
+ restrictedEnvironment: true,
261
+ livedeskMcpOnly: typeof createMcpSession === 'function' && Boolean(mcpServerPath),
262
+ selectedDeviceScopeEnforced: typeof createMcpSession === 'function',
263
+ failClosed: true,
264
+ verified: configured && securityVerificationState === 'verified',
265
+ state: configured ? securityVerificationState : 'unavailable'
266
+ };
267
+ }
268
+
269
+ async function prepareCodexHome() {
270
+ try {
271
+ await ensureCodexHome(codexHome);
272
+ securityVerificationState = 'verified';
273
+ } catch (error) {
274
+ securityVerificationState = 'unavailable';
275
+ throw error;
276
+ }
277
+ }
278
+
279
+ function assertSecurityConfiguration() {
280
+ const status = securityStatus();
281
+ if (!status.isolatedCodexHome || !status.restrictedEnvironment || !status.livedeskMcpOnly || !status.selectedDeviceScopeEnforced) {
282
+ throw new AgentProviderError('codex-unsupported-security-config', 'Codex security isolation is not available.', { status: 503 });
283
+ }
284
+ }
285
+
286
+ async function createCodexClient(options) {
287
+ if (typeof injectedCodexClient === 'function') return injectedCodexClient(options);
288
+ const { Codex } = await loadCodex();
289
+ return new Codex(options);
290
+ }
291
+
232
292
  function scheduleRunCleanup(run) {
233
293
  const timer = setTimeout(() => {
234
294
  if (runs.get(run.runId) === run && isTerminalStatus(run.status) && runAgeMs(run) >= RUN_TTL_MS) {
@@ -268,6 +328,7 @@ export function createCodexAgentRuntime({
268
328
  }
269
329
 
270
330
  async function getStatus() {
331
+ if (typeof injectedCodexStatus === 'function') return injectedCodexStatus();
271
332
  const pathToCli = cliPath();
272
333
  if (!pathToCli) {
273
334
  return { installed: false, authenticated: 'unknown', status: 'not-installed', codexPath: '' };
@@ -275,28 +336,29 @@ export function createCodexAgentRuntime({
275
336
  let authenticated = 'unknown';
276
337
  let detail = '';
277
338
  try {
278
- await ensureCodexHome(codexHome);
339
+ await prepareCodexHome();
279
340
  const result = await runCli(pathToCli, ['login', 'status'], 5000, safeCodexEnv(codexHome));
280
341
  detail = safeText(result.stdout || result.stderr, 300);
281
342
  authenticated = /logged in|authenticated|chatgpt/i.test(`${result.stdout}\n${result.stderr}`)
282
343
  ? 'signed-in'
283
344
  : 'not-signed-in';
284
345
  } catch (error) {
346
+ if (error instanceof AgentProviderError) throw error;
285
347
  detail = safeText(error?.message, 300);
286
348
  }
287
349
  return { installed: true, authenticated, status: authenticated === 'signed-in' ? 'ready' : 'auth-required', codexPath: pathToCli, detail };
288
350
  }
289
351
 
290
352
  async function testConnection() {
353
+ assertSecurityConfiguration();
291
354
  const startedAt = Date.now();
292
355
  const status = await getStatus();
293
356
  if (!status.installed) throw new AgentProviderError('codex-sdk-not-installed', 'Codex SDK is not installed.', { status: 503 });
294
357
  if (status.authenticated !== 'signed-in') throw new AgentProviderError('codex-auth-required', 'Sign in with the Codex CLI first.', { status: 401 });
295
358
  const settings = await settingsStore.get();
296
359
  await mkdir(workspace, { recursive: true });
297
- await ensureCodexHome(codexHome);
298
- const { Codex } = await loadCodex();
299
- const codex = new Codex({ env: safeCodexEnv(codexHome), config: baseCodexConfig() });
360
+ await prepareCodexHome();
361
+ const codex = await createCodexClient({ env: safeCodexEnv(codexHome), config: baseCodexConfig() });
300
362
  const controller = new AbortController();
301
363
  const timer = setTimeout(() => controller.abort(), Math.min(30000, settings.codexTaskTimeoutMs));
302
364
  try {
@@ -359,6 +421,7 @@ export function createCodexAgentRuntime({
359
421
  let session;
360
422
  let timeoutTimer;
361
423
  try {
424
+ assertSecurityConfiguration();
362
425
  const settings = await settingsStore.get();
363
426
  const timeoutMs = Math.max(1000, Number(settings.codexTaskTimeoutMs) || 600000);
364
427
  timeoutTimer = setTimeout(() => {
@@ -372,22 +435,21 @@ export function createCodexAgentRuntime({
372
435
  throw new AgentProviderError('agent-concurrency-limit', 'The LiveDesk Agent concurrency limit is reached.', { status: 429, retryable: true });
373
436
  }
374
437
  if (run.abortController.signal.aborted) {
375
- if (run.abortReason === 'timeout') throw codexAbortError('codex-task-timeout', 'The Codex task exceeded its timeout.');
438
+ if (run.abortReason === 'timeout') throw codexAbortError('codex-run-timeout', 'The Codex task exceeded its timeout.');
376
439
  throw Object.assign(new Error('The Codex task was cancelled.'), { name: 'AbortError' });
377
440
  }
378
441
  const status = await getStatus();
379
442
  if (!status.installed) throw new AgentProviderError('codex-sdk-not-installed', 'Codex SDK is not installed.', { status: 503 });
380
443
  if (status.authenticated !== 'signed-in') throw new AgentProviderError('codex-auth-required', 'Sign in with the Codex CLI first.', { status: 401 });
381
- session = createMcpSession({ runId: run.runId, signal: run.abortController.signal, allowedDeviceIds: input.deviceIds });
444
+ session = createMcpSession({ runId: run.runId, signal: run.abortController.signal, allowedDeviceIds: input.deviceIds, maxToolCalls: settings.codexMaxToolCalls });
382
445
  run.mcpToken = session.token;
383
446
  if (run.abortController.signal.aborted) {
384
447
  session.cancel();
385
- if (run.abortReason === 'timeout') throw codexAbortError('codex-task-timeout', 'The Codex task exceeded its timeout.');
448
+ if (run.abortReason === 'timeout') throw codexAbortError('codex-run-timeout', 'The Codex task exceeded its timeout.');
386
449
  throw Object.assign(new Error('The Codex task was cancelled.'), { name: 'AbortError' });
387
450
  }
388
- await ensureCodexHome(codexHome);
389
- const { Codex } = await loadCodex();
390
- const codex = new Codex({ env: safeCodexEnv(codexHome), config: codexConfig({ mcpServerPath, mcpUrl: session.url, token: session.token, workspace }) });
451
+ await prepareCodexHome();
452
+ const codex = await createCodexClient({ env: safeCodexEnv(codexHome), config: codexConfig({ mcpServerPath, mcpUrl: session.url, token: session.token, workspace }) });
391
453
  const threadOptions = codexThreadOptions(settings, workspace);
392
454
  const thread = input.resumeThreadId && settings.codexResumeSessions
393
455
  ? codex.resumeThread(String(input.resumeThreadId), threadOptions)
@@ -397,6 +459,15 @@ export function createCodexAgentRuntime({
397
459
  const streamed = await thread.runStreamed(promptFor({ instruction: run.instruction, deviceIds: input.deviceIds }), { signal: run.abortController.signal });
398
460
  for await (const event of streamed.events) {
399
461
  emit(run, event);
462
+ if (event.type === 'turn.started') {
463
+ run.turnCount += 1;
464
+ if (run.turnCount > settings.codexMaxTurns) {
465
+ run.abortReason = 'turn-limit';
466
+ run.abortController.abort();
467
+ session.cancel();
468
+ throw new AgentProviderError('codex-turn-limit-reached', 'Codex exceeded the LiveDesk turn limit.', { status: 409 });
469
+ }
470
+ }
400
471
  if (run.toolCallCount > settings.codexMaxToolCalls) {
401
472
  run.abortReason = 'tool-limit';
402
473
  run.abortController.abort();
@@ -412,10 +483,13 @@ export function createCodexAgentRuntime({
412
483
  } catch (error) {
413
484
  if (run.abortReason === 'timeout') {
414
485
  run.status = 'failed';
415
- run.error = 'codex-task-timeout';
486
+ run.error = 'codex-run-timeout';
416
487
  } else if (run.abortReason === 'tool-limit') {
417
488
  run.status = 'failed';
418
489
  run.error = 'codex-tool-limit-reached';
490
+ } else if (run.abortReason === 'turn-limit') {
491
+ run.status = 'failed';
492
+ run.error = 'codex-turn-limit-reached';
419
493
  } else if (isAbortError(error) || run.abortController.signal.aborted) {
420
494
  run.status = 'cancelled';
421
495
  run.error = 'cancelled-by-user';
@@ -439,8 +513,11 @@ export function createCodexAgentRuntime({
439
513
  async function start(input = {}) {
440
514
  const instruction = safeText(input.instruction, 4000);
441
515
  if (!instruction) throw new AgentProviderError('agent-invalid-instruction', 'Instruction is required.', { status: 400 });
516
+ const deviceIds = safeAgentDeviceIds(input.deviceIds);
517
+ if (deviceIds.length === 0) throw new AgentProviderError('agent-no-target-devices', 'Select at least one connected device.', { status: 400 });
442
518
  const settings = await settingsStore.get();
443
519
  if (!settings.enabled) throw new AgentProviderError('agent-ai-disabled', 'Agent AI is disabled in Settings.', { status: 409 });
520
+ assertSecurityConfiguration();
444
521
  pruneRuns();
445
522
  if (runs.size >= MAX_RUNS) throw new AgentProviderError('agent-run-limit', 'The LiveDesk Agent run history is full.', { status: 429, retryable: true });
446
523
  const run = {
@@ -454,6 +531,7 @@ export function createCodexAgentRuntime({
454
531
  createdAt: new Date().toISOString(),
455
532
  updatedAt: new Date().toISOString(),
456
533
  toolCallCount: 0,
534
+ turnCount: 0,
457
535
  batchIds: new Set(),
458
536
  events: [],
459
537
  abortController: new AbortController(),
@@ -461,7 +539,7 @@ export function createCodexAgentRuntime({
461
539
  mcpToken: ''
462
540
  };
463
541
  runs.set(run.runId, run);
464
- void execute(run, { deviceIds: safeAgentDeviceIds(input.deviceIds), resumeThreadId: input.resumeThreadId });
542
+ void execute(run, { deviceIds, resumeThreadId: input.resumeThreadId });
465
543
  return publicRun(run);
466
544
  }
467
545
 
@@ -482,5 +560,5 @@ export function createCodexAgentRuntime({
482
560
  return publicRun(run);
483
561
  }
484
562
 
485
- return { getStatus, testConnection, start, get, cancel, workspace, codexHome };
563
+ return { getStatus, testConnection, start, get, cancel, workspace, codexHome, getSecurityStatus: securityStatus };
486
564
  }
@@ -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' : '');
package/hub/src/server.js CHANGED
@@ -16,7 +16,8 @@ import { toFilesystemHttpError } from './filesystem/directory-reader.js';
16
16
  import { createAgentManager } from './agents/agent-manager.js';
17
17
  import { AgentSettingsStore } from './agents/agent-settings.js';
18
18
  import { createCodexAgentRuntime } from './agents/codex-agent-runtime.js';
19
- import { createAgentDeviceScope, selectAgentDeviceIds } from './agents/agent-device-scope.js';
19
+ import { createAgentDeviceScope, resolveAgentTargetIds } from './agents/agent-device-scope.js';
20
+ import { AgentProviderError } from './agents/provider-errors.js';
20
21
 
21
22
  const __dirname = dirname(fileURLToPath(import.meta.url));
22
23
  const webDistCandidates = [
@@ -214,6 +215,8 @@ const agentMcpRunBatches = new Map();
214
215
  const agentMcpRunCleanupTimers = new Map();
215
216
  const AGENT_MCP_RUN_TTL_MS = 60 * 60 * 1000;
216
217
  const MAX_AGENT_MCP_RUN_RECORDS = 500;
218
+ const DEFAULT_AGENT_MCP_TOOL_CALL_LIMIT = 20;
219
+ const MAX_AGENT_MCP_TOOL_CALL_LIMIT = 100;
217
220
 
218
221
  function connectedAgentDeviceIds() {
219
222
  const devices = remoteHub.listDevices({ includeDataUrl: false });
@@ -246,15 +249,28 @@ function scheduleAgentMcpRunCleanup(runId) {
246
249
  agentMcpRunCleanupTimers.set(runId, timer);
247
250
  }
248
251
 
249
- function createAgentMcpSession({ runId, signal, allowedDeviceIds: allowedDeviceIdsInput = [] }) {
252
+ function normalizeAgentMcpToolCallLimit(value) {
253
+ const limit = Number(value);
254
+ if (!Number.isFinite(limit)) return DEFAULT_AGENT_MCP_TOOL_CALL_LIMIT;
255
+ return Math.min(MAX_AGENT_MCP_TOOL_CALL_LIMIT, Math.max(1, Math.floor(limit)));
256
+ }
257
+
258
+ function createAgentMcpSession({ runId, signal, allowedDeviceIds: allowedDeviceIdsInput = [], maxToolCalls }) {
250
259
  const token = crypto.randomBytes(32).toString('hex');
251
- const allowedDeviceIds = createAgentDeviceScope(allowedDeviceIdsInput, connectedAgentDeviceIds());
260
+ const allowedDeviceIds = createAgentDeviceScope(allowedDeviceIdsInput);
261
+ const initialResolution = resolveAgentTargetIds({ allowedDeviceIds, connectedDeviceIds: connectedAgentDeviceIds() });
262
+ if (initialResolution.deviceIds.length === 0) {
263
+ throw new AgentProviderError(initialResolution.error || 'agent-no-target-devices', 'No selected connected device is available.', { status: 400 });
264
+ }
252
265
  pruneAgentMcpRunBatches();
253
266
  const session = {
254
267
  token,
255
268
  runId: String(runId || ''),
256
269
  signal,
257
270
  allowedDeviceIds,
271
+ toolCallCount: 0,
272
+ maxToolCalls: normalizeAgentMcpToolCallLimit(maxToolCalls),
273
+ toolLimitReached: false,
258
274
  cancelled: false,
259
275
  cancel() {
260
276
  if (this.cancelled) return;
@@ -278,16 +294,41 @@ function delayAgentMcp(ms) {
278
294
  return new Promise(resolve => setTimeout(resolve, ms));
279
295
  }
280
296
 
297
+ function validateAgentMcpArguments(name, args) {
298
+ const value = args && typeof args === 'object' && !Array.isArray(args) ? args : null;
299
+ if (!value) return 'agent-tool-arguments-invalid';
300
+ if (value.deviceIds !== undefined) {
301
+ if (!Array.isArray(value.deviceIds) || value.deviceIds.length > 500 || value.deviceIds.some(item => typeof item !== 'string' || !item.trim())) {
302
+ return 'agent-tool-arguments-invalid';
303
+ }
304
+ }
305
+ if (name === 'livedesk.list_processes' && value.processName !== undefined
306
+ && (typeof value.processName !== 'string' || value.processName.length > 120)) return 'agent-tool-arguments-invalid';
307
+ if (name === 'livedesk.get_service_status' && value.serviceName !== undefined
308
+ && (typeof value.serviceName !== 'string' || value.serviceName.length > 120)) return 'agent-tool-arguments-invalid';
309
+ return '';
310
+ }
311
+
281
312
  async function dispatchAgentMcpTool(session, name, args = {}) {
282
313
  if (session.cancelled || session.signal?.aborted) return { ok: false, error: 'cancelled-by-user' };
314
+ // This check runs synchronously before the first await, so concurrent Node
315
+ // requests cannot pass the same remaining budget and queue extra Client work.
316
+ if (session.toolCallCount >= session.maxToolCalls) {
317
+ session.toolLimitReached = true;
318
+ return { ok: false, error: 'codex-tool-limit-reached' };
319
+ }
320
+ session.toolCallCount += 1;
321
+ const validationError = validateAgentMcpArguments(name, args);
322
+ if (validationError) return { ok: false, error: validationError };
283
323
  if (name === 'livedesk.list_devices') {
284
- const requested = selectAgentDeviceIds({
324
+ const resolution = resolveAgentTargetIds({
285
325
  allowedDeviceIds: session.allowedDeviceIds,
286
326
  requestedDeviceIds: args.deviceIds,
287
327
  connectedDeviceIds: connectedAgentDeviceIds()
288
328
  });
329
+ if (resolution.deviceIds.length === 0) return { ok: false, error: resolution.error };
289
330
  const devices = remoteHub.listDevices({ includeDataUrl: false })
290
- .filter(device => requested.includes(device.deviceId))
331
+ .filter(device => resolution.deviceIds.includes(device.deviceId))
291
332
  .slice(0, 500)
292
333
  .map(device => ({ deviceId: device.deviceId, deviceName: device.deviceName, hostname: device.hostname, connected: device.connected === true, platform: device.platform, capabilities: device.capabilities }));
293
334
  return { ok: true, devices };
@@ -301,13 +342,14 @@ async function dispatchAgentMcpTool(session, name, args = {}) {
301
342
  'livedesk.collect_diagnostics': 'diagnostics.collect'
302
343
  };
303
344
  const operation = operationByTool[name];
304
- if (!operation) return { ok: false, error: 'unsupported-livedesk-tool' };
305
- const targetIds = selectAgentDeviceIds({
345
+ if (!operation) return { ok: false, error: 'codex-tool-not-allowed' };
346
+ const resolution = resolveAgentTargetIds({
306
347
  allowedDeviceIds: session.allowedDeviceIds,
307
348
  requestedDeviceIds: args.deviceIds,
308
349
  connectedDeviceIds: connectedAgentDeviceIds()
309
350
  });
310
- if (targetIds.length === 0) return { ok: false, error: 'no-target-devices' };
351
+ const targetIds = resolution.deviceIds;
352
+ if (targetIds.length === 0) return { ok: false, error: resolution.error };
311
353
  const targetQuery = operation === 'process.list'
312
354
  ? String(args.processName || '').replace(/[\0\r\n]/g, ' ').trim().slice(0, 120)
313
355
  : operation === 'service.status'
@@ -475,7 +517,12 @@ function sendAgentError(res, error) {
475
517
  const code = String(error?.code || 'agent-request-failed').replace(/[^a-z0-9-]/gi, '-').toLowerCase().slice(0, 80);
476
518
  const status = Number.isInteger(error?.status) && error.status >= 400 && error.status <= 599
477
519
  ? error.status
478
- : code === 'agent-api-key-missing' || code === 'agent-api-key-invalid' || code === 'codex-auth-required' ? 401 : 502;
520
+ : code === 'agent-api-key-missing' || code === 'agent-api-key-invalid' || code === 'codex-auth-required' ? 401
521
+ : code === 'agent-no-target-devices' || code === 'agent-target-outside-selection' || code === 'codex-tool-not-allowed' ? 400
522
+ : code === 'agent-mcp-loopback-only' ? 403
523
+ : code === 'codex-isolation-unavailable' || code === 'codex-environment-isolation-failed' || code === 'codex-unsupported-security-config' ? 503
524
+ : code === 'codex-run-timeout' ? 504
525
+ : 502;
479
526
  res.status(status).json({ ok: false, error: code });
480
527
  }
481
528
 
@@ -1351,9 +1398,13 @@ app.post('/api/settings/agent/test', async (_req, res) => {
1351
1398
 
1352
1399
  app.post('/api/internal/agent-mcp/tool', async (req, res) => {
1353
1400
  noStore(res);
1401
+ if (!isLoopbackAddress(req.socket?.remoteAddress)) {
1402
+ res.status(403).json({ ok: false, error: 'agent-mcp-loopback-only' });
1403
+ return;
1404
+ }
1354
1405
  const authorization = String(req.headers.authorization || '');
1355
1406
  const token = authorization.startsWith('Bearer ') ? authorization.slice(7).trim() : '';
1356
- const session = agentMcpSessions.get(token);
1407
+ const session = /^[a-f0-9]{64}$/i.test(token) ? agentMcpSessions.get(token) : null;
1357
1408
  if (!session || session.cancelled || session.signal?.aborted) {
1358
1409
  res.status(401).json({ ok: false, error: 'agent-mcp-session-invalid' });
1359
1410
  return;
@@ -1365,7 +1416,7 @@ app.post('/api/internal/agent-mcp/tool', async (req, res) => {
1365
1416
  try {
1366
1417
  const name = String(req.body?.name || '').slice(0, 120);
1367
1418
  const result = await dispatchAgentMcpTool(session, name, req.body?.arguments || {});
1368
- res.json({ ok: result.ok !== false, result });
1419
+ res.status(result.ok === false ? 400 : 200).json({ ok: result.ok !== false, error: result.ok === false ? result.error : undefined, result });
1369
1420
  } catch {
1370
1421
  res.status(502).json({ ok: false, error: 'agent-mcp-tool-failed' });
1371
1422
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livedesk",
3
- "version": "0.1.206",
3
+ "version": "0.1.208",
4
4
  "description": "LiveDesk Hub and client launcher",
5
5
  "type": "module",
6
6
  "bin": {