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