@kingsoftcloud/agentkit-personal-assistant 0.0.0-stage → 0.1.6
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/LICENSE +201 -0
- package/NOTICE +7 -0
- package/README.md +38 -2
- package/cordis.patch.yml +6 -0
- package/dist/client.js +6 -0
- package/package.json +83 -4
- package/personal-assistant.plugin.json +31 -0
- package/personal-assistant.plugin.schema.json +43 -0
- package/runtime/assistant-runtime.mjs +317 -0
- package/runtime/check-manifest.mjs +21 -0
- package/runtime/cloud-browser-cdp.mjs +313 -0
- package/runtime/cloud-browser.mjs +972 -0
- package/runtime/cloud-execution.mjs +320 -0
- package/runtime/cloud-session-channel.mjs +45 -0
- package/runtime/computer-agentd.mjs +488 -0
- package/runtime/desktop-clipboard-limit.mjs +2 -0
- package/runtime/desktop-launcher.mjs +106 -0
- package/runtime/desktop-permit.mjs +42 -0
- package/runtime/desktop-session.mjs +840 -0
- package/runtime/desktop-ws.mjs +371 -0
- package/runtime/prepare-dsh-profile.mjs +17 -0
- package/runtime/welcome-server.mjs +144 -0
- package/scripts/assistant-runtime-launcher.sh +3 -0
- package/scripts/build-artifacts.mjs +81 -0
- package/scripts/build-client.mjs +82 -0
- package/scripts/computer-agentd-launcher.sh +3 -0
- package/scripts/deterministic_tar.py +21 -0
- package/scripts/install-local.mjs +262 -0
- package/scripts/scan-secrets.mjs +41 -0
- package/scripts/sync-public.mjs +52 -0
- package/scripts/verification/gateway-desktop-client.mjs +142 -0
- package/scripts/verification/verify-desktop-applications-linux.mjs +46 -0
- package/scripts/verification/verify-desktop-input-linux.mjs +127 -0
- package/scripts/verification/verify-desktop-pod-gateway.mjs +204 -0
- package/scripts/verify-cloud-turn.mjs +18 -0
- package/scripts/verify-computer-broker.mjs +10 -0
- package/src/automation-tools.mjs +18 -0
- package/src/bot-private-tools.mjs +23 -0
- package/src/bot-registry.mjs +157 -0
- package/src/bot-tool-policy.mjs +48 -0
- package/src/bot-tool-scope.mjs +40 -0
- package/src/client/bot-directory.mjs +23 -0
- package/src/client/components/desktop-clipboard.mjs +194 -0
- package/src/client/conversation.mjs +558 -0
- package/src/client/desktop-control.mjs +70 -0
- package/src/client/desktop-view.mjs +68 -0
- package/src/client/entry.mjs +8 -0
- package/src/client/index.mjs +693 -0
- package/src/client/markdown.mjs +130 -0
- package/src/client/native-workbench.mjs +62 -0
- package/src/cloud-computer-tools.mjs +245 -0
- package/src/cloud-files.mjs +57 -0
- package/src/cloud-model.mjs +10 -0
- package/src/cloud-session-transport.mjs +76 -0
- package/src/computer-broker-protocol.mjs +116 -0
- package/src/computer-broker.mjs +220 -0
- package/src/desktop-tools.mjs +64 -0
- package/src/dsh-session-bridge.mjs +207 -0
- package/src/dsh-web-bridge.mjs +127 -0
- package/src/host-gateway.mjs +228 -0
- package/src/host-session-channel.mjs +410 -0
- package/src/implementation-capabilities.mjs +10 -0
- package/src/index.mjs +148 -0
- package/src/persistent-state.mjs +201 -0
- package/src/platform-manifest.mjs +24 -0
- package/src/plugin-manifest.mjs +82 -0
- package/src/schedule-manager.mjs +88 -0
- package/src/system-fetch.mjs +72 -0
- package/src/workspace-tools.mjs +22 -0
- package/src/workspace.mjs +22 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { createComputerBrokerClient, createHttpComputerBrokerTransport } from '../src/computer-broker.mjs';
|
|
4
|
+
import { createComputerToolset } from '../src/cloud-computer-tools.mjs';
|
|
5
|
+
import { BotRegistry } from '../src/bot-registry.mjs';
|
|
6
|
+
import { createPersonalAssistantStateStore } from '../src/persistent-state.mjs';
|
|
7
|
+
import { createCloudSessionChannel } from './cloud-session-channel.mjs';
|
|
8
|
+
import { attachDesktopWebSocket } from './desktop-ws.mjs';
|
|
9
|
+
import { pathToFileURL } from 'node:url';
|
|
10
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
11
|
+
import { createScheduleManager } from '../src/schedule-manager.mjs';
|
|
12
|
+
import { createCloudFileStore } from '../src/cloud-files.mjs';
|
|
13
|
+
|
|
14
|
+
export async function startAssistantRuntime({ runtime, env = process.env, argv = [] } = {}) {
|
|
15
|
+
const values = new Map();
|
|
16
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
17
|
+
const key = argv[index];
|
|
18
|
+
if (!key.startsWith('--')) continue;
|
|
19
|
+
values.set(key.slice(2), argv[index + 1]?.startsWith('--') ? '' : (argv[++index] ?? ''));
|
|
20
|
+
}
|
|
21
|
+
const required = name => {
|
|
22
|
+
const value = values.get(name) || env[name.toUpperCase().replaceAll('-', '_')];
|
|
23
|
+
if (!value) throw new Error(`missing ${name}`);
|
|
24
|
+
return value;
|
|
25
|
+
};
|
|
26
|
+
const assistantId = required('assistant-id');
|
|
27
|
+
const computerId = required('computer-id');
|
|
28
|
+
const userId = required('user-id');
|
|
29
|
+
const brokerEndpoint = env.COMPUTER_BROKER_URL || required('computer-broker');
|
|
30
|
+
const host = env.AGENTKIT_RUNTIME_HOST || '0.0.0.0';
|
|
31
|
+
const port = Number(env.AGENTKIT_RUNTIME_PORT || 8080);
|
|
32
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('invalid runtime port');
|
|
33
|
+
const capabilities = String(values.get('capabilities') || env.AGENTKIT_PERSONAL_ASSISTANT_CAPABILITIES || '').split(',').map(item => item.trim()).filter(Boolean);
|
|
34
|
+
const workspace = values.get('workspace') || env.WORKSPACE_PATH || env.AGENTKIT_WORKSPACE_DIR || '/workspace';
|
|
35
|
+
const artifacts = values.get('artifacts') || env.AGENTKIT_ARTIFACTS_DIR || `${workspace}/files`;
|
|
36
|
+
const state = values.get('state') || env.ASSISTANT_STATE_PATH || env.AGENTKIT_STATE_DIR || `${workspace}/.agentkit/state`;
|
|
37
|
+
const runtimeToken = env.AGENTKIT_RUNTIME_TOKEN || '';
|
|
38
|
+
const registryPath = values.get('bot-registry') || env.BOT_REGISTRY_PATH;
|
|
39
|
+
await mkdir(workspace, { recursive: true, mode: 0o700 });
|
|
40
|
+
await mkdir(artifacts, { recursive: true, mode: 0o750 });
|
|
41
|
+
await mkdir(state, { recursive: true, mode: 0o700 });
|
|
42
|
+
const stateStore = await createPersonalAssistantStateStore(state).init();
|
|
43
|
+
const cloudFiles = createCloudFileStore(`${workspace}/workspace`);
|
|
44
|
+
let bots = [];
|
|
45
|
+
if (registryPath) {
|
|
46
|
+
const parsed = JSON.parse(await readFile(registryPath, 'utf8'));
|
|
47
|
+
bots = Array.isArray(parsed) ? parsed : (Array.isArray(parsed.bots) ? parsed.bots : []);
|
|
48
|
+
}
|
|
49
|
+
const registry = runtime?.registry ?? new BotRegistry({ profileId: assistantId, bots });
|
|
50
|
+
const turnBridge = runtime?.turnBridge;
|
|
51
|
+
turnBridge?.setMemoryProvider?.(botId => stateStore.memory(botId), (botId, value) => stateStore.putMemory({ ...value, botId }));
|
|
52
|
+
turnBridge?.setSessionOwnerResolver?.(async sessionId => (await stateStore.read('sessions')).find(row => row.sessionId === sessionId)?.botId);
|
|
53
|
+
|
|
54
|
+
const broker = createComputerBrokerClient({ transport: createHttpComputerBrokerTransport({ baseUrl: brokerEndpoint }) });
|
|
55
|
+
|
|
56
|
+
const send = (response, status, body) => {
|
|
57
|
+
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
58
|
+
response.end(JSON.stringify(body));
|
|
59
|
+
};
|
|
60
|
+
const readBody = request => new Promise((resolve, reject) => {
|
|
61
|
+
const chunks = [];
|
|
62
|
+
request.on('data', chunk => chunks.push(chunk));
|
|
63
|
+
request.on('end', () => {
|
|
64
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); } catch (error) { reject(Object.assign(error, { code: 'INVALID_JSON' })); }
|
|
65
|
+
});
|
|
66
|
+
request.on('error', reject);
|
|
67
|
+
});
|
|
68
|
+
const requireRuntimeToken = request => {
|
|
69
|
+
const authorization = String(request.headers.authorization || '');
|
|
70
|
+
if (!runtimeToken || authorization !== `Bearer ${runtimeToken}`) throw Object.assign(new Error('runtime token required'), { code: 'RUNTIME_UNAUTHORIZED', status: 401 });
|
|
71
|
+
};
|
|
72
|
+
const brokerDesktopRequest = async (path, body) => {
|
|
73
|
+
const result = await fetch(`${brokerEndpoint}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...body, userId, assistantId, computerId }) });
|
|
74
|
+
const payload = await result.json().catch(() => ({ error: 'COMPUTER_BROKER_INVALID_RESPONSE' }));
|
|
75
|
+
if (!result.ok) throw Object.assign(new Error(payload.error || 'computer broker request failed'), { code: payload.error || 'COMPUTER_BROKER_REQUEST_FAILED', status: result.status });
|
|
76
|
+
return payload;
|
|
77
|
+
};
|
|
78
|
+
const brokerDesktopFrameBinary = async body => {
|
|
79
|
+
const result = await fetch(`${brokerEndpoint}/broker/desktop/frame-binary`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...body, userId, assistantId, computerId }) });
|
|
80
|
+
if (!result.ok) { const payload = await result.json().catch(() => ({})); throw Object.assign(new Error(payload.error || 'computer frame failed'), { code: payload.error || 'COMPUTER_FRAME_FAILED', status: result.status }); }
|
|
81
|
+
return { unchanged: result.headers.get('x-desktop-unchanged') === '1', seq: Number(result.headers.get('x-desktop-seq') || 0), width: Number(result.headers.get('x-desktop-width') || 1280), height: Number(result.headers.get('x-desktop-height') || 800), bytes: Number(result.headers.get('x-desktop-bytes') || 0), nextFrameDelayMs: Number(result.headers.get('x-desktop-next-frame-delay-ms') || 80), data: Buffer.from(await result.arrayBuffer()) };
|
|
82
|
+
};
|
|
83
|
+
const botIdOf = value => {
|
|
84
|
+
const botId = String(value ?? '').trim();
|
|
85
|
+
if (!botId) throw Object.assign(new Error('botId is required'), { code: 'BOT_REQUIRED' });
|
|
86
|
+
registry.require(botId);
|
|
87
|
+
return botId;
|
|
88
|
+
};
|
|
89
|
+
const taskIdOf = value => {
|
|
90
|
+
const taskId = String(value ?? 'runtime-action').trim();
|
|
91
|
+
if (!taskId || taskId.length > 128) throw Object.assign(new Error('taskId is invalid'), { code: 'TASK_INVALID' });
|
|
92
|
+
return taskId;
|
|
93
|
+
};
|
|
94
|
+
const actionToolName = value => {
|
|
95
|
+
const tool = String(value ?? '').trim();
|
|
96
|
+
const allowed = new Set([
|
|
97
|
+
'cloud_browser_open', 'cloud_browser_snapshot', 'cloud_browser_click',
|
|
98
|
+
'cloud_browser_type', 'cloud_browser_press', 'cloud_browser_screenshot',
|
|
99
|
+
'cloud_browser_download', 'cloud_browser_close', 'cloud_bash', 'cloud_run_code',
|
|
100
|
+
]);
|
|
101
|
+
if (!allowed.has(tool)) throw Object.assign(new Error('unsupported personal assistant tool'), { code: 'TOOL_UNSUPPORTED' });
|
|
102
|
+
return tool;
|
|
103
|
+
};
|
|
104
|
+
const computerStatus = async body => broker.status({ userId, assistantId, botId: botIdOf(body.botId), computerId });
|
|
105
|
+
const computerAction = async body => {
|
|
106
|
+
const botId = botIdOf(body.botId);
|
|
107
|
+
const tool = actionToolName(body.tool || body.name);
|
|
108
|
+
const taskId = taskIdOf(body.taskId);
|
|
109
|
+
const toolset = createComputerToolset({ broker, userId, assistantId, botId, computerId, taskId });
|
|
110
|
+
try { return await toolset.executeRaw(tool, body.args || {}, { taskId }); }
|
|
111
|
+
finally { await toolset.dispose(); }
|
|
112
|
+
};
|
|
113
|
+
// A deterministic, side-effect-free broker smoke: status, lease acquisition,
|
|
114
|
+
// and the close action all cross the localhost broker. `cloud_browser_close`
|
|
115
|
+
// does not launch a browser and therefore works even on a fresh Computer Pod.
|
|
116
|
+
const computerSmoke = async body => {
|
|
117
|
+
const botId = botIdOf(body.botId);
|
|
118
|
+
const taskId = taskIdOf(body.taskId || 'broker-smoke');
|
|
119
|
+
const status = await broker.status({ userId, assistantId, botId, computerId });
|
|
120
|
+
const toolset = createComputerToolset({ broker, userId, assistantId, botId, computerId, taskId });
|
|
121
|
+
try {
|
|
122
|
+
const action = await toolset.executeRaw('cloud_browser_close', {}, { taskId });
|
|
123
|
+
return { status, action, smoke: 'passed' };
|
|
124
|
+
} finally {
|
|
125
|
+
await toolset.dispose();
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const urlOf = request => new URL(request.url || '/', `http://${host}:${port}`);
|
|
130
|
+
const stateBotId = value => botIdOf(value);
|
|
131
|
+
const sessionCreate = async body => {
|
|
132
|
+
const botId = stateBotId(body.botId);
|
|
133
|
+
return stateStore.createSession({ botId, title: body.title, metadata: body.metadata });
|
|
134
|
+
};
|
|
135
|
+
const memoryWrite = async body => {
|
|
136
|
+
const botId = stateBotId(body.botId);
|
|
137
|
+
return stateStore.putMemory({ botId, key: body.key, content: body.content, metadata: body.metadata });
|
|
138
|
+
};
|
|
139
|
+
const scheduleWrite = async body => {
|
|
140
|
+
const botId = stateBotId(body.botId);
|
|
141
|
+
if (!turnBridge?.available) throw Object.assign(new Error('cloud DSH Session Controller unavailable'), { code: 'DSH_SESSION_CONTROLLER_UNAVAILABLE', status: 503 });
|
|
142
|
+
const input = String(body.input ?? body.metadata?.input ?? '').trim();
|
|
143
|
+
if (!input || Buffer.byteLength(input) > 64 * 1024) throw Object.assign(new Error('schedule input is required'), { code: 'SCHEDULE_INPUT_REQUIRED' });
|
|
144
|
+
if (!Number.isSafeInteger(body.runAt) || body.runAt <= Date.now()) throw Object.assign(new Error('schedule must be in the future'), { code: 'SCHEDULE_INVALID' });
|
|
145
|
+
const sessionId = body.sessionId || (await stateStore.createSession({ botId, title: `定时任务:${body.scheduleId}` })).sessionId;
|
|
146
|
+
await requireSession(botId, sessionId);
|
|
147
|
+
const row = await stateStore.putSchedule({ botId, scheduleId: body.scheduleId, runAt: body.runAt, metadata: { ...body.metadata, input, sessionId, generation: randomUUID() } });
|
|
148
|
+
await patchSchedule(row, { status: 'pending' });
|
|
149
|
+
armSchedule(row);
|
|
150
|
+
return { ...row, status: 'pending' };
|
|
151
|
+
};
|
|
152
|
+
const sessionResource = pathname => {
|
|
153
|
+
const match = pathname.match(/^\/v1\/sessions\/([^/]+)\/(messages|turns)$/u);
|
|
154
|
+
return match ? { sessionId: decodeURIComponent(match[1]), resource: match[2] } : null;
|
|
155
|
+
};
|
|
156
|
+
const messageWrite = async (body, sessionId) => {
|
|
157
|
+
const botId = stateBotId(body.botId);
|
|
158
|
+
await requireSession(botId, sessionId);
|
|
159
|
+
return stateStore.appendMessage({ botId, sessionId, role: body.role, content: body.content, metadata: body.metadata });
|
|
160
|
+
};
|
|
161
|
+
const requireSession = async (botId, sessionId) => {
|
|
162
|
+
if (!(await stateStore.sessions(botId)).some(item => item.sessionId === sessionId)) throw Object.assign(new Error('session not found for Bot'), { code: 'SESSION_NOT_FOUND', status: 404 });
|
|
163
|
+
};
|
|
164
|
+
const updateTurn = (turnId, patch) => stateStore.mutate('turns', rows => {
|
|
165
|
+
const row = rows.find(item => item.turnId === turnId);
|
|
166
|
+
if (!row) return null;
|
|
167
|
+
Object.assign(row, patch, { updatedAt: new Date().toISOString() });
|
|
168
|
+
return row;
|
|
169
|
+
});
|
|
170
|
+
const pendingTurns = new Set();
|
|
171
|
+
const submissionLocks = new Map();
|
|
172
|
+
const scheduler = runtime?.scheduleManager ?? createScheduleManager();
|
|
173
|
+
const scheduleKey = row => `task-${createHash('sha256').update(JSON.stringify([row.botId, row.scheduleId])).digest('hex')}`;
|
|
174
|
+
const patchSchedule = (record, patch) => stateStore.mutate('schedules', rows => {
|
|
175
|
+
const row = rows.find(item => item.botId === record.botId && item.scheduleId === record.scheduleId);
|
|
176
|
+
if (!row || row.metadata?.generation !== record.metadata?.generation) return null;
|
|
177
|
+
Object.assign(row, patch);
|
|
178
|
+
return row;
|
|
179
|
+
});
|
|
180
|
+
function armSchedule(row) {
|
|
181
|
+
scheduler.scheduleOnce({ scheduleId: scheduleKey(row), runAt: Math.min(Math.max(Date.now() + 1, row.nextRunAt), Date.now() + 2_147_483_647), task: async () => {
|
|
182
|
+
try {
|
|
183
|
+
if (row.nextRunAt > Date.now()) { armSchedule(row); return; }
|
|
184
|
+
const claimed = await patchSchedule(row, { status: 'running', startedAt: new Date().toISOString() });
|
|
185
|
+
if (!claimed) return;
|
|
186
|
+
const receipt = await turnWrite({ botId: row.botId, input: row.metadata.input, metadata: { scheduleId: row.scheduleId } }, row.metadata.sessionId);
|
|
187
|
+
await Promise.allSettled([...pendingTurns]);
|
|
188
|
+
const turn = (await stateStore.turns(row.botId, row.metadata.sessionId)).find(item => item.turnId === receipt.turnId);
|
|
189
|
+
await patchSchedule(row, { status: turn?.status === 'completed' ? 'completed' : 'failed', turnId: receipt.turnId, finishedAt: new Date().toISOString(), error: turn?.error || null });
|
|
190
|
+
console.info(JSON.stringify({ event: 'personal-assistant.schedule.finished', botId: row.botId, scheduleId: row.scheduleId, turnId: receipt.turnId, status: turn?.status || 'failed' }));
|
|
191
|
+
} catch (error) { await patchSchedule(row, { status: 'failed', error: error.code || 'SCHEDULE_EXECUTION_FAILED' }); }
|
|
192
|
+
} });
|
|
193
|
+
}
|
|
194
|
+
const turnWrite = async (body, sessionId) => {
|
|
195
|
+
const key = JSON.stringify([body.botId, sessionId]);
|
|
196
|
+
const previous = submissionLocks.get(key) || Promise.resolve();
|
|
197
|
+
const current = previous.catch(() => {}).then(() => submitCloudTurn(body, sessionId));
|
|
198
|
+
submissionLocks.set(key, current);
|
|
199
|
+
try { return await current; } finally { if (submissionLocks.get(key) === current) submissionLocks.delete(key); }
|
|
200
|
+
};
|
|
201
|
+
const submitCloudTurn = async (body, sessionId) => {
|
|
202
|
+
const botId = stateBotId(body.botId);
|
|
203
|
+
await requireSession(botId, sessionId);
|
|
204
|
+
if (!turnBridge?.available) throw Object.assign(new Error('cloud DSH Session Controller unavailable'), { code: 'DSH_SESSION_CONTROLLER_UNAVAILABLE', status: 503 });
|
|
205
|
+
const clientTurnId = body.metadata?.clientTurnId;
|
|
206
|
+
if (clientTurnId) {
|
|
207
|
+
const existing = (await stateStore.turns(botId, sessionId)).find(row => row.metadata?.clientTurnId === clientTurnId);
|
|
208
|
+
if (existing) {
|
|
209
|
+
if (existing.input !== body.input) throw Object.assign(new Error('同一发送标识不能改变内容'), { code: 'CLIENT_TURN_CONFLICT', status: 409 });
|
|
210
|
+
return existing;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const turn = await stateStore.createTurn({ botId, sessionId, input: body.input, metadata: body.metadata });
|
|
214
|
+
const message = await stateStore.appendMessage({ botId, sessionId, role: 'user', content: body.input, metadata: { turnId: turn.turnId, ...(clientTurnId ? { clientTurnId } : {}) } });
|
|
215
|
+
try {
|
|
216
|
+
const content = [{ type: 'text', text: body.input }];
|
|
217
|
+
for (const attachment of body.attachments || []) {
|
|
218
|
+
const data = await cloudFiles.attachment({ botId, sessionId, path: attachment.path });
|
|
219
|
+
// 只有明确的图像附件进入模型图片通道;其他文件由工作区工具读取处理。
|
|
220
|
+
if (['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(attachment.mediaType)) {
|
|
221
|
+
const store = runtime?.ctx?.get?.('attachments');
|
|
222
|
+
if (!store?.saveImage) throw Object.assign(new Error('云端图片附件服务不可用'), { status: 503 });
|
|
223
|
+
const ref = await store.saveImage({ data, mediaType: attachment.mediaType, name: attachment.name });
|
|
224
|
+
content.push({ type: 'image', attachment: ref });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const receipt = await turnBridge.submitTurn({ botId, sessionId, input: body.input, parts: content, requestId: turn.turnId });
|
|
228
|
+
await updateTurn(turn.turnId, { status: 'running', execution: receipt.execution, requestId: receipt.requestId });
|
|
229
|
+
const pending = turnBridge.waitForTurn({ botId, sessionId, requestId: receipt.requestId, onProgress: progress => updateTurn(turn.turnId, { progress }) }).then(async result => {
|
|
230
|
+
for (const content of result.messages) await stateStore.appendMessage({ botId, sessionId, role: 'assistant', content, metadata: { turnId: turn.turnId, ...(clientTurnId ? { clientTurnId } : {}), execution: receipt.execution } });
|
|
231
|
+
await updateTurn(turn.turnId, { status: result.status, result: result.messages.join('\n'), error: result.error || null });
|
|
232
|
+
console.info(JSON.stringify({ event: 'personal-assistant.turn.finished', botId, sessionId, turnId: turn.turnId, status: result.status }));
|
|
233
|
+
}).catch(error => updateTurn(turn.turnId, { status: 'failed', error: error.code || error.message }));
|
|
234
|
+
pendingTurns.add(pending);
|
|
235
|
+
void pending.finally(() => pendingTurns.delete(pending));
|
|
236
|
+
return { ...turn, status: 'running', messageId: message.messageId, ...receipt };
|
|
237
|
+
} catch (error) {
|
|
238
|
+
await updateTurn(turn.turnId, { status: 'failed', error: error.code || error.message });
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// 尚未开始的任务可恢复;中断时不自动重放未知结果,避免重复执行操作。
|
|
244
|
+
await stateStore.mutate('turns', rows => {
|
|
245
|
+
for (const row of rows) if (row.status === 'running' || row.status === 'queued') Object.assign(row, { status: 'interrupted', error: 'RUNTIME_PROCESS_INTERRUPTED', updatedAt: new Date().toISOString() });
|
|
246
|
+
});
|
|
247
|
+
for (const row of await stateStore.read('schedules')) {
|
|
248
|
+
if (row.status === 'running') await patchSchedule(row, { status: 'interrupted', error: 'SCHEDULE_PROCESS_INTERRUPTED' });
|
|
249
|
+
else if ((!row.status || row.status === 'pending') && row.metadata?.input && row.metadata?.sessionId) armSchedule(row);
|
|
250
|
+
}
|
|
251
|
+
runtime?.setAutomationService?.(async args => {
|
|
252
|
+
const botId = stateBotId(args.botId);
|
|
253
|
+
if (args.action === 'list') return { schedules: await stateStore.schedules(botId) };
|
|
254
|
+
if (args.action === 'create') return scheduleWrite({ botId, scheduleId: args.scheduleId || randomUUID(), runAt: args.runAt, input: args.input });
|
|
255
|
+
if (typeof args.scheduleId !== 'string') throw new TypeError('scheduleId is required');
|
|
256
|
+
const row = (await stateStore.schedules(botId)).find(row => row.scheduleId === args.scheduleId);
|
|
257
|
+
if (row?.status === 'running') throw Object.assign(new Error('任务已开始执行,不能伪报取消'), { status: 409 });
|
|
258
|
+
if (row) { scheduler.cancel(scheduleKey(row)); await patchSchedule(row, { status: 'cancelled' }); }
|
|
259
|
+
return { cancelled: Boolean(row) };
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const server = createServer(async (request, response) => {
|
|
263
|
+
try {
|
|
264
|
+
const url = urlOf(request);
|
|
265
|
+
if (request.method === 'GET' && ['/health', '/ready', '/readyz'].includes(url.pathname)) return send(response, turnBridge?.available === true ? 200 : 503, { status: turnBridge?.available === true ? 'ready' : 'dsh-unavailable', assistantId, computerId, bots: registry.list().length, dshSessionController: turnBridge?.available === true, capabilities });
|
|
266
|
+
requireRuntimeToken(request);
|
|
267
|
+
if (request.method === 'GET' && url.pathname === '/v1/bots') return send(response, 200, { bots: registry.list() });
|
|
268
|
+
if (request.method === 'GET' && url.pathname === '/v1/sessions') return send(response, 200, { sessions: await stateStore.sessions(stateBotId(url.searchParams.get('botId'))) });
|
|
269
|
+
if (request.method === 'GET' && sessionResource(url.pathname)?.resource === 'messages') {
|
|
270
|
+
const resource = sessionResource(url.pathname);
|
|
271
|
+
const botId = stateBotId(url.searchParams.get('botId')); await requireSession(botId, resource.sessionId);
|
|
272
|
+
return send(response, 200, { messages: await stateStore.messages({ botId, sessionId: resource.sessionId }) });
|
|
273
|
+
}
|
|
274
|
+
if (request.method === 'GET' && sessionResource(url.pathname)?.resource === 'turns') {
|
|
275
|
+
const resource = sessionResource(url.pathname);
|
|
276
|
+
const botId = stateBotId(url.searchParams.get('botId')); await requireSession(botId, resource.sessionId);
|
|
277
|
+
return send(response, 200, { turns: await stateStore.turns(botId, resource.sessionId) });
|
|
278
|
+
}
|
|
279
|
+
if (request.method === 'GET' && url.pathname === '/v1/memory') return send(response, 200, { memories: await stateStore.memory(stateBotId(url.searchParams.get('botId')), url.searchParams.get('query') || '') });
|
|
280
|
+
if (request.method === 'GET' && url.pathname === '/v1/schedules') return send(response, 200, { schedules: await stateStore.schedules(stateBotId(url.searchParams.get('botId'))) });
|
|
281
|
+
if (request.method === 'DELETE' && url.pathname.startsWith('/v1/schedules/')) {
|
|
282
|
+
const botId = stateBotId(url.searchParams.get('botId')), scheduleId = decodeURIComponent(url.pathname.slice('/v1/schedules/'.length));
|
|
283
|
+
scheduler.cancel(scheduleKey({ botId, scheduleId }));
|
|
284
|
+
const removed = await stateStore.removeSchedule(botId, scheduleId);
|
|
285
|
+
return send(response, 200, { removed });
|
|
286
|
+
}
|
|
287
|
+
if (request.method !== 'POST') return send(response, 404, { error: 'not_found' });
|
|
288
|
+
const body = await readBody(request);
|
|
289
|
+
if (url.pathname === '/v1/computer/status') return send(response, 200, await computerStatus(body));
|
|
290
|
+
if (url.pathname === '/v1/computer/action') return send(response, 200, await computerAction(body));
|
|
291
|
+
if (url.pathname === '/v1/computer/smoke') return send(response, 200, await computerSmoke(body));
|
|
292
|
+
if (url.pathname === '/v1/computer/desktop/session') { requireRuntimeToken(request); return send(response, 200, await brokerDesktopRequest('/broker/desktop/session', { botId: botIdOf(body.botId), taskId: taskIdOf(body.taskId), leaseId: body.leaseId || undefined })); }
|
|
293
|
+
if (url.pathname === '/v1/computer/desktop/frame') { requireRuntimeToken(request); return send(response, 200, await brokerDesktopRequest('/broker/desktop/frame', { sessionId: body.sessionId, afterSeq: body.afterSeq })); }
|
|
294
|
+
if (url.pathname === '/v1/computer/desktop/input') { requireRuntimeToken(request); return send(response, 202, await brokerDesktopRequest('/broker/desktop/input', { sessionId: body.sessionId, botId: botIdOf(body.botId), taskId: taskIdOf(body.taskId), leaseId: body.leaseId, event: body.event })); }
|
|
295
|
+
if (url.pathname === '/v1/computer/desktop/close') { requireRuntimeToken(request); return send(response, 200, await brokerDesktopRequest('/broker/desktop/close', { sessionId: body.sessionId })); }
|
|
296
|
+
if (url.pathname === '/v1/sessions') return send(response, 201, await sessionCreate(body));
|
|
297
|
+
if (sessionResource(url.pathname)?.resource === 'messages') return send(response, 201, await messageWrite(body, sessionResource(url.pathname).sessionId));
|
|
298
|
+
if (sessionResource(url.pathname)?.resource === 'turns') return send(response, 202, await turnWrite(body, sessionResource(url.pathname).sessionId));
|
|
299
|
+
if (url.pathname === '/v1/memory') return send(response, 201, await memoryWrite(body));
|
|
300
|
+
if (url.pathname === '/v1/schedules') return send(response, 201, await scheduleWrite(body));
|
|
301
|
+
return send(response, 404, { error: 'not_found' });
|
|
302
|
+
} catch (error) {
|
|
303
|
+
const status = Number.isInteger(error?.status) && error.status >= 400 && error.status < 600 ? error.status : 400;
|
|
304
|
+
return send(response, status, { error: error?.code || error?.message || 'PERSONAL_ASSISTANT_RUNTIME_FAILED' });
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
attachDesktopWebSocket({ server, runtimeToken, assistantId, brokerDesktopRequest, brokerDesktopFrameBinary, botIdOf, taskIdOf, listBots: () => registry.list(), sessionRequest: createCloudSessionChannel({ assistantId, computerId, stateStore, registry, sessionCreate, turnWrite, scheduleWrite, cloudFiles }) });
|
|
308
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, resolve); });
|
|
309
|
+
return { server, stateStore, waitForIdle: () => Promise.allSettled([...pendingTurns]), close: () => { scheduler.dispose(); return new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); } };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
313
|
+
// 没有真实 DSH Host 注入时 readiness 明确为 503,禁止伪造回合。
|
|
314
|
+
const handle = await startAssistantRuntime({ argv: process.argv.slice(2) });
|
|
315
|
+
const close = () => void handle.close().then(() => process.exit(0));
|
|
316
|
+
process.on('SIGTERM', close); process.on('SIGINT', close);
|
|
317
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url';
|
|
2
|
+
import { loadPersonalAssistantManifest } from '../src/platform-manifest.mjs';
|
|
3
|
+
|
|
4
|
+
export const PERSONAL_ASSISTANT_MANIFEST_PATH = '/app/code/agentengine.yaml';
|
|
5
|
+
|
|
6
|
+
/** 独立init校验只读声明及公开身份,不需要模型key、runtime token或可写目录。 */
|
|
7
|
+
export function checkPersonalAssistantManifest(env = process.env, { loadManifest = loadPersonalAssistantManifest } = {}) {
|
|
8
|
+
if (env.AGENTENGINE_MANIFEST_PATH !== PERSONAL_ASSISTANT_MANIFEST_PATH) throw Object.assign(new Error('MANAGED_MANIFEST_PATH_INVALID'), { code: 'MANAGED_MANIFEST_PATH_INVALID' });
|
|
9
|
+
const manifest = loadManifest(env);
|
|
10
|
+
return { verified: true, runtime: manifest.runtime.name, version: manifest.runtime.version, manifestSha256: env.AGENTENGINE_MANIFEST_SHA256 };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
14
|
+
try {
|
|
15
|
+
if (process.argv.length !== 4 || process.argv[2] !== '--manifest' || process.argv[3] !== PERSONAL_ASSISTANT_MANIFEST_PATH) throw Object.assign(new Error('MANAGED_MANIFEST_ARGUMENTS_INVALID'), { code: 'MANAGED_MANIFEST_ARGUMENTS_INVALID' });
|
|
16
|
+
console.info(JSON.stringify(checkPersonalAssistantManifest()));
|
|
17
|
+
} catch (error) {
|
|
18
|
+
console.error(JSON.stringify({ error: error.code || 'MANAGED_MANIFEST_CHECK_FAILED' }));
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/** Minimal Chromium DevTools Protocol transport: one browser, one WebSocket, no reconnect. */
|
|
2
|
+
import { spawn as spawnChild } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync, mkdirSync, chmodSync, lstatSync } from 'node:fs';
|
|
4
|
+
import { rm } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join, isAbsolute } from 'node:path';
|
|
7
|
+
|
|
8
|
+
export const DEVTOOLS_PORT_FILE = 'DevToolsActivePort';
|
|
9
|
+
export const DEVTOOLS_LAUNCH_TIMEOUT_MS = 30_000;
|
|
10
|
+
export const CDP_COMMAND_TIMEOUT_MS = 30_000;
|
|
11
|
+
const CDP_CONNECT_TIMEOUT_MS = 10_000;
|
|
12
|
+
const MAX_STDERR_BYTES = 4096;
|
|
13
|
+
const persistentProfileOwners = new Map();
|
|
14
|
+
|
|
15
|
+
const fail = (message, code = 'ASSISTANT_BROWSER_CDP_UNAVAILABLE') => Object.assign(new Error(message), { code });
|
|
16
|
+
const delay = ms => new Promise(resolvePromise => setTimeout(resolvePromise, ms));
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Chromium flags for the managed image. Chromium's own sandbox is unavailable in
|
|
20
|
+
* most managed containers, so the egress proxy stays authoritative for network
|
|
21
|
+
* policy; `--proxy-bypass-list=<-loopback>` keeps loopback inside the proxy too.
|
|
22
|
+
* 桌面模式的首屏是本机 127.0.0.1 的欢迎页,因此回环必须显式放行,否则页面会被
|
|
23
|
+
* 出口代理吃掉而白屏;公网流量仍然全部走代理。
|
|
24
|
+
* 桌面窗口固定 `--class=Chromium`:否则 `--app=<地址>` 会把 WM_CLASS 设成地址本身,
|
|
25
|
+
* dock 的任务按钮匹配不到 chromium.desktop,只能显示一个灰色兜底图标。
|
|
26
|
+
* 托管容器里 Chromium 自带沙箱不可用,`--no-sandbox` 会让窗口版 Chromium 顶部弹出
|
|
27
|
+
* “You are using an unsupported command-line flag” 信息栏;`--test-type` 抑制该提示栏,
|
|
28
|
+
* 不改变 CDP/自动化语义,也不影响页面能力。
|
|
29
|
+
*/
|
|
30
|
+
export function chromiumArgs({ userDataDir, proxy, windowSize = '1280,800', desktop = false, startUrl, openUrl }) {
|
|
31
|
+
return [
|
|
32
|
+
...(desktop ? ['--ozone-platform=x11', '--window-position=520,90', '--test-type', '--lang=zh-CN', '--accept-lang=zh-CN,zh', '--class=Chromium', '--hide-crash-restore-bubble'] : ['--headless=new']),
|
|
33
|
+
// 应用窗口承载首屏:没有地址栏、没有多余标签条。
|
|
34
|
+
...(desktop && startUrl ? [`--app=${startUrl}`] : []),
|
|
35
|
+
// 普通窗口模式:把首页作为位置参数交给 Chromium,避免先出一个空白标签再补一个页面。
|
|
36
|
+
...(desktop && !startUrl && openUrl ? [openUrl] : []),
|
|
37
|
+
'--disable-gpu', '--disable-dev-shm-usage', '--no-sandbox',
|
|
38
|
+
'--no-first-run', '--no-default-browser-check', '--disable-extensions', '--disable-sync',
|
|
39
|
+
'--disable-background-networking', '--disable-breakpad', '--disable-crash-reporter',
|
|
40
|
+
'--disable-translate', '--mute-audio', '--hide-scrollbars',
|
|
41
|
+
`--window-size=${desktop ? '740,620' : windowSize}`,
|
|
42
|
+
'--remote-debugging-port=0', '--remote-debugging-address=127.0.0.1',
|
|
43
|
+
`--user-data-dir=${userDataDir}`,
|
|
44
|
+
`--proxy-server=${proxy}`,
|
|
45
|
+
'--proxy-bypass-list=<-loopback>,127.0.0.1,localhost',
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Read `<user-data-dir>/DevToolsActivePort`; Chromium writes port and browser WebSocket path. */
|
|
50
|
+
export function readDevToolsEndpoint(userDataDir) {
|
|
51
|
+
let content;
|
|
52
|
+
try { content = readFileSync(join(userDataDir, DEVTOOLS_PORT_FILE), 'utf8'); } catch { return null; }
|
|
53
|
+
const [portLine, pathLine] = content.split(/\r?\n/);
|
|
54
|
+
const port = Number(portLine);
|
|
55
|
+
const path = (pathLine || '').trim();
|
|
56
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535 || !path.startsWith('/')) return null;
|
|
57
|
+
return { port, path, endpoint: `ws://127.0.0.1:${port}${path}` };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createProfileDir() {
|
|
61
|
+
return mkdtempSync(join(tmpdir(), 'agentkit-cloud-browser-'));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Kill the detached Chromium process group; fall back to the single pid. */
|
|
65
|
+
export function killProcessTree(pid, signal = 'SIGKILL') {
|
|
66
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return;
|
|
67
|
+
try { process.kill(-pid, signal); } catch { try { process.kill(pid, signal); } catch {} }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function collectStderr(child, state) {
|
|
71
|
+
child?.stderr?.on?.('data', chunk => {
|
|
72
|
+
if (state.bytes >= MAX_STDERR_BYTES) return;
|
|
73
|
+
const slice = chunk.subarray(0, MAX_STDERR_BYTES - state.bytes);
|
|
74
|
+
state.text += slice.toString('utf8');
|
|
75
|
+
state.bytes += slice.length;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const childExited = child => child.exitCode !== null && child.exitCode !== undefined || Boolean(child.signalCode);
|
|
80
|
+
const waitForOwnChild = (child, timeoutMs) => {
|
|
81
|
+
if (!child || childExited(child)) return Promise.resolve();
|
|
82
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
83
|
+
const finish = error => { clearTimeout(timer); child.removeListener?.('exit', exited); error ? rejectPromise(error) : resolvePromise(); };
|
|
84
|
+
const exited = () => finish();
|
|
85
|
+
const timer = setTimeout(() => finish(fail('自有 Chromium 进程仍未退出,拒绝复用私有配置目录', 'ASSISTANT_BROWSER_PROCESS_STILL_RUNNING')), timeoutMs);
|
|
86
|
+
child.once('exit', exited);
|
|
87
|
+
if (childExited(child)) finish();
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
async function stopOwnChromium(child, kill, { wait = false, graceMs = 0 } = {}) {
|
|
91
|
+
if (!child?.pid || childExited(child)) return;
|
|
92
|
+
// 正常关窗/Browser.close 可先完成 SQLite 等状态写入;只等待自己的 child。
|
|
93
|
+
if (wait && graceMs) { try { await waitForOwnChild(child, graceMs); return; } catch (error) { if (error.code !== 'ASSISTANT_BROWSER_PROCESS_STILL_RUNNING') throw error; } }
|
|
94
|
+
try { kill(child.pid, 'SIGKILL'); } catch {
|
|
95
|
+
if (wait) throw fail('无法停止自有 Chromium,拒绝复用私有配置目录', 'ASSISTANT_BROWSER_PROCESS_STILL_RUNNING');
|
|
96
|
+
}
|
|
97
|
+
if (wait) await waitForOwnChild(child, 2000);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function preparePersistentProfile(userDataDir) {
|
|
101
|
+
if (!userDataDir || !isAbsolute(userDataDir)) throw fail('持久浏览器配置目录必须为绝对路径', 'ASSISTANT_BROWSER_PROFILE_INVALID');
|
|
102
|
+
try {
|
|
103
|
+
mkdirSync(userDataDir, { recursive: true, mode: 0o700 });
|
|
104
|
+
if (!lstatSync(userDataDir).isDirectory() || lstatSync(userDataDir).isSymbolicLink()) throw fail('浏览器私有配置目录不能是符号链接', 'ASSISTANT_BROWSER_PROFILE_INVALID');
|
|
105
|
+
chmodSync(userDataDir, 0o700);
|
|
106
|
+
// 旧 child 已由调用方 dispose 等待退出。只清调试端口元数据,不碰锁或登录数据。
|
|
107
|
+
rmSync(join(userDataDir, DEVTOOLS_PORT_FILE), { force: true });
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (String(error.code).startsWith('ASSISTANT_BROWSER_')) throw error;
|
|
110
|
+
throw fail(`浏览器私有配置目录不可用: ${error.code || 'filesystem-error'}`, 'ASSISTANT_BROWSER_PROFILE_UNAVAILABLE');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function launchChromium({
|
|
115
|
+
chromium, proxy, userDataDir, spawn = spawnChild,
|
|
116
|
+
launchTimeoutMs = DEVTOOLS_LAUNCH_TIMEOUT_MS, now = Date.now, pollMs = 25, env = process.env,
|
|
117
|
+
preserveUserDataDir = false, startUrl, openUrl,
|
|
118
|
+
} = {}) {
|
|
119
|
+
if (preserveUserDataDir) {
|
|
120
|
+
const previous = persistentProfileOwners.get(userDataDir);
|
|
121
|
+
if (previous && !childExited(previous)) throw fail('自有 Chromium 仍占用私有配置目录,拒绝启动第二个实例', 'ASSISTANT_BROWSER_PROCESS_STILL_RUNNING');
|
|
122
|
+
preparePersistentProfile(userDataDir);
|
|
123
|
+
}
|
|
124
|
+
else if (userDataDir === undefined) userDataDir = createProfileDir();
|
|
125
|
+
const cleanupProfile = () => { if (!preserveUserDataDir) rmSync(userDataDir, { recursive: true, force: true }); };
|
|
126
|
+
const desktop = env.AGENTKIT_DESKTOP_MODE === '1';
|
|
127
|
+
// 桌面浏览器展示的是本地时间(欢迎页时钟、页面里的 Date),必须与桌面其它
|
|
128
|
+
// 进程同一时区;非桌面模式只沿用宿主 TZ,不改变既有口径。
|
|
129
|
+
const timeZone = typeof env.TZ === 'string' && env.TZ
|
|
130
|
+
? env.TZ
|
|
131
|
+
: desktop ? String(env.AGENTKIT_DESKTOP_TIME_ZONE || 'Asia/Shanghai') : undefined;
|
|
132
|
+
const args = chromiumArgs({ userDataDir, proxy, desktop, startUrl, openUrl });
|
|
133
|
+
const stderr = { text: '', bytes: 0 };
|
|
134
|
+
let child;
|
|
135
|
+
try {
|
|
136
|
+
child = spawn(chromium, args, {
|
|
137
|
+
detached: true,
|
|
138
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
139
|
+
env: {
|
|
140
|
+
PATH: '/usr/local/bin:/usr/bin:/bin', HOME: userDataDir, TMPDIR: '/tmp',
|
|
141
|
+
// 桌面窗口给用户看:界面与页面语言都用简体中文;LC_ALL 会盖过 LANG,桌面模式不能设。
|
|
142
|
+
// 无头自动化不改变既有口径(历史行为是 C.UTF-8 + 默认语言)。
|
|
143
|
+
...(desktop
|
|
144
|
+
? { LANG: 'zh_CN.UTF-8', LANGUAGE: 'zh_CN:zh' }
|
|
145
|
+
: { LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8' }),
|
|
146
|
+
...(timeZone ? { TZ: timeZone } : {}),
|
|
147
|
+
...(desktop ? { DISPLAY: env.DISPLAY || ':99' } : {}),
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
} catch (error) {
|
|
151
|
+
cleanupProfile();
|
|
152
|
+
throw fail(`Chromium 启动失败: ${error?.code || 'spawn-error'}`, 'ASSISTANT_BROWSER_LAUNCH_FAILED');
|
|
153
|
+
}
|
|
154
|
+
if (preserveUserDataDir && child?.pid) {
|
|
155
|
+
persistentProfileOwners.set(userDataDir, child);
|
|
156
|
+
child.once('exit', () => { if (persistentProfileOwners.get(userDataDir) === child) persistentProfileOwners.delete(userDataDir); });
|
|
157
|
+
}
|
|
158
|
+
collectStderr(child, stderr);
|
|
159
|
+
let exit, spawnError;
|
|
160
|
+
child?.once?.('exit', (code, signal) => { exit = { code, signal }; });
|
|
161
|
+
child?.once?.('error', error => { spawnError = error; });
|
|
162
|
+
const deadline = now() + launchTimeoutMs;
|
|
163
|
+
while (now() < deadline) {
|
|
164
|
+
const endpoint = readDevToolsEndpoint(userDataDir);
|
|
165
|
+
if (endpoint) return { child, userDataDir, endpoint, stderr };
|
|
166
|
+
if (spawnError) { cleanupProfile(); throw fail(`Chromium 启动失败: ${spawnError.code || 'spawn-error'}`, 'ASSISTANT_BROWSER_LAUNCH_FAILED'); }
|
|
167
|
+
if (exit) {
|
|
168
|
+
await stopOwnChromium(child, killProcessTree, { wait: preserveUserDataDir });
|
|
169
|
+
cleanupProfile();
|
|
170
|
+
const detail = stderr.text.trim().slice(0, 500);
|
|
171
|
+
throw fail(`Chromium 在调试端口就绪前退出(${exit.code ?? exit.signal})${detail ? `: ${detail}` : ''}`, 'ASSISTANT_BROWSER_LAUNCH_FAILED');
|
|
172
|
+
}
|
|
173
|
+
await delay(pollMs);
|
|
174
|
+
}
|
|
175
|
+
await stopOwnChromium(child, killProcessTree, { wait: preserveUserDataDir });
|
|
176
|
+
cleanupProfile();
|
|
177
|
+
throw fail('Chromium 调试端口在限定时间内未就绪', 'ASSISTANT_BROWSER_LAUNCH_FAILED');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** One JSON-RPC channel. Commands time out individually; a closed channel never reconnects. */
|
|
181
|
+
export class CdpConnection {
|
|
182
|
+
#ws; #pending = new Map(); #nextId = 0; #closed = false; #commandTimeoutMs;
|
|
183
|
+
|
|
184
|
+
constructor(ws, { commandTimeoutMs = CDP_COMMAND_TIMEOUT_MS } = {}) {
|
|
185
|
+
this.#ws = ws;
|
|
186
|
+
this.#commandTimeoutMs = commandTimeoutMs;
|
|
187
|
+
ws.addEventListener?.('message', event => this.#onMessage(event));
|
|
188
|
+
ws.addEventListener?.('close', () => this.#rejectAll(fail('云端浏览器调试连接已关闭', 'ASSISTANT_BROWSER_CDP_CLOSED')));
|
|
189
|
+
ws.addEventListener?.('error', () => this.#rejectAll(fail('云端浏览器调试连接出错', 'ASSISTANT_BROWSER_CDP_ERROR')));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
get closed() { return this.#closed; }
|
|
193
|
+
|
|
194
|
+
send(method, params = {}, { sessionId, timeoutMs = this.#commandTimeoutMs } = {}) {
|
|
195
|
+
if (this.#closed) return Promise.reject(fail('云端浏览器调试连接已关闭', 'ASSISTANT_BROWSER_CDP_CLOSED'));
|
|
196
|
+
if (typeof method !== 'string' || !method) return Promise.reject(fail('CDP 方法名无效', 'ASSISTANT_BROWSER_CDP_ERROR'));
|
|
197
|
+
const id = ++this.#nextId;
|
|
198
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
199
|
+
// Keep the timeout ref'd: an in-flight command must keep the process
|
|
200
|
+
// alive long enough for the timeout to fire instead of leaving a
|
|
201
|
+
// promise that the event loop silently abandons (Node 22 cancels it).
|
|
202
|
+
const timer = setTimeout(() => {
|
|
203
|
+
this.#pending.delete(id);
|
|
204
|
+
rejectPromise(fail(`云端浏览器命令 ${method} 超时`, 'ASSISTANT_BROWSER_CDP_TIMEOUT'));
|
|
205
|
+
}, timeoutMs);
|
|
206
|
+
this.#pending.set(id, { resolve: resolvePromise, reject: rejectPromise, timer, method });
|
|
207
|
+
try { this.#ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }
|
|
208
|
+
catch {
|
|
209
|
+
this.#pending.delete(id);
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
rejectPromise(fail(`云端浏览器命令 ${method} 发送失败`, 'ASSISTANT_BROWSER_CDP_ERROR'));
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
#onMessage(event) {
|
|
217
|
+
let message;
|
|
218
|
+
try { message = JSON.parse(typeof event === 'string' ? event : event?.data); } catch { return; }
|
|
219
|
+
if (!message || typeof message.id !== 'number') return;
|
|
220
|
+
const pending = this.#pending.get(message.id);
|
|
221
|
+
if (!pending) return;
|
|
222
|
+
this.#pending.delete(message.id);
|
|
223
|
+
clearTimeout(pending.timer);
|
|
224
|
+
if (message.error) pending.reject(fail(`CDP ${pending.method} 失败: ${String(message.error.message || '').slice(0, 300)}`, 'ASSISTANT_BROWSER_CDP_ERROR'));
|
|
225
|
+
else pending.resolve(message.result || {});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
#rejectAll(error) {
|
|
229
|
+
if (this.#closed && this.#pending.size === 0) return;
|
|
230
|
+
this.#closed = true;
|
|
231
|
+
for (const pending of this.#pending.values()) {
|
|
232
|
+
clearTimeout(pending.timer);
|
|
233
|
+
pending.reject(error);
|
|
234
|
+
}
|
|
235
|
+
this.#pending.clear();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
close() {
|
|
239
|
+
if (this.#closed) return;
|
|
240
|
+
this.#rejectAll(fail('云端浏览器调试连接已关闭', 'ASSISTANT_BROWSER_CDP_CLOSED'));
|
|
241
|
+
try { this.#ws.close(); } catch {}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Connect once to the browser-level WebSocket endpoint. */
|
|
246
|
+
export async function connectCdp({
|
|
247
|
+
endpoint, WebSocketImpl = globalThis.WebSocket,
|
|
248
|
+
commandTimeoutMs = CDP_COMMAND_TIMEOUT_MS, connectTimeoutMs = CDP_CONNECT_TIMEOUT_MS,
|
|
249
|
+
} = {}) {
|
|
250
|
+
if (typeof WebSocketImpl !== 'function') throw fail('运行环境缺少 WebSocket 实现', 'ASSISTANT_BROWSER_CDP_UNAVAILABLE');
|
|
251
|
+
if (typeof endpoint !== 'string' || !endpoint.startsWith('ws://')) throw fail('Chromium 调试端点无效', 'ASSISTANT_BROWSER_LAUNCH_FAILED');
|
|
252
|
+
const ws = new WebSocketImpl(endpoint);
|
|
253
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
254
|
+
let settled = false;
|
|
255
|
+
const timer = setTimeout(() => {
|
|
256
|
+
if (settled) return;
|
|
257
|
+
settled = true;
|
|
258
|
+
try { ws.close(); } catch {}
|
|
259
|
+
rejectPromise(fail('连接 Chromium 调试端口超时', 'ASSISTANT_BROWSER_CDP_TIMEOUT'));
|
|
260
|
+
}, connectTimeoutMs);
|
|
261
|
+
timer.unref?.();
|
|
262
|
+
const finish = error => {
|
|
263
|
+
if (settled) return;
|
|
264
|
+
settled = true;
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
if (error) rejectPromise(error); else resolvePromise();
|
|
267
|
+
};
|
|
268
|
+
ws.addEventListener?.('open', () => finish());
|
|
269
|
+
ws.addEventListener?.('error', () => finish(fail('无法连接 Chromium 调试端口', 'ASSISTANT_BROWSER_CDP_UNAVAILABLE')));
|
|
270
|
+
ws.addEventListener?.('close', () => finish(fail('Chromium 调试端口连接已关闭', 'ASSISTANT_BROWSER_CDP_UNAVAILABLE')));
|
|
271
|
+
});
|
|
272
|
+
return new CdpConnection(ws, { commandTimeoutMs });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Launch and connect one Chromium. `dispose()` closes the channel, kills the
|
|
277
|
+
* process group. 普通临时配置会删除;Computer 明确声明的持久私有配置保留。
|
|
278
|
+
*/
|
|
279
|
+
export async function startCdpTransport({
|
|
280
|
+
chromium, proxy, spawn = spawnChild, WebSocketImpl = globalThis.WebSocket,
|
|
281
|
+
kill = killProcessTree, launchTimeoutMs = DEVTOOLS_LAUNCH_TIMEOUT_MS,
|
|
282
|
+
commandTimeoutMs = CDP_COMMAND_TIMEOUT_MS, now = Date.now, userDataDir, endpoint: fixedEndpoint,
|
|
283
|
+
env = process.env,
|
|
284
|
+
preserveUserDataDir = false, startUrl,
|
|
285
|
+
} = {}) {
|
|
286
|
+
if (preserveUserDataDir && (!userDataDir || !isAbsolute(userDataDir))) throw fail('持久浏览器配置目录必须为绝对路径', 'ASSISTANT_BROWSER_PROFILE_INVALID');
|
|
287
|
+
const launched = fixedEndpoint
|
|
288
|
+
? { child: undefined, userDataDir, endpoint: { endpoint: fixedEndpoint }, stderr: { text: '' } }
|
|
289
|
+
: await launchChromium({ chromium, proxy, spawn, launchTimeoutMs, now, userDataDir, env, preserveUserDataDir, startUrl });
|
|
290
|
+
let connection;
|
|
291
|
+
try { connection = await connectCdp({ endpoint: launched.endpoint.endpoint, WebSocketImpl, commandTimeoutMs }); }
|
|
292
|
+
catch (error) {
|
|
293
|
+
await stopOwnChromium(launched.child, kill, { wait: preserveUserDataDir });
|
|
294
|
+
if (launched.userDataDir && !preserveUserDataDir) await rm(launched.userDataDir, { recursive: true, force: true }).catch(() => {});
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
let disposing;
|
|
298
|
+
return {
|
|
299
|
+
connection,
|
|
300
|
+
child: launched.child,
|
|
301
|
+
userDataDir: launched.userDataDir,
|
|
302
|
+
stderr: launched.stderr,
|
|
303
|
+
dispose() {
|
|
304
|
+
if (disposing) return disposing;
|
|
305
|
+
disposing = (async () => {
|
|
306
|
+
try { connection.close(); } catch {}
|
|
307
|
+
await stopOwnChromium(launched.child, kill, { wait: preserveUserDataDir, graceMs: preserveUserDataDir ? 1000 : 0 });
|
|
308
|
+
if (launched.userDataDir && !preserveUserDataDir) await rm(launched.userDataDir, { recursive: true, force: true }).catch(() => {});
|
|
309
|
+
})();
|
|
310
|
+
return disposing;
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
}
|