livedesk 0.1.206 → 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.
@@ -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,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
 
@@ -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,7 +240,9 @@ 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;
@@ -229,6 +250,29 @@ export function createCodexAgentRuntime({
229
250
  const workspace = path.resolve(dataDir, 'agent-workspace');
230
251
  const codexHome = path.resolve(dataDir, 'codex-home');
231
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
+
232
276
  function scheduleRunCleanup(run) {
233
277
  const timer = setTimeout(() => {
234
278
  if (runs.get(run.runId) === run && isTerminalStatus(run.status) && runAgeMs(run) >= RUN_TTL_MS) {
@@ -268,6 +312,7 @@ export function createCodexAgentRuntime({
268
312
  }
269
313
 
270
314
  async function getStatus() {
315
+ if (typeof injectedCodexStatus === 'function') return injectedCodexStatus();
271
316
  const pathToCli = cliPath();
272
317
  if (!pathToCli) {
273
318
  return { installed: false, authenticated: 'unknown', status: 'not-installed', codexPath: '' };
@@ -282,12 +327,14 @@ export function createCodexAgentRuntime({
282
327
  ? 'signed-in'
283
328
  : 'not-signed-in';
284
329
  } catch (error) {
330
+ if (error instanceof AgentProviderError) throw error;
285
331
  detail = safeText(error?.message, 300);
286
332
  }
287
333
  return { installed: true, authenticated, status: authenticated === 'signed-in' ? 'ready' : 'auth-required', codexPath: pathToCli, detail };
288
334
  }
289
335
 
290
336
  async function testConnection() {
337
+ assertSecurityConfiguration();
291
338
  const startedAt = Date.now();
292
339
  const status = await getStatus();
293
340
  if (!status.installed) throw new AgentProviderError('codex-sdk-not-installed', 'Codex SDK is not installed.', { status: 503 });
@@ -295,8 +342,7 @@ export function createCodexAgentRuntime({
295
342
  const settings = await settingsStore.get();
296
343
  await mkdir(workspace, { recursive: true });
297
344
  await ensureCodexHome(codexHome);
298
- const { Codex } = await loadCodex();
299
- const codex = new Codex({ env: safeCodexEnv(codexHome), config: baseCodexConfig() });
345
+ const codex = await createCodexClient({ env: safeCodexEnv(codexHome), config: baseCodexConfig() });
300
346
  const controller = new AbortController();
301
347
  const timer = setTimeout(() => controller.abort(), Math.min(30000, settings.codexTaskTimeoutMs));
302
348
  try {
@@ -359,6 +405,7 @@ export function createCodexAgentRuntime({
359
405
  let session;
360
406
  let timeoutTimer;
361
407
  try {
408
+ assertSecurityConfiguration();
362
409
  const settings = await settingsStore.get();
363
410
  const timeoutMs = Math.max(1000, Number(settings.codexTaskTimeoutMs) || 600000);
364
411
  timeoutTimer = setTimeout(() => {
@@ -372,7 +419,7 @@ export function createCodexAgentRuntime({
372
419
  throw new AgentProviderError('agent-concurrency-limit', 'The LiveDesk Agent concurrency limit is reached.', { status: 429, retryable: true });
373
420
  }
374
421
  if (run.abortController.signal.aborted) {
375
- if (run.abortReason === 'timeout') throw codexAbortError('codex-task-timeout', 'The Codex task exceeded its timeout.');
422
+ if (run.abortReason === 'timeout') throw codexAbortError('codex-run-timeout', 'The Codex task exceeded its timeout.');
376
423
  throw Object.assign(new Error('The Codex task was cancelled.'), { name: 'AbortError' });
377
424
  }
378
425
  const status = await getStatus();
@@ -382,12 +429,11 @@ export function createCodexAgentRuntime({
382
429
  run.mcpToken = session.token;
383
430
  if (run.abortController.signal.aborted) {
384
431
  session.cancel();
385
- if (run.abortReason === 'timeout') throw codexAbortError('codex-task-timeout', 'The Codex task exceeded its timeout.');
432
+ if (run.abortReason === 'timeout') throw codexAbortError('codex-run-timeout', 'The Codex task exceeded its timeout.');
386
433
  throw Object.assign(new Error('The Codex task was cancelled.'), { name: 'AbortError' });
387
434
  }
388
435
  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 }) });
436
+ const codex = await createCodexClient({ env: safeCodexEnv(codexHome), config: codexConfig({ mcpServerPath, mcpUrl: session.url, token: session.token, workspace }) });
391
437
  const threadOptions = codexThreadOptions(settings, workspace);
392
438
  const thread = input.resumeThreadId && settings.codexResumeSessions
393
439
  ? codex.resumeThread(String(input.resumeThreadId), threadOptions)
@@ -397,6 +443,15 @@ export function createCodexAgentRuntime({
397
443
  const streamed = await thread.runStreamed(promptFor({ instruction: run.instruction, deviceIds: input.deviceIds }), { signal: run.abortController.signal });
398
444
  for await (const event of streamed.events) {
399
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
+ }
400
455
  if (run.toolCallCount > settings.codexMaxToolCalls) {
401
456
  run.abortReason = 'tool-limit';
402
457
  run.abortController.abort();
@@ -412,10 +467,13 @@ export function createCodexAgentRuntime({
412
467
  } catch (error) {
413
468
  if (run.abortReason === 'timeout') {
414
469
  run.status = 'failed';
415
- run.error = 'codex-task-timeout';
470
+ run.error = 'codex-run-timeout';
416
471
  } else if (run.abortReason === 'tool-limit') {
417
472
  run.status = 'failed';
418
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';
419
477
  } else if (isAbortError(error) || run.abortController.signal.aborted) {
420
478
  run.status = 'cancelled';
421
479
  run.error = 'cancelled-by-user';
@@ -439,8 +497,11 @@ export function createCodexAgentRuntime({
439
497
  async function start(input = {}) {
440
498
  const instruction = safeText(input.instruction, 4000);
441
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 });
442
502
  const settings = await settingsStore.get();
443
503
  if (!settings.enabled) throw new AgentProviderError('agent-ai-disabled', 'Agent AI is disabled in Settings.', { status: 409 });
504
+ assertSecurityConfiguration();
444
505
  pruneRuns();
445
506
  if (runs.size >= MAX_RUNS) throw new AgentProviderError('agent-run-limit', 'The LiveDesk Agent run history is full.', { status: 429, retryable: true });
446
507
  const run = {
@@ -454,6 +515,7 @@ export function createCodexAgentRuntime({
454
515
  createdAt: new Date().toISOString(),
455
516
  updatedAt: new Date().toISOString(),
456
517
  toolCallCount: 0,
518
+ turnCount: 0,
457
519
  batchIds: new Set(),
458
520
  events: [],
459
521
  abortController: new AbortController(),
@@ -461,7 +523,7 @@ export function createCodexAgentRuntime({
461
523
  mcpToken: ''
462
524
  };
463
525
  runs.set(run.runId, run);
464
- void execute(run, { deviceIds: safeAgentDeviceIds(input.deviceIds), resumeThreadId: input.resumeThreadId });
526
+ void execute(run, { deviceIds, resumeThreadId: input.resumeThreadId });
465
527
  return publicRun(run);
466
528
  }
467
529
 
@@ -482,5 +544,5 @@ export function createCodexAgentRuntime({
482
544
  return publicRun(run);
483
545
  }
484
546
 
485
- return { getStatus, testConnection, start, get, cancel, workspace, codexHome };
547
+ return { getStatus, testConnection, start, get, cancel, workspace, codexHome, getSecurityStatus: securityStatus };
486
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' : '');
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 = [
@@ -248,7 +249,11 @@ function scheduleAgentMcpRunCleanup(runId) {
248
249
 
249
250
  function createAgentMcpSession({ runId, signal, allowedDeviceIds: allowedDeviceIdsInput = [] }) {
250
251
  const token = crypto.randomBytes(32).toString('hex');
251
- const allowedDeviceIds = createAgentDeviceScope(allowedDeviceIdsInput, connectedAgentDeviceIds());
252
+ const allowedDeviceIds = createAgentDeviceScope(allowedDeviceIdsInput);
253
+ const initialResolution = resolveAgentTargetIds({ allowedDeviceIds, connectedDeviceIds: connectedAgentDeviceIds() });
254
+ if (initialResolution.deviceIds.length === 0) {
255
+ throw new AgentProviderError(initialResolution.error || 'agent-no-target-devices', 'No selected connected device is available.', { status: 400 });
256
+ }
252
257
  pruneAgentMcpRunBatches();
253
258
  const session = {
254
259
  token,
@@ -278,16 +283,34 @@ function delayAgentMcp(ms) {
278
283
  return new Promise(resolve => setTimeout(resolve, ms));
279
284
  }
280
285
 
286
+ function validateAgentMcpArguments(name, args) {
287
+ const value = args && typeof args === 'object' && !Array.isArray(args) ? args : null;
288
+ if (!value) return 'agent-tool-arguments-invalid';
289
+ if (value.deviceIds !== undefined) {
290
+ if (!Array.isArray(value.deviceIds) || value.deviceIds.length > 500 || value.deviceIds.some(item => typeof item !== 'string' || !item.trim())) {
291
+ return 'agent-tool-arguments-invalid';
292
+ }
293
+ }
294
+ if (name === 'livedesk.list_processes' && value.processName !== undefined
295
+ && (typeof value.processName !== 'string' || value.processName.length > 120)) return 'agent-tool-arguments-invalid';
296
+ if (name === 'livedesk.get_service_status' && value.serviceName !== undefined
297
+ && (typeof value.serviceName !== 'string' || value.serviceName.length > 120)) return 'agent-tool-arguments-invalid';
298
+ return '';
299
+ }
300
+
281
301
  async function dispatchAgentMcpTool(session, name, args = {}) {
282
302
  if (session.cancelled || session.signal?.aborted) return { ok: false, error: 'cancelled-by-user' };
303
+ const validationError = validateAgentMcpArguments(name, args);
304
+ if (validationError) return { ok: false, error: validationError };
283
305
  if (name === 'livedesk.list_devices') {
284
- const requested = selectAgentDeviceIds({
306
+ const resolution = resolveAgentTargetIds({
285
307
  allowedDeviceIds: session.allowedDeviceIds,
286
308
  requestedDeviceIds: args.deviceIds,
287
309
  connectedDeviceIds: connectedAgentDeviceIds()
288
310
  });
311
+ if (resolution.deviceIds.length === 0) return { ok: false, error: resolution.error };
289
312
  const devices = remoteHub.listDevices({ includeDataUrl: false })
290
- .filter(device => requested.includes(device.deviceId))
313
+ .filter(device => resolution.deviceIds.includes(device.deviceId))
291
314
  .slice(0, 500)
292
315
  .map(device => ({ deviceId: device.deviceId, deviceName: device.deviceName, hostname: device.hostname, connected: device.connected === true, platform: device.platform, capabilities: device.capabilities }));
293
316
  return { ok: true, devices };
@@ -301,13 +324,14 @@ async function dispatchAgentMcpTool(session, name, args = {}) {
301
324
  'livedesk.collect_diagnostics': 'diagnostics.collect'
302
325
  };
303
326
  const operation = operationByTool[name];
304
- if (!operation) return { ok: false, error: 'unsupported-livedesk-tool' };
305
- const targetIds = selectAgentDeviceIds({
327
+ if (!operation) return { ok: false, error: 'codex-tool-not-allowed' };
328
+ const resolution = resolveAgentTargetIds({
306
329
  allowedDeviceIds: session.allowedDeviceIds,
307
330
  requestedDeviceIds: args.deviceIds,
308
331
  connectedDeviceIds: connectedAgentDeviceIds()
309
332
  });
310
- if (targetIds.length === 0) return { ok: false, error: 'no-target-devices' };
333
+ const targetIds = resolution.deviceIds;
334
+ if (targetIds.length === 0) return { ok: false, error: resolution.error };
311
335
  const targetQuery = operation === 'process.list'
312
336
  ? String(args.processName || '').replace(/[\0\r\n]/g, ' ').trim().slice(0, 120)
313
337
  : operation === 'service.status'
@@ -475,7 +499,12 @@ function sendAgentError(res, error) {
475
499
  const code = String(error?.code || 'agent-request-failed').replace(/[^a-z0-9-]/gi, '-').toLowerCase().slice(0, 80);
476
500
  const status = Number.isInteger(error?.status) && error.status >= 400 && error.status <= 599
477
501
  ? error.status
478
- : code === 'agent-api-key-missing' || code === 'agent-api-key-invalid' || code === 'codex-auth-required' ? 401 : 502;
502
+ : code === 'agent-api-key-missing' || code === 'agent-api-key-invalid' || code === 'codex-auth-required' ? 401
503
+ : code === 'agent-no-target-devices' || code === 'agent-target-outside-selection' || code === 'codex-tool-not-allowed' ? 400
504
+ : code === 'agent-mcp-loopback-only' ? 403
505
+ : code === 'codex-isolation-unavailable' || code === 'codex-environment-isolation-failed' || code === 'codex-unsupported-security-config' ? 503
506
+ : code === 'codex-run-timeout' ? 504
507
+ : 502;
479
508
  res.status(status).json({ ok: false, error: code });
480
509
  }
481
510
 
@@ -1351,9 +1380,13 @@ app.post('/api/settings/agent/test', async (_req, res) => {
1351
1380
 
1352
1381
  app.post('/api/internal/agent-mcp/tool', async (req, res) => {
1353
1382
  noStore(res);
1383
+ if (!isLoopbackAddress(req.socket?.remoteAddress)) {
1384
+ res.status(403).json({ ok: false, error: 'agent-mcp-loopback-only' });
1385
+ return;
1386
+ }
1354
1387
  const authorization = String(req.headers.authorization || '');
1355
1388
  const token = authorization.startsWith('Bearer ') ? authorization.slice(7).trim() : '';
1356
- const session = agentMcpSessions.get(token);
1389
+ const session = /^[a-f0-9]{64}$/i.test(token) ? agentMcpSessions.get(token) : null;
1357
1390
  if (!session || session.cancelled || session.signal?.aborted) {
1358
1391
  res.status(401).json({ ok: false, error: 'agent-mcp-session-invalid' });
1359
1392
  return;
@@ -1365,7 +1398,7 @@ app.post('/api/internal/agent-mcp/tool', async (req, res) => {
1365
1398
  try {
1366
1399
  const name = String(req.body?.name || '').slice(0, 120);
1367
1400
  const result = await dispatchAgentMcpTool(session, name, req.body?.arguments || {});
1368
- res.json({ ok: result.ok !== false, result });
1401
+ res.status(result.ok === false ? 400 : 200).json({ ok: result.ok !== false, error: result.ok === false ? result.error : undefined, result });
1369
1402
  } catch {
1370
1403
  res.status(502).json({ ok: false, error: 'agent-mcp-tool-failed' });
1371
1404
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livedesk",
3
- "version": "0.1.206",
3
+ "version": "0.1.207",
4
4
  "description": "LiveDesk Hub and client launcher",
5
5
  "type": "module",
6
6
  "bin": {