@kingsoftcloud/agentkit-personal-assistant 0.0.0-stage → 0.1.7
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 +733 -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 +281 -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,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 仅在全新一次性 Linux 容器中验证真实 dock 点击、文件管理器与进程清理。
|
|
3
|
+
* 禁止在正在运行的云端 Pod 执行;已有 :99 桌面时会在写文件前拒绝启动。
|
|
4
|
+
* 截图为显式验收产物,不记录视频流。此结果不替代目标 AppArmor 证明。
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert/strict';import {execFile as cb} from 'node:child_process';import {promisify} from 'node:util';import {readFile,readdir,writeFile,mkdir,access,stat} from 'node:fs/promises';
|
|
7
|
+
import {createComputerAgentServer} from '../../runtime/computer-agentd.mjs';import {createDesktopController} from '../../runtime/desktop-session.mjs';
|
|
8
|
+
import {createCloudBrowser} from '../../runtime/cloud-browser.mjs';import {startCdpTransport} from '../../runtime/cloud-browser-cdp.mjs';
|
|
9
|
+
const args=process.argv.slice(2), index=args.indexOf('--evidence');
|
|
10
|
+
const evidence=index<0?'/tmp/pa-desktop-app-evidence':args[index+1];
|
|
11
|
+
assert.equal(process.platform,'linux','仅用于一次性 Linux 容器');
|
|
12
|
+
assert.ok(!process.env.KUBERNETES_SERVICE_HOST,'禁止在 Kubernetes Pod 中新建验收桌面,请使用现存 Broker 的网关验收脚本');
|
|
13
|
+
try { await access('/tmp/.X11-unix/X99'); throw new Error('检测到现有 :99 桌面,禁止启动第二套验收桌面'); } catch(error) { if(error.code!=='ENOENT')throw error; }
|
|
14
|
+
await mkdir(evidence,{recursive:true});
|
|
15
|
+
const exec=promisify(cb),delay=ms=>new Promise(r=>setTimeout(r,ms)),env={...process.env,DISPLAY:':99',AGENTKIT_DESKTOP_MODE:'1',AGENTKIT_WORKSPACE_DIR:'/workspace',COMPUTER_PORT:'0',USER_ID:'local-proof',ASSISTANT_ID:'local-proof',COMPUTER_ID:'local-proof'};
|
|
16
|
+
process.env.AGENTKIT_DESKTOP_MODE='1';process.env.DISPLAY=':99';
|
|
17
|
+
const identity={userId:'local-proof',assistantId:'local-proof',computerId:'local-proof',botId:'local-proof',taskId:'local-proof'};let desktop;const transports=[],launchErrors=[];const service=createComputerAgentServer({env,logger:{info:line=>console.info(line),error:line=>{console.error(line);try{const event=JSON.parse(line);if(event.event==='desktop-app-launch-failed')launchErrors.push(event)}catch{}}},desktopFactory:options=>(desktop=createDesktopController(options)),browserFactory:options=>createCloudBrowser({...options,transportFactory:async args=>{const transport=await startCdpTransport(args);transports.push(transport);return transport}})});const report={scope:'本地 Linux 依赖probe+候选源码,非最终镜像/目标AppArmor',checks:[]};
|
|
18
|
+
const ps=async()=>{const a=[];for(const p of await readdir('/proc'))if(/^\d+$/.test(p)){try{const state=await readFile(`/proc/${p}/status`,'utf8');if(/^State:\s+Z/m.test(state))continue;const comm=(await readFile(`/proc/${p}/comm`,'utf8')).trim(),args=(await readFile(`/proc/${p}/cmdline`,'utf8')).split('\0').filter(Boolean);if(args.length&&(['Xvfb','fluxbox','xterm','tint2','thunar','dbus-daemon','xfconfd'].includes(comm)||(comm==='chromium'&&!args.some(s=>s.startsWith('--type=')))))a.push({pid:+p,comm,args})}catch{}}return a};
|
|
19
|
+
const wait=async(fn,label,timeout=6000)=>{const end=Date.now()+timeout;while(Date.now()<end){try{const r=await fn();if(r)return r}catch{}await delay(50)}throw new Error('timeout '+label)};
|
|
20
|
+
const geometry=async id=>Object.fromEntries((await exec('xdotool',['getwindowgeometry','--shell',id],{env})).stdout.trim().split('\n').map(x=>x.split('=')));
|
|
21
|
+
const window=async pattern=>{let ids;try{ids=(await exec('xdotool',['search','--onlyvisible','--class',pattern],{env})).stdout.trim().split('\n')}catch{return null}for(const id of ids){const g=await geometry(id);if(+g.WIDTH>=300&&+g.HEIGHT>=200)return{id,geometry:g,name:(await exec('xdotool',['getwindowname',id],{env})).stdout.trim()}}return null};
|
|
22
|
+
let port,leaseId,sessionId;
|
|
23
|
+
const post=async(path,payload={},status=200)=>{const r=await fetch(`http://127.0.0.1:${port}/broker/${path}`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({...identity,leaseId,sessionId,...payload})});const b=await r.json();assert.equal(r.status,status,JSON.stringify(b));return b};
|
|
24
|
+
const click=async(x,y)=>{for(const event of [{type:'pointermove',x,y},{type:'button',button:1,action:'down',x,y},{type:'button',button:1,action:'up'}])await post('desktop/input',{event},202)};
|
|
25
|
+
try{
|
|
26
|
+
await writeFile('/workspace/PA_VISIBLE_FILE.txt','本地真实文件管理器验收\n');service.listen();await service.whenReady;port=service.server.address().port;
|
|
27
|
+
leaseId=(await post('lease/acquire')).lease.leaseId;sessionId=(await post('desktop/session')).sessionId;
|
|
28
|
+
const dockId=await wait(async()=>{try{return(await exec('xdotool',['search','--onlyvisible','--name','^AgentKit Dock$'],{env})).stdout.trim().split('\n')[0]}catch{return null}},'dock visible');const dg=await geometry(dockId);report.dockGeometry=dg;
|
|
29
|
+
await exec('import',['-display',':99','-window','root',evidence+'/dock-three-apps.png'],{env});
|
|
30
|
+
const initial=await ps(),oldChrome=initial.find(x=>x.comm==='chromium');assert.ok(oldChrome);report.initialProcesses=initial;
|
|
31
|
+
const originalProfile=transports[0].userDataDir;assert.equal(originalProfile,'/var/lib/agentkit/computer/chromium-profile');assert.equal((await stat(originalProfile)).mode&0o777,0o700);await writeFile(originalProfile+'/fixture-sentinel','private-local-fixture');const fixtureCookie={name:'pa_local_fixture',value:'local-proof-not-a-credential',url:'https://example.com/',expires:Math.floor(Date.now()/1000)+3600};await transports[0].connection.send('Storage.setCookies',{cookies:[fixtureCookie]});
|
|
32
|
+
const positions={browser:{x:+dg.X+32,y:+dg.Y+27},terminal:{x:+dg.X+82,y:+dg.Y+27},files:{x:+dg.X+132,y:+dg.Y+27}};
|
|
33
|
+
await click(positions.files.x,positions.files.y);const files=await wait(()=>window('Thunar'),'visible workspace folder');report.fileWindow=files;assert.match(files.name,/workspace/i);await delay(300);await exec('import',['-display',':99','-window','root',evidence+'/workspace-file-manager.png'],{env});report.checks.push({name:'真实点击文件入口打开 /workspace 并显示目录',passed:true});
|
|
34
|
+
await click(positions.terminal.x,positions.terminal.y);await wait(async()=>{const id=(await exec('xdotool',['getactivewindow'],{env})).stdout.trim();return(await exec('xprop',['-display',':99','-id',id,'WM_CLASS'],{env})).stdout.includes('XTerm')},'terminal focus');report.checks.push({name:'真实点击终端入口复用现有终端',passed:true});
|
|
35
|
+
await click(positions.browser.x,positions.browser.y);await wait(async()=>{const id=(await exec('xdotool',['getactivewindow'],{env})).stdout.trim();return(await exec('xdotool',['getwindowname',id],{env})).stdout.includes('Chromium')},'browser focus');assert.equal((await ps()).find(x=>x.comm==='chromium').pid,oldChrome.pid);report.checks.push({name:'真实点击浏览器入口复用受管 Chromium 主进程',passed:true,pid:oldChrome.pid});
|
|
36
|
+
let fileWindow=files;
|
|
37
|
+
for(let n=0;n<10;n++){
|
|
38
|
+
await exec('xdotool',['windowclose',fileWindow.id],{env});await wait(async()=>!desktop.stats().fileManagerRunning,'file manager exit');
|
|
39
|
+
await click(positions.files.x,positions.files.y);fileWindow=await wait(()=>window('Thunar'),'file reopen');const current=await ps();for(const comm of ['Xvfb','tint2','thunar'])assert.equal(current.filter(x=>x.comm===comm).length,1,comm+' duplicated');
|
|
40
|
+
}
|
|
41
|
+
report.checks.push({name:'文件管理器连续开关十次,Xvfb/tint2/thunar 始终各一个',passed:true});
|
|
42
|
+
await click(positions.browser.x,positions.browser.y);await wait(async()=>{const id=(await exec('xdotool',['getactivewindow'],{env})).stdout.trim();return(await exec('xprop',['-display',':99','-id',id,'WM_CLASS'],{env})).stdout.includes('Chromium')},'browser foreground before close');const chromeWindow=await window('Chromium');assert.ok(chromeWindow);report.beforeBrowserClose=chromeWindow;await exec('import',['-display',':99','-window','root',evidence+'/before-browser-close.png'],{env});await click(+chromeWindow.geometry.X + +chromeWindow.geometry.WIDTH - 20,+chromeWindow.geometry.Y + 20);await wait(async()=>!await window('Chromium'),'actual browser close button');await click(positions.browser.x,positions.browser.y);const reopened=await wait(()=>window('Chromium'),'managed browser reopen');const after=await ps();assert.equal(after.filter(x=>x.comm==='chromium').length,1);report.checks.push({name:'浏览器关窗后从同一入口恢复,仍仅一个受管主进程',passed:true,beforePid:oldChrome.pid,afterPid:after.find(x=>x.comm==='chromium').pid,window:reopened});
|
|
43
|
+
await wait(()=>transports.length>=2&&transports.at(-1).connection&&!transports.at(-1).connection.closed,'reopened managed CDP');const recovered=transports.at(-1);assert.equal(recovered.userDataDir,originalProfile);assert.equal(await readFile(originalProfile+'/fixture-sentinel','utf8'),'private-local-fixture');const cookies=await recovered.connection.send('Storage.getCookies',{});assert.ok(cookies.cookies.some(c=>c.name===fixtureCookie.name&&c.value===fixtureCookie.value));report.checks.push({name:'真实关闭并恢复后私有 profile 路径、fixture cookie 与 sentinel 保留',passed:true,samePrivateProfile:true,fixtureCookiePreserved:true,sentinelPreserved:true});
|
|
44
|
+
await post('desktop/close');report.parkedStats=desktop.stats();assert.equal(report.parkedStats.dockRunning,true);assert.equal(report.parkedStats.fileManagerRunning,true);report.checks.push({name:'关闭观察保留 dock、文件管理器与原桌面',passed:true});
|
|
45
|
+
report.beforeDispose=await ps();await service.close();assert.equal(await readFile(originalProfile+'/fixture-sentinel','utf8'),'private-local-fixture');await delay(200);report.afterDispose=await ps();assert.equal(report.afterDispose.length,0,JSON.stringify(report.afterDispose));report.checks.push({name:'dispose 清理 X/WM/dock/终端/浏览器及私有DBus进程组',passed:true});report.launchErrors=launchErrors;assert.deepEqual(launchErrors,[],'应用入口不能出现被窗口表象掩盖的失败');report.passed=true;
|
|
46
|
+
}catch(e){report.passed=false;report.error={message:e.message,stack:e.stack};console.error(e);await service.close().catch(()=>{});process.exitCode=1}finally{await writeFile(evidence+'/report.json',JSON.stringify(report,null,2));console.log(JSON.stringify({passed:report.passed,checks:report.checks,error:report.error,dockGeometry:report.dockGeometry,fileWindow:report.fileWindow},null,2))}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/** 在真实 Linux/X11 中验证输入与窗口保留;不替代目标 Pod/AppArmor 验收。 */
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { execFile as execFileCallback } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { createDesktopController } from '../../runtime/desktop-session.mjs';
|
|
9
|
+
import { createCloudBrowser } from '../../runtime/cloud-browser.mjs';
|
|
10
|
+
|
|
11
|
+
const execFile = promisify(execFileCallback), args = process.argv.slice(2);
|
|
12
|
+
const argument = (name, fallback) => { const index = args.indexOf(name); return index === -1 ? fallback : args[index + 1]; };
|
|
13
|
+
const observerMs = Number(argument('--observer-ms', 180_000));
|
|
14
|
+
const parkMs = Number(argument('--park-ms', 720_000));
|
|
15
|
+
assert.ok(Number.isFinite(observerMs) && observerMs >= 0 && Number.isFinite(parkMs) && parkMs >= 0);
|
|
16
|
+
const evidence = argument('--evidence', '/tmp/pa-desktop-evidence');
|
|
17
|
+
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
18
|
+
const workspace = await mkdtemp(join(tmpdir(), 'pa-real-input-'));
|
|
19
|
+
await mkdir(evidence, { recursive: true });
|
|
20
|
+
process.env.AGENTKIT_DESKTOP_MODE = '1';
|
|
21
|
+
const desktop = createDesktopController(), browser = createCloudBrowser({ workspace });
|
|
22
|
+
const report = { environment: '本地 Linux 容器,非预发目标 AppArmor', startedAt: new Date().toISOString(), observerMs, parkMs, checks: [] };
|
|
23
|
+
const pass = (name, data = {}) => { const check = { name, passed: true, ...data }; report.checks.push(check); console.log(JSON.stringify(check)); };
|
|
24
|
+
const waitFile = async (path, bytes = 1) => {
|
|
25
|
+
const deadline = Date.now() + 5000;
|
|
26
|
+
while (Date.now() < deadline) {
|
|
27
|
+
try { const value = await readFile(path); if (value.length >= bytes) return value; }
|
|
28
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
29
|
+
await delay(25);
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`terminal did not create ${path}`);
|
|
32
|
+
};
|
|
33
|
+
const processList = async () => {
|
|
34
|
+
const found = [];
|
|
35
|
+
for (const pid of await readdir('/proc')) if (/^\d+$/.test(pid)) {
|
|
36
|
+
try {
|
|
37
|
+
const argv = (await readFile(`/proc/${pid}/cmdline`, 'utf8')).split('\0');
|
|
38
|
+
if (/(?:^|\/)(?:Xvfb|fluxbox|xterm|chromium)$/.test(argv[0])) found.push({ pid: Number(pid), binary: argv[0], argv });
|
|
39
|
+
} catch { /* 已结束的子进程无需采集。 */ }
|
|
40
|
+
}
|
|
41
|
+
return found;
|
|
42
|
+
};
|
|
43
|
+
try {
|
|
44
|
+
report.appArmor = (await readFile('/proc/self/attr/current', 'utf8').catch(() => 'unavailable')).trim();
|
|
45
|
+
const session = await desktop.openSession({ identity: { userId: 'local-proof', assistantId: 'local-proof', computerId: 'local-proof' }, controllable: true, leaseId: 'local-proof' });
|
|
46
|
+
const input = event => desktop.input({ sessionId: session.sessionId, event });
|
|
47
|
+
const text = value => input({ type: 'text', text: value });
|
|
48
|
+
const key = value => input({ type: 'key', key: value });
|
|
49
|
+
await input({ type: 'button', action: 'down', button: 1, x: 120, y: 140 });
|
|
50
|
+
await input({ type: 'button', action: 'up', button: 1 });
|
|
51
|
+
await key('ctrl+c');
|
|
52
|
+
const typedFile = join(workspace, 'typed');
|
|
53
|
+
await text(`cat > ${typedFile}`); await key('Enter'); await delay(100);
|
|
54
|
+
const ascii = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'.repeat(3).slice(0, 100);
|
|
55
|
+
const chinese = '你好云电脑'.repeat(10);
|
|
56
|
+
for (const value of [ascii, chinese]) { await Promise.all([...value].map(character => text(character))); await key('Enter'); }
|
|
57
|
+
await key('ctrl+d');
|
|
58
|
+
const received = (await waitFile(typedFile, Buffer.byteLength(`${ascii}\n${chinese}\n`))).toString('utf8');
|
|
59
|
+
assert.equal(received, `${ascii}\n${chinese}\n`);
|
|
60
|
+
pass('100 ASCII 与 50 中文快速输入逐字一致,Enter 正常', { asciiCharacters: ascii.length, chineseCharacters: [...chinese].length, utf8Bytes: Buffer.byteLength(received) });
|
|
61
|
+
|
|
62
|
+
const backspaceFile = join(workspace, 'backspace');
|
|
63
|
+
await text('printf %s ABCX'); await key('Backspace'); await text(` > ${backspaceFile}`); await key('Enter');
|
|
64
|
+
assert.equal((await waitFile(backspaceFile, 3)).toString(), 'ABC');
|
|
65
|
+
pass('Backspace 正常,不与前后 text 乱序');
|
|
66
|
+
|
|
67
|
+
const bad = join(workspace, 'ctrlc-failed'), good = join(workspace, 'ctrlc-ok');
|
|
68
|
+
await text(`sleep 30; touch ${bad}`); await key('Enter'); await delay(100); await key('ctrl+c');
|
|
69
|
+
await text(`printf ok > ${good}`); await key('Enter');
|
|
70
|
+
assert.equal((await waitFile(good, 2)).toString(), 'ok');
|
|
71
|
+
await assert.rejects(readFile(bad), { code: 'ENOENT' });
|
|
72
|
+
pass('Ctrl+C 中断终端程序,后续输入继续执行');
|
|
73
|
+
|
|
74
|
+
const completion = join(workspace, 'completion'); await mkdir(completion);
|
|
75
|
+
await writeFile(join(completion, 'unique-entry'), 'fixture');
|
|
76
|
+
const tabFile = join(workspace, 'tab');
|
|
77
|
+
await text(`printf %s ${completion}/uni`); await key('Tab'); await text(` > ${tabFile}`); await key('Enter');
|
|
78
|
+
assert.equal((await waitFile(tabFile, Buffer.byteLength(join(completion, 'unique-entry')))).toString(), join(completion, 'unique-entry'));
|
|
79
|
+
pass('Tab 在真实终端完成文件名补全');
|
|
80
|
+
|
|
81
|
+
const escapeFile = join(workspace, 'escape');
|
|
82
|
+
await text(`stty -icanon -echo; dd bs=1 count=1 of=${escapeFile}; stty sane`); await key('Enter'); await delay(100); await key('Escape');
|
|
83
|
+
assert.deepEqual(await waitFile(escapeFile), Buffer.from([27]));
|
|
84
|
+
pass('Esc 到达真实终端,字节为 27');
|
|
85
|
+
|
|
86
|
+
const font = await execFile('fc-match', ['Noto Sans CJK SC', '--format=%{family}\n']);
|
|
87
|
+
assert.match(font.stdout, /Noto Sans CJK SC/);
|
|
88
|
+
// xterm 字体度量可能把右侧撑满;左侧 10px 一定在窗口外的根桌面。
|
|
89
|
+
await input({ type: 'button', action: 'click', button: 3, x: 10, y: 500 });
|
|
90
|
+
await delay(250);
|
|
91
|
+
await execFile('import', ['-display', process.env.DISPLAY || ':99', '-window', 'root', join(evidence, 'menu-chinese.png')]);
|
|
92
|
+
pass('菜单中文字体已解析,显式截图已保存', { font: font.stdout.trim(), screenshot: 'menu-chinese.png' });
|
|
93
|
+
await key('Escape');
|
|
94
|
+
|
|
95
|
+
await browser.openDesktopWindow();
|
|
96
|
+
await desktop.frameBinary({ sessionId: session.sessionId });
|
|
97
|
+
const initial = await processList();
|
|
98
|
+
for (const binary of ['Xvfb', 'fluxbox', 'xterm', 'chromium']) assert.ok(initial.some(value => value.binary.split('/').at(-1) === binary), `missing real process: ${binary}`);
|
|
99
|
+
await writeFile(join(evidence, 'processes-before.json'), JSON.stringify(initial, null, 2));
|
|
100
|
+
console.log(JSON.stringify({ waiting: '旁观保活', milliseconds: observerMs }));
|
|
101
|
+
await delay(observerMs);
|
|
102
|
+
assert.equal(desktop.getSession(session.sessionId)?.idle, false);
|
|
103
|
+
assert.equal(desktop.stats().xserverRunning, true);
|
|
104
|
+
assert.ok((await desktop.frameBinary({ sessionId: session.sessionId })).bytes > 0);
|
|
105
|
+
pass('真实计时旁观不休眠且仍出帧', { elapsedMs: observerMs });
|
|
106
|
+
desktop.closeSession(session.sessionId);
|
|
107
|
+
console.log(JSON.stringify({ waiting: '无观察者保留窗口', milliseconds: parkMs }));
|
|
108
|
+
await delay(parkMs);
|
|
109
|
+
assert.equal(desktop.stats().collectorRunning, false);
|
|
110
|
+
const reopened = await desktop.openSession({ identity: {} });
|
|
111
|
+
const retained = await processList();
|
|
112
|
+
await writeFile(join(evidence, 'processes-after.json'), JSON.stringify(retained, null, 2));
|
|
113
|
+
const windows = initial.filter(value => /(?:^|\/)(?:Xvfb|fluxbox|xterm)$/.test(value.binary) || (/(?:^|\/)chromium$/.test(value.binary) && !value.argv.some(arg => arg.startsWith('--type='))));
|
|
114
|
+
for (const child of windows) assert.ok(retained.some(value => value.pid === child.pid), `window process restarted: ${child.binary} ${child.pid}`);
|
|
115
|
+
assert.ok((await desktop.frameBinary({ sessionId: reopened.sessionId })).bytes > 0);
|
|
116
|
+
await execFile('import', ['-display', process.env.DISPLAY || ':99', '-window', 'root', join(evidence, 'retained-windows.png')]);
|
|
117
|
+
pass('无观察者后恢复仍为原 X11/WM/终端/Chromium 进程', { elapsedMs: parkMs, preservedPids: windows.map(value => value.pid), screenshot: 'retained-windows.png' });
|
|
118
|
+
report.passed = true;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
report.passed = false; report.error = { code: error.code, message: error.message, stack: error.stack };
|
|
121
|
+
console.error(error); process.exitCode = 1;
|
|
122
|
+
} finally {
|
|
123
|
+
report.finishedAt = new Date().toISOString();
|
|
124
|
+
await writeFile(join(evidence, 'report.json'), JSON.stringify(report, null, 2));
|
|
125
|
+
await browser.dispose(); await desktop.dispose(); await rm(workspace, { recursive: true, force: true });
|
|
126
|
+
console.log(`REPORT_JSON ${JSON.stringify(report)}`);
|
|
127
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 仅在已运行的 computer 容器内执行。stdin 接收 JSON 凭据,只访问既有 Broker/Gateway,
|
|
3
|
+
* 不创建 Controller/Xvfb、不修改策略、不关闭用户窗口。测试文件保留供验收追查。
|
|
4
|
+
*/
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { spawn, execFile as execFileCallback } from 'node:child_process';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { mkdtemp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { openGatewayDesktop } from './gateway-desktop-client.mjs';
|
|
12
|
+
|
|
13
|
+
const execFile = promisify(execFileCallback), sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
14
|
+
let raw = ''; for await (const chunk of process.stdin) { raw += chunk; if (raw.length > 16384) throw new Error('verification stdin exceeds limit'); }
|
|
15
|
+
let input;
|
|
16
|
+
try { input = JSON.parse(raw); } catch { throw new Error('verification stdin must be valid JSON'); }
|
|
17
|
+
raw = '';
|
|
18
|
+
assert.ok(input.gatewayUrl && input.apiKey && input.agentId && input.botId && input.secondaryBotId && input.secondaryBotId !== input.botId, 'stdin requires gatewayUrl/apiKey/agentId/botId/secondaryBotId');
|
|
19
|
+
const observerMs = input.observerMs ?? 180000, parkMs = input.parkMs ?? 720000;
|
|
20
|
+
assert.ok(Number.isSafeInteger(observerMs) && observerMs >= 0 && Number.isSafeInteger(parkMs) && parkMs >= 0);
|
|
21
|
+
const workspace = process.env.AGENTKIT_WORKSPACE_DIR || '/workspace', artifacts = process.env.AGENTKIT_ARTIFACTS_DIR || '/artifacts';
|
|
22
|
+
const files = await mkdtemp(join(workspace, '.pa-e2e-')), evidence = await mkdtemp(join(artifacts, 'desktop-e2e-'));
|
|
23
|
+
const identity = { userId: process.env.USER_ID, assistantId: process.env.ASSISTANT_ID, computerId: process.env.COMPUTER_ID };
|
|
24
|
+
assert.equal(identity.assistantId, input.agentId, 'test Agent must match this running Pod');
|
|
25
|
+
assert.ok(identity.userId && identity.computerId);
|
|
26
|
+
const report = { startedAt: new Date().toISOString(), agentId: input.agentId, computerId: identity.computerId, evidence, files, observerMs, parkMs, checks: [] };
|
|
27
|
+
const connections = []; let fixtureTerminal, reopenedSession;
|
|
28
|
+
const record = (name, details = {}) => { const result = { name, passed: true, ...details }; report.checks.push(result); console.log(JSON.stringify(result)); };
|
|
29
|
+
const safeError = error => ({ code: error.code, status: error.status, message: String(error.message).replaceAll(String(input.apiKey), '[redacted]').slice(0, 1000) });
|
|
30
|
+
const broker = async (path, payload = {}, expectedStatus = 200) => {
|
|
31
|
+
const response = await fetch(`http://127.0.0.1:${process.env.COMPUTER_PORT || 8787}${path}`, {
|
|
32
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...identity, ...payload }), signal: AbortSignal.timeout(12000),
|
|
33
|
+
});
|
|
34
|
+
if (response.status !== expectedStatus) throw Object.assign(new Error(`Broker ${path} HTTP ${response.status}: ${(await response.text()).slice(0, 300)}`), { status: response.status });
|
|
35
|
+
return response.headers.get('content-type')?.includes('application/json') ? response.json() : response.arrayBuffer();
|
|
36
|
+
};
|
|
37
|
+
const guiProcesses = async () => {
|
|
38
|
+
const found = [];
|
|
39
|
+
for (const pid of await readdir('/proc')) if (/^\d+$/u.test(pid)) try {
|
|
40
|
+
const argv = (await readFile(`/proc/${pid}/cmdline`, 'utf8')).split('\0'), binary = argv[0].split('/').at(-1);
|
|
41
|
+
if (['Xvfb', 'fluxbox', 'xterm', 'ffmpeg', 'tint2', 'thunar', 'Thunar', 'dbus-run-session', 'dbus-daemon'].includes(binary) || (binary === 'chromium' && !argv.some(value => value.startsWith('--type=')))) found.push({ pid: Number(pid), binary, argv });
|
|
42
|
+
} catch { /* 进程退出的瞬时竞态。 */ }
|
|
43
|
+
return found;
|
|
44
|
+
};
|
|
45
|
+
const waitFor = async (predicate, description, timeout = 10000) => {
|
|
46
|
+
const deadline = Date.now() + timeout;
|
|
47
|
+
do { const value = await predicate(); if (value) return value; await sleep(25); } while (Date.now() < deadline);
|
|
48
|
+
throw new Error(`timeout: ${description}`);
|
|
49
|
+
};
|
|
50
|
+
const waitFile = (path, bytes) => waitFor(async () => { try { const value = await readFile(path); return value.length >= bytes && value; } catch (error) { if (error.code !== 'ENOENT') throw error; } }, path);
|
|
51
|
+
const connect = async (botId, mode) => {
|
|
52
|
+
const value = await openGatewayDesktop({ ...input, botId, mode, taskId: `pod-e2e-${randomUUID()}` });
|
|
53
|
+
connections.push(value.client); return value;
|
|
54
|
+
};
|
|
55
|
+
const acquire = client => client.request({ type: 'control.acquire', controlEpoch: client.controlEpoch }, value => value.type === 'control' && value.readOnly === false);
|
|
56
|
+
const release = client => client.request({ type: 'control.release', controlEpoch: client.controlEpoch }, value => value.type === 'control' && value.readOnly === true);
|
|
57
|
+
const event = (client, value) => client.request({ type: 'input', controlEpoch: client.controlEpoch, event: value }, reply => reply.type === 'input-accepted');
|
|
58
|
+
const key = (client, value) => event(client, { type: 'key', key: value });
|
|
59
|
+
const text = (client, value) => event(client, { type: 'text', text: value });
|
|
60
|
+
const click = async client => {
|
|
61
|
+
await event(client, { type: 'pointermove', x: 120, y: 150 });
|
|
62
|
+
await event(client, { type: 'button', button: 1, action: 'down', x: 120, y: 150 });
|
|
63
|
+
await event(client, { type: 'button', button: 1, action: 'up' });
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const currentProfile = (await readFile('/proc/self/attr/current', 'utf8')).trim();
|
|
68
|
+
const serviceProfile = (await readFile('/proc/1/attr/current', 'utf8')).trim();
|
|
69
|
+
assert.match(currentProfile, /^agentengine-dsh-sandbox-v1(?:\s|$)/u);
|
|
70
|
+
assert.match(serviceProfile, /^agentengine-dsh-sandbox-v1(?:\s|$)/u);
|
|
71
|
+
report.appArmor = { current: currentProfile, service: serviceProfile };
|
|
72
|
+
record('真实目标 DSH AppArmor profile 生效', report.appArmor);
|
|
73
|
+
for (const path of ['/health', '/v1/status']) {
|
|
74
|
+
const response = await fetch(`http://127.0.0.1:${process.env.COMPUTER_PORT || 8787}${path}`);
|
|
75
|
+
assert.equal(response.status, 200); const value = await response.json();
|
|
76
|
+
assert.equal(value.computerId, identity.computerId); assert.equal(value.assistantId, input.agentId); assert.equal(value.desktopReady, true);
|
|
77
|
+
}
|
|
78
|
+
const original = await guiProcesses();
|
|
79
|
+
assert.equal(original.filter(value => value.binary === 'Xvfb').length, 1, '运行前必须已有且只有一个 Xvfb');
|
|
80
|
+
assert.equal(original.filter(value => value.binary === 'tint2').length, 1, '运行前必须已有且只有一个应用底栏');
|
|
81
|
+
record('当前 Broker 健康且已有单个真实桌面');
|
|
82
|
+
|
|
83
|
+
const { client: reader, ready: readReady } = await connect(input.botId, 'read');
|
|
84
|
+
assert.equal(readReady.readOnly, true);
|
|
85
|
+
await assert.rejects(event(reader, { type: 'text', text: 'MUST_NOT_APPEAR' }), { status: 403, code: 'DESKTOP_READ_ONLY' });
|
|
86
|
+
await assert.rejects(acquire(reader), { status: 403 });
|
|
87
|
+
await waitFor(() => reader.frames.length > 0, '只读票据仍能看到桌面');
|
|
88
|
+
record('真实网关 read 票据拒绝输入和接管,仍能接收 JPEG');
|
|
89
|
+
|
|
90
|
+
const { client: primary, ready } = await connect(input.botId, 'control');
|
|
91
|
+
assert.equal(ready.readOnly, true); assert.equal(ready.controlAvailable, true);
|
|
92
|
+
await waitFor(() => primary.frames.length > 0, '首帧');
|
|
93
|
+
const firstFrameMs = primary.ticketMs + primary.frames[0].at - primary.openedAt;
|
|
94
|
+
assert.ok(firstFrameMs <= 3000, `first frame ${firstFrameMs}ms exceeds 3s`);
|
|
95
|
+
record('网关出票、WSS 与首帧真实通过', { ticketMs: primary.ticketMs, handshakeMs: primary.handshakeMs, firstFrameMs, firstFrameBytes: primary.frames[0].bytes });
|
|
96
|
+
await acquire(primary);
|
|
97
|
+
|
|
98
|
+
// 独立验收终端复用现有 DISPLAY,不触碰用户正在运行的 shell/程序。
|
|
99
|
+
const title = `PA_E2E_${randomUUID()}`;
|
|
100
|
+
fixtureTerminal = spawn('xterm', ['-display', process.env.DISPLAY || ':99', '-u8', '-fa', 'DejaVu Sans Mono', '-fs', '12', '-geometry', '56x24+30+80', '-title', title,
|
|
101
|
+
'-xrm', 'XTerm*VT100.translations: #override\\nCtrl Shift <Key>V: insert-selection(CLIPBOARD)', '-e', 'bash', '--noprofile', '--norc'], {
|
|
102
|
+
cwd: files, env: { ...process.env, LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', PS1: 'pa-e2e> ', HISTFILE: '/dev/null' }, stdio: 'ignore',
|
|
103
|
+
});
|
|
104
|
+
let terminalError; fixtureTerminal.once('error', error => { terminalError = error; });
|
|
105
|
+
await waitFor(async () => { if (terminalError) throw terminalError; try { return (await execFile('xdotool', ['search', '--onlyvisible', '--name', `^${title}$`])).stdout.trim(); } catch { return false; } }, '验收终端窗口');
|
|
106
|
+
await click(primary);
|
|
107
|
+
const typedPath = join(files, 'typed');
|
|
108
|
+
await text(primary, `cat > ${typedPath}`); await key(primary, 'Enter');
|
|
109
|
+
const ascii = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'.repeat(3).slice(0, 100), chinese = '你好云电脑'.repeat(10);
|
|
110
|
+
await text(primary, ascii); await key(primary, 'Enter'); await text(primary, chinese); await key(primary, 'Enter'); await key(primary, 'ctrl+d');
|
|
111
|
+
assert.equal((await waitFile(typedPath, 252)).toString('utf8'), `${ascii}\n${chinese}\n`);
|
|
112
|
+
record('通过真实 Gateway→Runtime WS→Broker,100 ASCII 与 50 中文精确回显', { utf8Bytes: 252 });
|
|
113
|
+
const bs = join(files, 'backspace');
|
|
114
|
+
await text(primary, 'printf %s ABCX'); await key(primary, 'Backspace'); await text(primary, ` > ${bs}`); await key(primary, 'Enter');
|
|
115
|
+
assert.equal((await waitFile(bs, 3)).toString(), 'ABC');
|
|
116
|
+
const interrupted = join(files, 'must-not-exist'), continued = join(files, 'ctrlc');
|
|
117
|
+
await text(primary, `sleep 30; touch ${interrupted}`); await key(primary, 'Enter'); await sleep(100); await key(primary, 'ctrl+c');
|
|
118
|
+
await text(primary, `printf ok > ${continued}`); await key(primary, 'Enter');
|
|
119
|
+
assert.equal((await waitFile(continued, 2)).toString(), 'ok'); await assert.rejects(readFile(interrupted), { code: 'ENOENT' });
|
|
120
|
+
record('真实 WS 的 Backspace 与 Ctrl+C 不乱序、不中断用户终端');
|
|
121
|
+
const escaped = join(files, 'escape');
|
|
122
|
+
await text(primary, `stty -icanon -echo; dd bs=1 count=1 of=${escaped}; stty sane`); await key(primary, 'Enter'); await sleep(100); await key(primary, 'Escape');
|
|
123
|
+
assert.deepEqual(await waitFile(escaped, 1), Buffer.from([27]));
|
|
124
|
+
const completion = join(files, 'unique-completion'); await writeFile(completion, 'fixture');
|
|
125
|
+
const completed = join(files, 'tab');
|
|
126
|
+
await text(primary, `printf %s ${files}/unique-comp`); await key(primary, 'Tab'); await text(primary, ` > ${completed}`); await key(primary, 'Enter');
|
|
127
|
+
assert.equal((await waitFile(completed, Buffer.byteLength(completion))).toString(), completion);
|
|
128
|
+
record('真实 WS 的 Esc 与 Tab 补全正确');
|
|
129
|
+
// 真实容器 cgroup 计数,包含采集/编码、Broker 和本验收的协议开销。
|
|
130
|
+
const cpuSeconds = async () => {
|
|
131
|
+
try { return Number((await readFile('/sys/fs/cgroup/cpu.stat', 'utf8')).match(/^usage_usec (\d+)$/mu)?.[1]) / 1e6; }
|
|
132
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; return Number(await readFile('/sys/fs/cgroup/cpuacct/cpuacct.usage', 'utf8')) / 1e9; }
|
|
133
|
+
};
|
|
134
|
+
const cpuBefore = await cpuSeconds(), activeAt = Date.now(); await sleep(2000);
|
|
135
|
+
const cpuAfter = await cpuSeconds(), measuredMs = Date.now() - activeAt;
|
|
136
|
+
const activeFrames = primary.frames.filter(frame => frame.at >= activeAt).length, activeFps = activeFrames / 2;
|
|
137
|
+
assert.ok(activeFps >= 10, `active fps ${activeFps} < 10`);
|
|
138
|
+
const cpuPercentOfOneCore = (cpuAfter - cpuBefore) / (measuredMs / 1000) * 100;
|
|
139
|
+
assert.ok(Number.isFinite(cpuPercentOfOneCore) && cpuPercentOfOneCore >= 0);
|
|
140
|
+
record('有操作后的真实串流至少 10 fps,单帧不超过 300 KiB', { activeFps, maxFrameBytes: Math.max(...primary.frames.map(frame => frame.bytes)), measuredMs, cpuPercentOfOneCore });
|
|
141
|
+
|
|
142
|
+
const { client: secondary } = await connect(input.secondaryBotId, 'control');
|
|
143
|
+
await assert.rejects(acquire(secondary), { status: 409 });
|
|
144
|
+
await waitFor(() => secondary.frames.length > 0, '冲突后的只读旁观');
|
|
145
|
+
await release(primary); await acquire(secondary); await release(secondary);
|
|
146
|
+
record('第二内部 Bot 接管冲突 409,旁观允许,第一个释放后可取得控制权');
|
|
147
|
+
const cycles = [];
|
|
148
|
+
for (let i = 0; i < 10; i++) {
|
|
149
|
+
const began = Date.now(); await acquire(primary); const acquiredMs = Date.now() - began;
|
|
150
|
+
await click(primary);
|
|
151
|
+
const releaseAt = Date.now(); await release(primary); const releasedMs = Date.now() - releaseAt;
|
|
152
|
+
assert.ok(acquiredMs <= 600, `acquire ${acquiredMs}ms > 600ms`); assert.ok(releasedMs <= 300, `release ${releasedMs}ms > 300ms`);
|
|
153
|
+
cycles.push({ acquiredMs, releasedMs });
|
|
154
|
+
}
|
|
155
|
+
record('同一 WS 连续十次接管/释放成功,画面连接保留', { cycles });
|
|
156
|
+
await execFile('import', ['-display', process.env.DISPLAY || ':99', '-window', 'root', join(evidence, 'terminal-input.png')]);
|
|
157
|
+
await reader.close(); await secondary.close();
|
|
158
|
+
console.log(JSON.stringify({ waiting: '真实网关旁观保活', milliseconds: observerMs }));
|
|
159
|
+
const previousFrameCount = primary.frames.length, observerStarted = Date.now(); await sleep(observerMs);
|
|
160
|
+
assert.equal(primary.closed, false); assert.ok(primary.frames.length > previousFrameCount || observerMs === 0);
|
|
161
|
+
const idleFrames = primary.frames.filter(frame => frame.at >= observerStarted + 15000);
|
|
162
|
+
const idleFps = idleFrames.length > 1 ? (idleFrames.length - 1) * 1000 / (idleFrames.at(-1).at - idleFrames[0].at) : null;
|
|
163
|
+
if (observerMs >= 30000) { assert.ok(idleFps !== null && idleFps <= 1, `idle fps ${idleFps} exceeds 1`); }
|
|
164
|
+
record('保持真实网关观察会话期间不休眠', { elapsedMs: observerMs, idleFps });
|
|
165
|
+
const retained = (await guiProcesses()).filter(value => value.binary !== 'ffmpeg');
|
|
166
|
+
await writeFile(join(evidence, 'processes-before.json'), JSON.stringify(retained.map(({ pid, binary }) => ({ pid, binary })), null, 2));
|
|
167
|
+
await primary.close();
|
|
168
|
+
const stopCaptureBegan = Date.now();
|
|
169
|
+
await waitFor(async () => !(await guiProcesses()).some(value => value.binary === 'ffmpeg' && value.argv.includes('x11grab')), '其他观察者仍在,不能冒充无观察者阶段', 30000);
|
|
170
|
+
record('关闭所有验收观察者后采集进程停止', { stoppedWithinMs: Date.now() - stopCaptureBegan });
|
|
171
|
+
console.log(JSON.stringify({ waiting: '无观察者保留窗口', milliseconds: parkMs }));
|
|
172
|
+
const parkedAt = Date.now(); let noObserverSamples = 0;
|
|
173
|
+
while (Date.now() - parkedAt < parkMs) {
|
|
174
|
+
assert.ok(!(await guiProcesses()).some(value => value.binary === 'ffmpeg' && value.argv.includes('x11grab')), '无观察者阶段出现新的采集器,不能继续宣称无人旁观');
|
|
175
|
+
noObserverSamples++; await sleep(Math.min(1000, Math.max(1, parkMs - (Date.now() - parkedAt))));
|
|
176
|
+
}
|
|
177
|
+
reopenedSession = await broker('/broker/desktop/session', { botId: input.botId, taskId: `restore-${randomUUID()}`, mode: 'read' });
|
|
178
|
+
const frame = await broker('/broker/desktop/frame-binary', { sessionId: reopenedSession.sessionId }); assert.ok(frame.byteLength > 0 && frame.byteLength <= 307200);
|
|
179
|
+
const after = await guiProcesses();
|
|
180
|
+
for (const process of retained) assert.ok(after.some(value => value.pid === process.pid), `原窗口进程被重建: ${process.binary} ${process.pid}`);
|
|
181
|
+
assert.equal(after.filter(value => value.binary === 'Xvfb').length, 1);
|
|
182
|
+
await writeFile(join(evidence, 'processes-after.json'), JSON.stringify(after.map(({ pid, binary }) => ({ pid, binary })), null, 2));
|
|
183
|
+
await execFile('import', ['-display', process.env.DISPLAY || ':99', '-window', 'root', join(evidence, 'restored-desktop.png')]);
|
|
184
|
+
record('无观察者等待后恢复仍为原 X11/WM/应用进程', { elapsedMs: Date.now() - parkedAt, noObserverSamples, preservedPids: retained.map(value => value.pid) });
|
|
185
|
+
report.passed = true;
|
|
186
|
+
} catch (error) { report.passed = false; report.error = safeError(error); console.error(JSON.stringify(report.error)); process.exitCode = 1; }
|
|
187
|
+
finally {
|
|
188
|
+
report.cleanupErrors = [];
|
|
189
|
+
for (const connection of connections) try { if (!connection.closed && connection.readOnly === false) await release(connection); await connection.close(); } catch (error) { report.cleanupErrors.push(safeError(error)); }
|
|
190
|
+
if (reopenedSession) try { await broker('/broker/desktop/close', { sessionId: reopenedSession.sessionId }); } catch (error) { report.cleanupErrors.push(safeError(error)); }
|
|
191
|
+
if (fixtureTerminal && fixtureTerminal.exitCode === null && fixtureTerminal.signalCode === null) {
|
|
192
|
+
fixtureTerminal.kill('SIGTERM');
|
|
193
|
+
await Promise.race([new Promise(resolve => fixtureTerminal.once('exit', resolve)), sleep(2000)]);
|
|
194
|
+
if (fixtureTerminal.exitCode === null && fixtureTerminal.signalCode === null) {
|
|
195
|
+
fixtureTerminal.kill('SIGKILL');
|
|
196
|
+
await Promise.race([new Promise(resolve => fixtureTerminal.once('exit', resolve)), sleep(1000)]);
|
|
197
|
+
if (fixtureTerminal.exitCode === null && fixtureTerminal.signalCode === null) report.cleanupErrors.push({ code: 'FIXTURE_TERMINAL_REAP_FAILED', message: '验收终端未退出' });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (report.cleanupErrors.length) { report.passed = false; process.exitCode = 1; }
|
|
201
|
+
report.finishedAt = new Date().toISOString();
|
|
202
|
+
await writeFile(join(evidence, 'report.json'), JSON.stringify(report, null, 2));
|
|
203
|
+
console.log(`REPORT_JSON ${JSON.stringify(report)}`);
|
|
204
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
const secrets=[process.env.AGENTKIT_RUNTIME_TOKEN,process.env.AGENTKIT_MODEL_API_KEY].filter(Boolean);
|
|
3
|
+
const print=v=>{let s=JSON.stringify(v);for(const secret of secrets)s=s.split(secret).join('[REDACTED]');console.log(s);};
|
|
4
|
+
const base='http://127.0.0.1:8080';
|
|
5
|
+
const request=async(path,body)=>{const r=await fetch(base+path,{method:body?'POST':'GET',headers:{authorization:`Bearer ${process.env.AGENTKIT_RUNTIME_TOKEN}`,'content-type':'application/json'},...(body?{body:JSON.stringify(body)}:{})});const data=await r.json();if(!r.ok){print({event:'request-failed',status:r.status,data});throw Error(`HTTP ${r.status}`);}return{status:r.status,data};};
|
|
6
|
+
const session=await request('/v1/sessions',{botId:'default',title:'Studio关闭后的云端真实回合验收'});print({event:'session-created',...session});
|
|
7
|
+
const sessionId=session.data.sessionId;
|
|
8
|
+
const receipt=await request(`/v1/sessions/${sessionId}/turns`,{botId:'default',input:'请只回复 PA_CLOUD_20261025_OK,不要调用任何工具。'});print({event:'turn-accepted',...receipt});
|
|
9
|
+
for(let i=0;i<120;i++){
|
|
10
|
+
const {data}=await request(`/v1/sessions/${sessionId}/turns?botId=default`);const turn=data.turns.find(t=>t.turnId===receipt.data.turnId);
|
|
11
|
+
if(turn&&['completed','failed','cancelled'].includes(turn.status)){
|
|
12
|
+
print({event:'turn-finished',turn});const messages=await request(`/v1/sessions/${sessionId}/messages?botId=default`);print({event:'final-messages',...messages});
|
|
13
|
+
assert.equal(turn.status,'completed');assert.ok(messages.data.messages.some(m=>m.role==='assistant'&&m.content.includes('PA_CLOUD_20261025_OK')));process.exit(0);
|
|
14
|
+
}
|
|
15
|
+
if(i%10===0)print({event:'turn-running',elapsedSeconds:i,turnId:receipt.data.turnId,status:turn?.status});
|
|
16
|
+
await new Promise(resolve=>setTimeout(resolve,1000));
|
|
17
|
+
}
|
|
18
|
+
throw Error('cloud turn did not finish in 120 seconds');
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
const base='http://127.0.0.1:8787';
|
|
3
|
+
const identity={userId:process.env.USER_ID,assistantId:process.env.ASSISTANT_ID,computerId:process.env.COMPUTER_ID};
|
|
4
|
+
for(const path of ['/health','/v1/status']){const r=await fetch(base+path);const body=await r.json();console.log(JSON.stringify({test:path,status:r.status,body}));assert.equal(r.status,200);}
|
|
5
|
+
const post=async(path,body)=>{const r=await fetch(base+path,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({...identity,...body})});return{status:r.status,body:await r.json()};};
|
|
6
|
+
const first={botId:'default',taskId:'cloud-acceptance-owner',ttlMs:10000,idempotencyKey:'cloud-acceptance-owner'};
|
|
7
|
+
const second={botId:'researcher',taskId:'cloud-acceptance-observer',ttlMs:10000,idempotencyKey:'cloud-acceptance-observer'};
|
|
8
|
+
const acquired=await post('/broker/lease/acquire',first);console.log(JSON.stringify({test:'first-bot-acquire',status:acquired.status,leasePresent:!!acquired.body.lease?.leaseId}));assert.equal(acquired.status,200);
|
|
9
|
+
try{const conflict=await post('/broker/lease/acquire',second);console.log(JSON.stringify({test:'second-bot-conflict',...conflict}));assert.equal(conflict.status,409);}finally{const released=await post('/broker/lease/release',{...first,leaseId:acquired.body.lease.leaseId});console.log(JSON.stringify({test:'first-bot-release',...released}));assert.equal(released.body.released,true);}
|
|
10
|
+
const next=await post('/broker/lease/acquire',second);console.log(JSON.stringify({test:'second-bot-acquire-after-release',status:next.status,leasePresent:!!next.body.lease?.leaseId}));assert.equal(next.status,200);await post('/broker/lease/release',{...second,leaseId:next.body.lease.leaseId});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** 闭包固定 Bot;任务由云端持久调度器执行,关闭 Studio 不影响触发。 */
|
|
2
|
+
export function registerAutomationTools({ agent, botId, service }) {
|
|
3
|
+
agent.ctx.get('tools').register({
|
|
4
|
+
name: 'assistant_schedule',
|
|
5
|
+
description: 'Create/list/cancel a persistent one-off cloud task. Convert the user time using their timezone; runAt must include a timezone offset. The cloud DSH executes input even after Studio closes. Never create a task without the user asking for it.',
|
|
6
|
+
parameters: { type: 'object', required: ['action'], additionalProperties: false, properties: {
|
|
7
|
+
action: { type: 'string', enum: ['create', 'list', 'cancel'] }, scheduleId: { type: 'string' }, runAt: { type: 'string' }, input: { type: 'string' },
|
|
8
|
+
} },
|
|
9
|
+
output: { schema: { type: 'object', required: ['result'], properties: { result: { type: 'string' } } }, render: (_args, value) => [{ type: 'text', text: value.result }] },
|
|
10
|
+
async execute(args) {
|
|
11
|
+
const current = service();
|
|
12
|
+
if (!current) throw new Error('云端调度服务尚未就绪');
|
|
13
|
+
if (!['create', 'list', 'cancel'].includes(args.action)) throw new TypeError('unsupported schedule action');
|
|
14
|
+
if (args.action === 'create' && (typeof args.runAt !== 'string' || !/(?:Z|[+-]\d\d:\d\d)$/u.test(args.runAt))) throw new TypeError('runAt must contain a timezone');
|
|
15
|
+
return { result: JSON.stringify(await current({ ...args, botId, runAt: args.action === 'create' ? Date.parse(args.runAt) : undefined })) };
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const object = properties => ({ type: 'object', properties, required: Object.keys(properties), additionalProperties: false });
|
|
2
|
+
const string = { type: 'string' };
|
|
3
|
+
const row = object({ key: string, content: string });
|
|
4
|
+
const output = schema => ({ schema, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }] });
|
|
5
|
+
|
|
6
|
+
/** 闭包固定当前 Bot,模型参数中不接收 botId/namespace/文件路径,不能扩大数据权限。 */
|
|
7
|
+
export function registerBotPrivateTools({ agent, read, write }) {
|
|
8
|
+
const tools = agent?.ctx?.get?.('tools');
|
|
9
|
+
if (typeof tools?.register !== 'function') throw Object.assign(new Error('Scoped Bot memory tools are unavailable'), { code: 'PERSONAL_ASSISTANT_TOOL_SCOPE_UNAVAILABLE' });
|
|
10
|
+
tools.register({ name: 'bot_memory_search', description: '查询当前逻辑 Bot 的私有记忆。不能读取其他 Bot、文件或凭据。',
|
|
11
|
+
parameters: object({ query: string }), output: output(object({ memories: { type: 'array', items: row } })),
|
|
12
|
+
async execute({ query }) { const needle = String(query).toLowerCase(); return { memories: (await read()).filter(value => `${value.key}\n${value.content}`.toLowerCase().includes(needle)) }; } });
|
|
13
|
+
if (!write) return;
|
|
14
|
+
const save = async ({ key, content }) => { const result = await write({ key, content }); return { key: result.key, content: result.content }; };
|
|
15
|
+
tools.register({ name: 'bot_memory_save', description: '保存当前逻辑 Bot 的私有记忆。不能指定其他 Bot 或文件路径。',
|
|
16
|
+
parameters: row, output: output(row), execute: save });
|
|
17
|
+
tools.register({ name: 'bot_plan_update', description: '保存当前 Bot 的任务计划;步骤仅作为资料,不会执行命令或授予权限。',
|
|
18
|
+
parameters: object({ steps: { type: 'array', items: object({ text: string, status: { type: 'string', enum: ['pending', 'running', 'completed'] } }) } }), output: output(row),
|
|
19
|
+
async execute({ steps }) {
|
|
20
|
+
if (!Array.isArray(steps) || steps.length > 50 || steps.some(step => typeof step.text !== 'string' || !step.text.trim() || step.text.length > 512 || !['pending', 'running', 'completed'].includes(step.status))) throw Object.assign(new Error('Bot plan is invalid'), { code: 'BOT_PLAN_INVALID' });
|
|
21
|
+
return save({ key: 'plan.current', content: JSON.stringify(steps) });
|
|
22
|
+
} });
|
|
23
|
+
}
|