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.
- package/hub/src/agents/agent-device-scope.js +11 -0
- package/hub/src/agents/agent-manager.js +19 -3
- package/hub/src/agents/codex-agent-runtime.js +106 -28
- package/hub/src/remote-hub.js +13 -1
- package/hub/src/server.js +62 -11
- package/package.json +1 -1
- package/web/dist/assets/index-BIAVgQv7.js +15 -0
- package/web/dist/assets/{index-DIA3wv8F.css → index-CtAsVGFt.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BZn8XWvN.js +0 -15
|
@@ -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
|
-
|
|
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
|
-
...
|
|
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 = {
|
|
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
|
-
|
|
156
|
-
const
|
|
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(
|
|
167
|
-
} catch {
|
|
168
|
-
|
|
169
|
-
|
|
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-
|
|
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
|
|
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
|
|
298
|
-
const
|
|
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-
|
|
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-
|
|
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
|
|
389
|
-
const
|
|
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-
|
|
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
|
|
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
|
}
|
package/hub/src/remote-hub.js
CHANGED
|
@@ -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.
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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 =>
|
|
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: '
|
|
305
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
}
|