vcorp-chrome 1.8.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -1
- package/docs//345/215/217/350/256/256.md +53 -0
- package/docs//345/257/271/346/257/224.md +55 -53
- package/docs//350/203/275/345/212/233/345/214/205/344/270/216eda-/344/272/244/346/216/245.md +288 -0
- package/extension/background.js +20 -2
- package/extension/cloud-policy.js +14 -0
- package/extension/cloud.js +229 -6
- package/extension/desktop-policy.js +400 -0
- package/extension/desktop-render.js +81 -0
- package/extension/device.js +144 -0
- package/extension/manifest.json +4 -1
- package/extension/panel.html +10 -0
- package/extension/panel.js +476 -427
- package/native-host/host.js +122 -0
- package/package.json +6 -2
- package/src/bridge.js +31 -2
- package/src/cli.js +35 -1
- package/src/lib/desktop.js +171 -0
- package/src/lib/native-install.js +139 -0
- package/src/lib/paths.js +20 -0
- package/src/lib/rpc.js +7 -2
- package/src/mcp-server.js +135 -10
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// 原生消息宿主 ai.vcorp.desktop —— 云端通道够到桌面的唯一一段。
|
|
2
|
+
//
|
|
3
|
+
// Chrome 每次 sendNativeMessage 起一个本进程:stdin 进一条消息(4 字节小端长度 + JSON),
|
|
4
|
+
// stdout 回一条,Chrome 随即关管道。本进程只做两件事:
|
|
5
|
+
// hello:报出这台电脑的桌面设备身份(和本机 MCP 用同一个 machineId,所以两条通道里是同一台设备)
|
|
6
|
+
// run:把扩展审过的参数再审一遍,交给 agent-desktop,回它的信封
|
|
7
|
+
// 扩展那一侧已经过了 desktop-policy.js;这里再审是因为扩展本身也可能出错,
|
|
8
|
+
// 而真实输入(--headed)、截图、剪贴板读取这些不该因为一处疏漏就对云端打开。
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { runAgentDesktop, findBinary } from '../src/lib/desktop.js';
|
|
12
|
+
import { machineId, desktopDeviceId } from '../src/lib/paths.js';
|
|
13
|
+
import {
|
|
14
|
+
DESKTOP_CMDS, REF_RE, filterWindows, checkObserved, recordOf, isBrowserMainTitle,
|
|
15
|
+
} from '../extension/desktop-policy.js';
|
|
16
|
+
import { VERSION } from '../src/lib/version.js';
|
|
17
|
+
|
|
18
|
+
// Chrome 收宿主回包的上限是 1MB,留出 JSON 外壳的余量
|
|
19
|
+
const REPLY_MAX = 1000 * 1000;
|
|
20
|
+
const IN_MAX = 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
const OPTS = new Set([
|
|
23
|
+
'--app', '--window-id', '--timeout-ms', '--role', '--name', '--text', '--nth', '--limit', '--property',
|
|
24
|
+
'--timeout', '--window', '--direction', '--amount', '--session', '--root', '--expected-app',
|
|
25
|
+
]);
|
|
26
|
+
const FLAGS = new Set(['-i', '--compact', '--skeleton']);
|
|
27
|
+
const COMBO_RE = /^[a-z0-9][a-z0-9+_-]{0,39}$/i;
|
|
28
|
+
const SESSION_RE = /^--session=cloud-[\w-]{1,64}$/;
|
|
29
|
+
|
|
30
|
+
function denied(message) {
|
|
31
|
+
return { ok: false, error: { code: 'DESKTOP_DENIED', message, disposition: { delivery: 'not_delivered', retry: 'safe' } } };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 返回 null 表示合规,否则是拒绝原因
|
|
35
|
+
export function recheck(argv) {
|
|
36
|
+
if (!Array.isArray(argv) || !argv.length || argv.length > 40) return 'argv 不合法';
|
|
37
|
+
if (!argv.every((a) => typeof a === 'string' && a.length <= 20000 && !a.includes('\0'))) return 'argv 不合法';
|
|
38
|
+
const spec = DESKTOP_CMDS[argv[0]];
|
|
39
|
+
if (!spec || !spec.cloud) return `云端不能用 ${argv[0]}`;
|
|
40
|
+
const cut = argv.indexOf('--');
|
|
41
|
+
const head = cut < 0 ? argv.slice(1) : argv.slice(1, cut);
|
|
42
|
+
let session = false;
|
|
43
|
+
for (const a of head) {
|
|
44
|
+
if (FLAGS.has(a) || REF_RE.test(a) || /^\d{1,6}$/.test(a)) continue;
|
|
45
|
+
if (argv[0] === 'press' && COMBO_RE.test(a)) continue;
|
|
46
|
+
const eq = a.indexOf('=');
|
|
47
|
+
if (a.startsWith('--') && eq > 2 && OPTS.has(a.slice(0, eq))) {
|
|
48
|
+
if (a.startsWith('--session=')) { if (!SESSION_RE.test(a)) return 'session 不合法'; session = true; }
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
return `参数不在白名单里:${a.slice(0, 40)}`;
|
|
52
|
+
}
|
|
53
|
+
if (!session && !['list-apps', 'clipboard-set'].includes(argv[0])) return '缺少任务的 session';
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readMessage() {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
const chunks = [];
|
|
60
|
+
let size = 0;
|
|
61
|
+
process.stdin.on('data', (b) => {
|
|
62
|
+
chunks.push(b);
|
|
63
|
+
size += b.length;
|
|
64
|
+
if (size > IN_MAX + 4) reject(new Error('消息太大'));
|
|
65
|
+
const buf = Buffer.concat(chunks);
|
|
66
|
+
if (buf.length >= 4 && buf.length >= 4 + buf.readUInt32LE(0)) resolve(JSON.parse(buf.subarray(4, 4 + buf.readUInt32LE(0)).toString('utf8')));
|
|
67
|
+
});
|
|
68
|
+
process.stdin.on('end', () => reject(new Error('没有收到消息')));
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function reply(obj) {
|
|
73
|
+
let body = Buffer.from(JSON.stringify(obj), 'utf8');
|
|
74
|
+
if (body.length > REPLY_MAX) {
|
|
75
|
+
body = Buffer.from(JSON.stringify({
|
|
76
|
+
ok: false,
|
|
77
|
+
error: {
|
|
78
|
+
code: 'TOO_LARGE',
|
|
79
|
+
message: `结果有 ${Math.round(body.length / 1024)}KB,超过原生消息 1MB 的上限`,
|
|
80
|
+
suggestion: 'snapshot 加 skeleton:true 先看轮廓,再用 root 往某一块里钻',
|
|
81
|
+
disposition: { delivery: 'unknown', retry: 'unsafe' },
|
|
82
|
+
},
|
|
83
|
+
}), 'utf8');
|
|
84
|
+
}
|
|
85
|
+
const len = Buffer.alloc(4);
|
|
86
|
+
len.writeUInt32LE(body.length, 0);
|
|
87
|
+
return new Promise((resolve) => process.stdout.write(Buffer.concat([len, body]), resolve));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function main() {
|
|
91
|
+
const msg = await readMessage();
|
|
92
|
+
if (msg?.op === 'hello') {
|
|
93
|
+
const machine = machineId();
|
|
94
|
+
const probe = await runAgentDesktop(['list-apps'], { timeoutMs: 15000 });
|
|
95
|
+
return reply({
|
|
96
|
+
ok: true,
|
|
97
|
+
device: { id: desktopDeviceId(machine), kind: 'desktop', machine, caps: { platform: process.platform, version: VERSION } },
|
|
98
|
+
ready: !!probe.ok,
|
|
99
|
+
...(probe.ok ? {} : { error: probe.error }),
|
|
100
|
+
binary: findBinary(),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if (msg?.op === 'run') {
|
|
104
|
+
const why = recheck(msg.argv);
|
|
105
|
+
if (why) return reply(denied(why));
|
|
106
|
+
const timeoutMs = Math.min(Math.max(Number(msg.timeoutMs) || 30000, 1000), 90000);
|
|
107
|
+
const env = await runAgentDesktop(msg.argv, { timeoutMs });
|
|
108
|
+
// 浏览器主窗口的标题和树不出这台电脑:扩展那侧也会滤,这里是第二道
|
|
109
|
+
if (env.ok && msg.argv[0] === 'list-windows') env.data = filterWindows(env.data, 'cloud');
|
|
110
|
+
if (env.ok && msg.argv[0] === 'snapshot') {
|
|
111
|
+
const rec = recordOf(env.data);
|
|
112
|
+
if (isBrowserMainTitle(rec?.window?.title)) return reply(denied('浏览器主窗口不出这台电脑'));
|
|
113
|
+
try { checkObserved(rec); } catch (e) { return reply(denied(e.message)); }
|
|
114
|
+
}
|
|
115
|
+
return reply(env);
|
|
116
|
+
}
|
|
117
|
+
return reply(denied(`未知操作 ${String(msg?.op).slice(0, 20)}`));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
121
|
+
main().then(() => process.exit(0), async (e) => { await reply(denied(`宿主出错:${e.message}`)); process.exit(0); });
|
|
122
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vcorp-chrome",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "让任何 AI agent 操控你自己的 Chrome——带着全部登录态。MCP + Chrome 扩展,Claude Code / Codex / Cursor / Gemini CLI 通用。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"src",
|
|
11
11
|
"extension",
|
|
12
|
+
"native-host",
|
|
12
13
|
"docs",
|
|
13
14
|
"README.md"
|
|
14
15
|
],
|
|
@@ -20,7 +21,7 @@
|
|
|
20
21
|
"bridge": "node src/cli.js bridge --foreground",
|
|
21
22
|
"doctor": "node src/cli.js doctor",
|
|
22
23
|
"build:loads": "node scripts/build-loads.mjs",
|
|
23
|
-
"test": "node --test test/protocol.test.js test/mcp.test.js test/fetch-pages.test.js test/liveness.test.js test/redact.test.js test/audit-redact.test.js test/learnings.test.js test/risk.test.js test/identity.test.js test/host.test.js test/presence.test.js test/act-script.test.js test/multi-instance.test.js test/compat.test.js test/cloud-policy.test.js test/loads.test.js",
|
|
24
|
+
"test": "node --test test/protocol.test.js test/mcp.test.js test/fetch-pages.test.js test/liveness.test.js test/redact.test.js test/audit-redact.test.js test/learnings.test.js test/risk.test.js test/identity.test.js test/host.test.js test/presence.test.js test/act-script.test.js test/multi-instance.test.js test/compat.test.js test/cloud-policy.test.js test/loads.test.js test/desktop-policy.test.js",
|
|
24
25
|
"test:live": "node --test test/scenarios.test.js"
|
|
25
26
|
},
|
|
26
27
|
"dependencies": {
|
|
@@ -45,5 +46,8 @@
|
|
|
45
46
|
"devDependencies": {
|
|
46
47
|
"esbuild": "^0.24.2",
|
|
47
48
|
"jszip": "^3.10.1"
|
|
49
|
+
},
|
|
50
|
+
"optionalDependencies": {
|
|
51
|
+
"agent-desktop": "1.0.2"
|
|
48
52
|
}
|
|
49
53
|
}
|
package/src/bridge.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// 网页的 Origin 是自己的域名,扩展的是 chrome-extension://<id>。只放后者进来。
|
|
9
9
|
import { WebSocketServer } from 'ws';
|
|
10
10
|
import fs from 'node:fs';
|
|
11
|
-
import { DEFAULT_PORT, writeBridgeInfo, newToken, tokenEquals, audit, ensureHome, ALLOWLIST_FILE } from './lib/paths.js';
|
|
11
|
+
import { DEFAULT_PORT, writeBridgeInfo, newToken, tokenEquals, audit, ensureHome, ALLOWLIST_FILE, machineId } from './lib/paths.js';
|
|
12
12
|
import { VERSION } from './lib/version.js';
|
|
13
13
|
// 纯字符串判定、不碰 chrome API,所以桥这边直接复用,不再抄一份
|
|
14
14
|
import { scrubProse } from '../extension/redact.js';
|
|
@@ -112,6 +112,8 @@ export function startBridge({ port = DEFAULT_PORT, token = newToken(), writeInfo
|
|
|
112
112
|
return pool.reduce((a, b) => (a.lastRx >= b.lastRx ? a : b));
|
|
113
113
|
}
|
|
114
114
|
const extLabel = (ws) => `Chrome ${ws.chromeVersion || '?'}${ws.headless ? ' · headless' : ''} · 扩展 ${ws.extVersion}`;
|
|
115
|
+
let machineCache = null;
|
|
116
|
+
const machine = () => (machineCache ??= machineId());
|
|
115
117
|
|
|
116
118
|
const wss = new WebSocketServer({
|
|
117
119
|
host: '127.0.0.1',
|
|
@@ -216,6 +218,8 @@ export function startBridge({ port = DEFAULT_PORT, token = newToken(), writeInfo
|
|
|
216
218
|
// 老扩展不报 instanceId。退回按扩展 id 认——行为和单槽那版一样
|
|
217
219
|
// (同一份代码的两个实例仍会互相替换),但至少不会跟新扩展混着算。
|
|
218
220
|
ws.instanceId = msg.instanceId || `ext:${msg.extId || '?'}`;
|
|
221
|
+
// 设备号由实例号派生:同一个 Chrome profile 重启、重载扩展都不变(见 extension/device.js)
|
|
222
|
+
ws.deviceId = `browser-${String(ws.instanceId).replace(/[^\w.-]/g, '').slice(0, 64) || 'unknown'}`;
|
|
219
223
|
|
|
220
224
|
// 同一个实例又连了一条:那是断线重连或重载扩展,旧的那条已经是死的,
|
|
221
225
|
// 顶掉它。**只顶同实例的**——顶错了就退回单槽,每秒互踢的老毛病就回来了。
|
|
@@ -241,6 +245,10 @@ export function startBridge({ port = DEFAULT_PORT, token = newToken(), writeInfo
|
|
|
241
245
|
+ `命令路由到:${extLabel(primary())}`);
|
|
242
246
|
}
|
|
243
247
|
send(ws, { type: 'welcome', bridge: VERSION, v: PROTOCOL });
|
|
248
|
+
// 连上就给一份名单,哪怕是空的。扩展手里那份可能是上一个桥留下的:桥换过之后
|
|
249
|
+
// 旧连接不会再发 close,不推的话死会话要等下一条命令才被冲掉——面板就一直
|
|
250
|
+
// 显示着已经不在的 agent。
|
|
251
|
+
send(ws, { type: 'event', event: 'sessions', live: liveSessions() });
|
|
244
252
|
broadcast({ type: 'event', event: 'extension_online' });
|
|
245
253
|
flushWaiting();
|
|
246
254
|
return;
|
|
@@ -265,6 +273,8 @@ export function startBridge({ port = DEFAULT_PORT, token = newToken(), writeInfo
|
|
|
265
273
|
const ext = primary();
|
|
266
274
|
send(ws, {
|
|
267
275
|
type: 'welcome', bridge: VERSION, v: PROTOCOL,
|
|
276
|
+
// 设备目录与按 to.device 投递。老桥没有这一项:它会把指名的命令照样投给默认的那个 Chrome
|
|
277
|
+
devices: true,
|
|
268
278
|
extensionOnline: !!ext,
|
|
269
279
|
extensionVersion: ext?.extVersion,
|
|
270
280
|
// 老 MCP 只认 versionMismatch:只在真需要人处理时置位,补丁号差异不再惊动它
|
|
@@ -286,6 +296,25 @@ export function startBridge({ port = DEFAULT_PORT, token = newToken(), writeInfo
|
|
|
286
296
|
// agent → extension
|
|
287
297
|
if (msg.type === 'cmd') {
|
|
288
298
|
if (!agents.has(ws)) return;
|
|
299
|
+
// 本机设备目录:桥是唯一知道「有几个 Chrome 连着」的一方,这条由它自己答
|
|
300
|
+
if (msg.cmd === 'devices') {
|
|
301
|
+
const main = primary();
|
|
302
|
+
return send(ws, { type: 'res', id: msg.id, ok: true, data: { devices: liveExtensions().map((e) => ({
|
|
303
|
+
id: e.deviceId, kind: 'browser', name: extLabel(e), machine: machine(), online: true,
|
|
304
|
+
caps: { platform: 'chrome', version: e.extVersion, cmds: [] }, primary: e === main,
|
|
305
|
+
})) } });
|
|
306
|
+
}
|
|
307
|
+
// 指名道姓的命令只投给那一个实例。它不在就直说,不改投别的 Chrome——
|
|
308
|
+
// agent 指定设备正是因为它在意落在哪一台上
|
|
309
|
+
const want = msg.to?.device;
|
|
310
|
+
if (want) {
|
|
311
|
+
const hit = liveExtensions().find((e) => e.deviceId === want);
|
|
312
|
+
if (!hit) {
|
|
313
|
+
return send(ws, { type: 'res', id: msg.id, ok: false, error: { code: 'DEVICE_OFFLINE',
|
|
314
|
+
message: `浏览器设备 ${String(want).slice(0, 80)} 没有连着。用 devices 看看现在有哪些。` } });
|
|
315
|
+
}
|
|
316
|
+
return dispatch(ws, msg, hit);
|
|
317
|
+
}
|
|
289
318
|
const target = primary();
|
|
290
319
|
if (!target) return enqueue(ws, msg);
|
|
291
320
|
return dispatch(ws, msg, target);
|
|
@@ -349,7 +378,7 @@ export function startBridge({ port = DEFAULT_PORT, token = newToken(), writeInfo
|
|
|
349
378
|
// 只有 281 个不同的 id,最多的一个出现了 1088 次。
|
|
350
379
|
// 路由用的一直是 connId:id,审计却记裸 id——「出事能查」这条承诺
|
|
351
380
|
// 在数据结构层面就不成立。
|
|
352
|
-
audit({ ev: 'cmd', id: key, cmd: msg.cmd, client: ws.client, sid: ws.sid, params: redact(msg.params) });
|
|
381
|
+
audit({ ev: 'cmd', id: key, cmd: msg.cmd, client: ws.client, sid: ws.sid, device: target.deviceId, params: redact(msg.params) });
|
|
353
382
|
// sid 盖章:扩展据此维护每个会话自己的受控 tab 槽(多 agent 并发隔离)。
|
|
354
383
|
// live 是此刻还连着的会话,扩展拿它判断某个标签页「还有没有主」。
|
|
355
384
|
// client / label 是给人看的:页面上的控制标记要写出「Codex CLI」而不是一串 sid。
|
package/src/cli.js
CHANGED
|
@@ -102,6 +102,28 @@ switch (cmd) {
|
|
|
102
102
|
await doctor();
|
|
103
103
|
break;
|
|
104
104
|
|
|
105
|
+
// 云端任务够到桌面要经原生消息宿主;本机 MCP 的 desktop 工具不需要这一步
|
|
106
|
+
case 'desktop': {
|
|
107
|
+
const nat = await import('./lib/native-install.js');
|
|
108
|
+
const sub = process.argv[3];
|
|
109
|
+
if (sub === 'install') {
|
|
110
|
+
const r = nat.install();
|
|
111
|
+
console.log(`已登记原生消息宿主 ${nat.HOST_NAME}(只允许扩展 ${r.id} 调用)`);
|
|
112
|
+
for (const p of r.placed) console.log(` ✅ ${p}`);
|
|
113
|
+
if (!r.placed.length) console.log(' ⚠️ 没找到已安装的 Chrome / Edge / Chromium 配置目录');
|
|
114
|
+
console.log('\n回到扩展侧栏点「启用桌面能力」。');
|
|
115
|
+
for (const l of (await nat.status()).lines) console.log(` ${l}`);
|
|
116
|
+
} else if (sub === 'uninstall') {
|
|
117
|
+
const r = nat.uninstall();
|
|
118
|
+
console.log(r.removed.length ? `已移除:\n ${r.removed.join('\n ')}` : '本来就没有登记。');
|
|
119
|
+
} else if (sub === 'status') {
|
|
120
|
+
for (const l of (await nat.status()).lines) console.log(` ${l}`);
|
|
121
|
+
} else {
|
|
122
|
+
console.log('用法:vcorp-chrome desktop install | uninstall | status');
|
|
123
|
+
}
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
|
|
105
127
|
case 'extension':
|
|
106
128
|
printExtension();
|
|
107
129
|
// --reveal:在访达 / 资源管理器里选中扩展文件夹,用户拖进 chrome://extensions 就装好了。
|
|
@@ -133,6 +155,7 @@ switch (cmd) {
|
|
|
133
155
|
vcorp-chrome audit --stats [--days 7] 真实 agent 的用法统计:回合数、哪类调用最多、在哪儿浪费
|
|
134
156
|
vcorp-chrome bridge --foreground 前台跑桥(调试用)
|
|
135
157
|
vcorp-chrome install --dry-run 只看会改哪些配置,不写
|
|
158
|
+
vcorp-chrome desktop install|uninstall|status 让云端任务也能操作桌面应用(登记原生消息宿主)
|
|
136
159
|
|
|
137
160
|
配置目录 ${HOME}`);
|
|
138
161
|
}
|
|
@@ -204,7 +227,6 @@ async function doctor() {
|
|
|
204
227
|
fs.existsSync(mf)
|
|
205
228
|
? ok(`${path.join(ROOT, 'extension')}${alive ? '' : ''}(这只说明文件在;Chrome 是否从这里加载了,看上面「扩展在线」那行)`)
|
|
206
229
|
: bad('扩展目录缺失', '重装 vcorp-chrome');
|
|
207
|
-
|
|
208
230
|
// bridge.log 里最近一次断连:用户体感的「插件消失」到底是什么时候、断了多久
|
|
209
231
|
try {
|
|
210
232
|
const lines = fs.readFileSync(LOG_FILE, 'utf8').trim().split('\n').slice(-400);
|
|
@@ -216,6 +238,18 @@ async function doctor() {
|
|
|
216
238
|
if (lastDown) console.log(` · 最近一次断开 ${lastDown}${lastUp ? `,最近一次连上 ${lastUp}` : ',之后没再连上'}(bridge.log,只有时分秒)`);
|
|
217
239
|
} catch { /* 没日志就没日志 */ }
|
|
218
240
|
|
|
241
|
+
console.log('\n桌面');
|
|
242
|
+
{
|
|
243
|
+
const { findBinary, runAgentDesktop } = await import('./lib/desktop.js');
|
|
244
|
+
const bin = findBinary();
|
|
245
|
+
const t0 = Date.now();
|
|
246
|
+
const env = await runAgentDesktop(['list-apps'], { bin, timeoutMs: 15000 });
|
|
247
|
+
if (env.ok) ok(`agent-desktop 可用 · ${(env.data?.apps || []).length} 个应用 · ${Date.now() - t0}ms(${bin})`);
|
|
248
|
+
else if (env.error?.code === 'PERM_DENIED') bad(`agent-desktop 没有辅助功能权限:${env.error.message}`, env.error.suggestion);
|
|
249
|
+
else bad(`desktop 工具用不了:[${env.error?.code}] ${env.error?.message}`,
|
|
250
|
+
'在 vcorp-chrome 的安装目录里补装 agent-desktop(npm i agent-desktop@1.0.2),或设 AGENT_DESKTOP_BIN 指到它的可执行文件');
|
|
251
|
+
}
|
|
252
|
+
|
|
219
253
|
// 经验库里 agent 记下的「vcorp-chrome 的 X 不生效」——产品 bug 住在经验库里,
|
|
220
254
|
// 没有这一栏就永远回不到开发者手上(screenshot 的 savePath 就是这么躺了一周的)
|
|
221
255
|
try {
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// 本机桌面执行器 —— 把 desktop 工具的一次调用变成一次 agent-desktop 子进程调用。
|
|
2
|
+
//
|
|
3
|
+
// agent-desktop 是无状态 CLI:每条命令一个进程、输出一个 JSON 信封(见它的 docs/json-output.md)。
|
|
4
|
+
// 这一层做四件事:找到二进制;每条命令先过 extension/desktop-policy.js;
|
|
5
|
+
// 记住每份快照属于哪个应用、哪个窗口(之后的 ref 命令对着它判);把信封渲染成给模型看的短文本。
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import {
|
|
12
|
+
checkDesktop, checkObserved, recordOf, filterWindows, checkSteps, snapshotIdOf,
|
|
13
|
+
} from '../../extension/desktop-policy.js';
|
|
14
|
+
import { renderEnvelope } from '../../extension/desktop-render.js';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
|
|
18
|
+
// npm 包 agent-desktop 自带各平台的二进制(bin/agent-desktop-<平台>-<架构>[.exe])。
|
|
19
|
+
// 直接调平台二进制,不经它的 JS 启动器:少起一个 node 进程,每条命令省一两百毫秒。
|
|
20
|
+
export function findBinary() {
|
|
21
|
+
if (process.env.AGENT_DESKTOP_BIN) return process.env.AGENT_DESKTOP_BIN;
|
|
22
|
+
const name = `agent-desktop-${process.platform === 'win32' ? 'win32' : process.platform}-${process.arch}${process.platform === 'win32' ? '.exe' : ''}`;
|
|
23
|
+
try {
|
|
24
|
+
const dir = path.dirname(require.resolve('agent-desktop/package.json'));
|
|
25
|
+
const p = path.join(dir, 'bin', name);
|
|
26
|
+
if (fs.existsSync(p)) return p;
|
|
27
|
+
} catch { /* 没装这个依赖,退到 PATH */ }
|
|
28
|
+
return process.platform === 'win32' ? 'agent-desktop.exe' : 'agent-desktop';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const OUT_MAX = 8 * 1024 * 1024;
|
|
32
|
+
|
|
33
|
+
// 跑一次,返回信封。不经 shell:参数数组原样交给进程,没有拼接、没有转义问题。
|
|
34
|
+
export function runAgentDesktop(argv, { bin = findBinary(), timeoutMs = 30000 } = {}) {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
let out = '';
|
|
37
|
+
let errText = '';
|
|
38
|
+
let done = false;
|
|
39
|
+
const finish = (env) => { if (!done) { done = true; clearTimeout(timer); resolve(env); } };
|
|
40
|
+
let child;
|
|
41
|
+
try {
|
|
42
|
+
child = spawn(bin, argv, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
43
|
+
} catch (e) {
|
|
44
|
+
finish(fail('DESKTOP_UNAVAILABLE', `起不来 agent-desktop(${bin}):${e.message}`));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const timer = setTimeout(() => {
|
|
48
|
+
child.kill();
|
|
49
|
+
finish(fail('TIMEOUT', `agent-desktop ${Math.round(timeoutMs / 1000)}s 没有回音,结果不明。先 snapshot 看现状,别直接重做。`, 'unknown'));
|
|
50
|
+
}, timeoutMs);
|
|
51
|
+
child.stdout.on('data', (b) => { if (out.length < OUT_MAX) out += b; });
|
|
52
|
+
child.stderr.on('data', (b) => { if (errText.length < 4000) errText += b; });
|
|
53
|
+
child.on('error', (e) => finish(fail('DESKTOP_UNAVAILABLE',
|
|
54
|
+
e.code === 'ENOENT' ? '这台电脑上没有 agent-desktop。运行 npx vcorp-chrome doctor 看怎么装。' : e.message)));
|
|
55
|
+
child.on('close', () => {
|
|
56
|
+
const line = out.trim().split('\n').filter(Boolean).at(-1) || '';
|
|
57
|
+
try { finish(JSON.parse(line)); } catch {
|
|
58
|
+
finish(fail('INTERNAL', `agent-desktop 的输出不是 JSON:${(line || errText).slice(0, 300)}`, 'unknown'));
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function fail(code, message, delivery = 'not_delivered') {
|
|
65
|
+
return { ok: false, error: { code, message, disposition: { delivery, retry: delivery === 'not_delivered' ? 'safe' : 'unknown' } } };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------- 会话 ----------
|
|
69
|
+
|
|
70
|
+
const SNAPS_MAX = 64;
|
|
71
|
+
|
|
72
|
+
// 一个 vcorp 会话(一个 MCP server 进程)一个实例。快照户口只活在内存里:
|
|
73
|
+
// 进程没了户口也没了,那时 ref 命令会被要求重新 snapshot——宁可多拍一次,也不认来路不明的 ref。
|
|
74
|
+
export class DesktopSession {
|
|
75
|
+
constructor({ session, channel = 'local', bin = findBinary(), run = runAgentDesktop } = {}) {
|
|
76
|
+
this.session = String(session || 'vcorp').replace(/[^\w-]/g, '-').slice(0, 64);
|
|
77
|
+
this.channel = channel;
|
|
78
|
+
this.bin = bin;
|
|
79
|
+
this.runner = run;
|
|
80
|
+
this.snaps = new Map();
|
|
81
|
+
this.windows = new Map();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
remember(rec) {
|
|
85
|
+
this.snaps.delete(rec.id);
|
|
86
|
+
this.snaps.set(rec.id, rec);
|
|
87
|
+
while (this.snaps.size > SNAPS_MAX) this.snaps.delete(this.snaps.keys().next().value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async refreshWindows() {
|
|
91
|
+
const env = await this.runner(['list-windows'], { bin: this.bin, timeoutMs: 15000 });
|
|
92
|
+
if (env?.ok && Array.isArray(env.data)) {
|
|
93
|
+
for (const w of env.data) this.windows.set(w.id, { app: w.app_name, title: w.title || '' });
|
|
94
|
+
}
|
|
95
|
+
return env;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
ctx() {
|
|
99
|
+
return {
|
|
100
|
+
channel: this.channel,
|
|
101
|
+
session: this.session,
|
|
102
|
+
lookup: (id) => this.snaps.get(id) || null,
|
|
103
|
+
windows: (id) => this.windows.get(id) || null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 一条命令。返回 { text, image?, env, sensitive }。策略不过直接抛(code 在 e.code 上)。
|
|
108
|
+
async run(params, { timeoutMs = 30000, allowSensitive = true } = {}) {
|
|
109
|
+
if (params?.steps !== undefined) return this.steps(params.steps, { timeoutMs });
|
|
110
|
+
if (params?.window && !this.windows.has(String(params.window))) await this.refreshWindows();
|
|
111
|
+
const chk = checkDesktop(params, this.ctx());
|
|
112
|
+
if (chk.sensitive === 'pay') {
|
|
113
|
+
throw Object.assign(new Error(`「${chk.target?.name}」像是要花钱的按钮。桌面能力不代做付款,请用户亲手点。`), { code: 'PAY_REFUSED' });
|
|
114
|
+
}
|
|
115
|
+
if (chk.sensitive && !allowSensitive) {
|
|
116
|
+
throw Object.assign(new Error(`「${chk.target?.name}」是发送/删除一类的动作,批处理不代做。单独调用一次 desktop ${chk.cmd}。`), { code: 'NEEDS_SINGLE_CALL' });
|
|
117
|
+
}
|
|
118
|
+
let shot = null;
|
|
119
|
+
if (chk.cmd === 'screenshot') {
|
|
120
|
+
shot = path.join(os.tmpdir(), `vcorp-desktop-${process.pid}-${Date.now()}.png`);
|
|
121
|
+
chk.argv.push(shot);
|
|
122
|
+
}
|
|
123
|
+
const env = await this.runner(chk.argv, { bin: this.bin, timeoutMs });
|
|
124
|
+
|
|
125
|
+
if (env?.ok && (chk.cmd === 'snapshot' || chk.cmd === 'find')) {
|
|
126
|
+
const rec = recordOf(env.data);
|
|
127
|
+
if (rec && !rec.app) rec.app = chk.app;
|
|
128
|
+
if (rec && !rec.window && env.data.window) rec.window = env.data.window;
|
|
129
|
+
if (chk.cmd === 'snapshot') checkObserved(rec);
|
|
130
|
+
if (rec) {
|
|
131
|
+
const prev = chk.cmd === 'find' ? this.snaps.get(rec.id) : null;
|
|
132
|
+
this.remember(prev ? { ...prev, refs: { ...prev.refs, ...rec.refs } } : rec);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (env?.ok && chk.cmd === 'list-windows') {
|
|
136
|
+
for (const w of env.data || []) this.windows.set(w.id, { app: w.app_name, title: w.title || '' });
|
|
137
|
+
env.data = filterWindows(env.data, this.channel);
|
|
138
|
+
}
|
|
139
|
+
let image = null;
|
|
140
|
+
if (shot) {
|
|
141
|
+
try { image = fs.readFileSync(shot).toString('base64'); } catch { /* 截图失败时信封里有原因 */ }
|
|
142
|
+
fs.rmSync(shot, { force: true });
|
|
143
|
+
}
|
|
144
|
+
return { text: renderEnvelope(chk.cmd, env, { target: chk.target }), image, env, cmd: chk.cmd, app: chk.app, sensitive: chk.sensitive };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 一串步骤:每步过同一套检查,任何一步失败或碰到敏感动作就停,讲清做到哪、为什么停
|
|
148
|
+
async steps(list, { timeoutMs = 30000 } = {}) {
|
|
149
|
+
checkSteps(list);
|
|
150
|
+
const out = [];
|
|
151
|
+
for (let i = 0; i < list.length; i++) {
|
|
152
|
+
const st = list[i];
|
|
153
|
+
let r;
|
|
154
|
+
try {
|
|
155
|
+
r = await this.run(st, { timeoutMs, allowSensitive: false });
|
|
156
|
+
} catch (e) {
|
|
157
|
+
out.push(`⏸ 第 ${i + 1}/${list.length} 步 ${st.cmd}:${e.message}`);
|
|
158
|
+
out.push(`剩下 ${list.length - i - 1} 步没做。`);
|
|
159
|
+
return { text: out.join('\n'), stopped: true };
|
|
160
|
+
}
|
|
161
|
+
out.push(`— ${i + 1}/${list.length} ${st.cmd}\n${r.text}`);
|
|
162
|
+
if (!r.env?.ok) {
|
|
163
|
+
out.push(`⏸ 停在第 ${i + 1} 步,剩下 ${list.length - i - 1} 步没做。`);
|
|
164
|
+
return { text: out.join('\n'), stopped: true };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { text: out.join('\n\n') };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export { snapshotIdOf };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// 原生消息宿主的安装 / 卸载 / 体检(npx vcorp-chrome desktop install|uninstall|status)。
|
|
2
|
+
//
|
|
3
|
+
// Chrome 找宿主靠一份清单:Windows 在注册表 HKCU 下登记清单路径,macOS / Linux 把清单放进
|
|
4
|
+
// 浏览器约定的目录。清单的 path 指向一个启动脚本,脚本用装它时的那个 node 跑 native-host/host.js。
|
|
5
|
+
// allowed_origins 只写本扩展:扩展 id 由 manifest.json 里的 key 算出来,不手抄。
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { HOME, ensureHome } from './paths.js';
|
|
13
|
+
|
|
14
|
+
export const HOST_NAME = 'ai.vcorp.desktop';
|
|
15
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
16
|
+
const HOST_JS = path.join(ROOT, 'native-host', 'host.js');
|
|
17
|
+
const DIR = path.join(HOME, 'desktop-host');
|
|
18
|
+
const MANIFEST = path.join(DIR, `${HOST_NAME}.json`);
|
|
19
|
+
|
|
20
|
+
// Chrome 的扩展 id:公钥 DER 的 sha256 前 16 字节,每个十六进制位 0-f 映射成 a-p
|
|
21
|
+
export function extensionId(manifestKey) {
|
|
22
|
+
const hex = crypto.createHash('sha256').update(Buffer.from(manifestKey, 'base64')).digest('hex').slice(0, 32);
|
|
23
|
+
return [...hex].map((c) => String.fromCharCode(97 + parseInt(c, 16))).join('');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ourExtensionId() {
|
|
27
|
+
const mf = JSON.parse(fs.readFileSync(path.join(ROOT, 'extension', 'manifest.json'), 'utf8'));
|
|
28
|
+
return extensionId(mf.key);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const REG_KEYS = [
|
|
32
|
+
`HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${HOST_NAME}`,
|
|
33
|
+
`HKCU\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\${HOST_NAME}`,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
function manifestDirs() {
|
|
37
|
+
const h = os.homedir();
|
|
38
|
+
if (process.platform === 'darwin') {
|
|
39
|
+
const base = path.join(h, 'Library', 'Application Support');
|
|
40
|
+
return ['Google/Chrome', 'Chromium', 'Microsoft Edge', 'BraveSoftware/Brave-Browser'].map((b) => path.join(base, b, 'NativeMessagingHosts'));
|
|
41
|
+
}
|
|
42
|
+
return ['google-chrome', 'chromium', 'microsoft-edge', 'BraveSoftware/Brave-Browser'].map((b) => path.join(h, '.config', b, 'NativeMessagingHosts'));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function install() {
|
|
46
|
+
ensureHome();
|
|
47
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
48
|
+
const win = process.platform === 'win32';
|
|
49
|
+
const launcher = path.join(DIR, win ? 'vcorp-desktop-host.cmd' : 'vcorp-desktop-host');
|
|
50
|
+
fs.writeFileSync(launcher, win
|
|
51
|
+
? `@echo off\r\n"${process.execPath}" "${HOST_JS}" %*\r\n`
|
|
52
|
+
: `#!/bin/sh\nexec "${process.execPath}" "${HOST_JS}" "$@"\n`);
|
|
53
|
+
if (!win) fs.chmodSync(launcher, 0o755);
|
|
54
|
+
const id = ourExtensionId();
|
|
55
|
+
fs.writeFileSync(MANIFEST, JSON.stringify({
|
|
56
|
+
name: HOST_NAME,
|
|
57
|
+
description: 'vcorp-chrome desktop host',
|
|
58
|
+
path: launcher,
|
|
59
|
+
type: 'stdio',
|
|
60
|
+
allowed_origins: [`chrome-extension://${id}/`],
|
|
61
|
+
}, null, 2));
|
|
62
|
+
const placed = [];
|
|
63
|
+
if (win) {
|
|
64
|
+
for (const key of REG_KEYS) {
|
|
65
|
+
execFileSync('reg', ['add', key, '/ve', '/t', 'REG_SZ', '/d', MANIFEST, '/f'], { stdio: 'ignore', windowsHide: true });
|
|
66
|
+
placed.push(key);
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
for (const dir of manifestDirs()) {
|
|
70
|
+
if (!fs.existsSync(path.dirname(dir))) continue; // 没装这个浏览器
|
|
71
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
72
|
+
fs.copyFileSync(MANIFEST, path.join(dir, `${HOST_NAME}.json`));
|
|
73
|
+
placed.push(dir);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { id, launcher, manifest: MANIFEST, placed };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function uninstall() {
|
|
80
|
+
const removed = [];
|
|
81
|
+
if (process.platform === 'win32') {
|
|
82
|
+
for (const key of REG_KEYS) {
|
|
83
|
+
try { execFileSync('reg', ['delete', key, '/f'], { stdio: 'ignore', windowsHide: true }); removed.push(key); } catch { /* 本来就没登记 */ }
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
for (const dir of manifestDirs()) {
|
|
87
|
+
const f = path.join(dir, `${HOST_NAME}.json`);
|
|
88
|
+
if (fs.existsSync(f)) { fs.rmSync(f); removed.push(f); }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
fs.rmSync(DIR, { recursive: true, force: true });
|
|
92
|
+
return { removed };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 按 Chrome 的方式跟宿主说一句话:起启动脚本、写一条带长度前缀的消息、读一条回包
|
|
96
|
+
export function talk(msg, { launcher, timeoutMs = 20000 } = {}) {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const win = process.platform === 'win32';
|
|
99
|
+
const child = win
|
|
100
|
+
? spawn('cmd.exe', ['/d', '/c', launcher, `chrome-extension://${ourExtensionId()}/`], { windowsHide: true })
|
|
101
|
+
: spawn(launcher, [`chrome-extension://${ourExtensionId()}/`]);
|
|
102
|
+
const chunks = [];
|
|
103
|
+
const timer = setTimeout(() => { child.kill(); reject(new Error(`宿主 ${timeoutMs / 1000}s 没有回包`)); }, timeoutMs);
|
|
104
|
+
child.stdout.on('data', (b) => chunks.push(b));
|
|
105
|
+
child.on('error', (e) => { clearTimeout(timer); reject(e); });
|
|
106
|
+
child.on('close', () => {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
const buf = Buffer.concat(chunks);
|
|
109
|
+
if (buf.length < 4) return reject(new Error('宿主没有回包'));
|
|
110
|
+
resolve(JSON.parse(buf.subarray(4, 4 + buf.readUInt32LE(0)).toString('utf8')));
|
|
111
|
+
});
|
|
112
|
+
const body = Buffer.from(JSON.stringify(msg), 'utf8');
|
|
113
|
+
const len = Buffer.alloc(4);
|
|
114
|
+
len.writeUInt32LE(body.length, 0);
|
|
115
|
+
child.stdin.end(Buffer.concat([len, body]));
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function status() {
|
|
120
|
+
const lines = [];
|
|
121
|
+
let launcher = '';
|
|
122
|
+
if (!fs.existsSync(MANIFEST)) return { installed: false, lines: ['没有安装:运行 npx vcorp-chrome desktop install'] };
|
|
123
|
+
const mf = JSON.parse(fs.readFileSync(MANIFEST, 'utf8'));
|
|
124
|
+
launcher = mf.path;
|
|
125
|
+
lines.push(`清单 ${MANIFEST}`);
|
|
126
|
+
if (process.platform === 'win32') {
|
|
127
|
+
for (const key of REG_KEYS) {
|
|
128
|
+
let ok = false;
|
|
129
|
+
try { ok = execFileSync('reg', ['query', key, '/ve'], { encoding: 'utf8', windowsHide: true }).includes(MANIFEST); } catch { /* 没登记 */ }
|
|
130
|
+
lines.push(`${ok ? '✅' : '❌'} ${key}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!fs.existsSync(HOST_JS)) lines.push(`❌ 启动脚本指向的 ${HOST_JS} 不在了(npx 缓存被清过?):重新运行 desktop install`);
|
|
134
|
+
const hello = await talk({ op: 'hello' }, { launcher });
|
|
135
|
+
lines.push(hello.ready
|
|
136
|
+
? `✅ 宿主应答 · 设备 ${hello.device.id} · agent-desktop 可用`
|
|
137
|
+
: `❌ 宿主应答了,但 agent-desktop 用不了:[${hello.error?.code}] ${hello.error?.message}`);
|
|
138
|
+
return { installed: true, ready: !!hello.ready, device: hello.device, lines };
|
|
139
|
+
}
|
package/src/lib/paths.js
CHANGED
|
@@ -11,8 +11,28 @@ export const AUDIT_FILE = path.join(HOME, 'audit.jsonl');
|
|
|
11
11
|
export const ALLOWLIST_FILE = path.join(HOME, 'allowlist.json');
|
|
12
12
|
export const LOG_FILE = path.join(HOME, 'bridge.log');
|
|
13
13
|
|
|
14
|
+
export const MACHINE_FILE = path.join(HOME, 'machine.json');
|
|
15
|
+
|
|
14
16
|
export const DEFAULT_PORT = 8899;
|
|
15
17
|
|
|
18
|
+
// 这台电脑(这个系统用户)的身份。本机 MCP 和原生宿主(native-host/)读同一个文件,
|
|
19
|
+
// 所以同一台电脑上的桌面设备在两条通道里是同一个设备号。第一次用时生成,之后不变。
|
|
20
|
+
export function machineId() {
|
|
21
|
+
try {
|
|
22
|
+
const id = JSON.parse(fs.readFileSync(MACHINE_FILE, 'utf8')).machineId;
|
|
23
|
+
if (/^m_[0-9a-f]{16}$/.test(id)) return id;
|
|
24
|
+
} catch { /* 还没有,下面生成 */ }
|
|
25
|
+
ensureHome();
|
|
26
|
+
const id = `m_${crypto.randomBytes(8).toString('hex')}`;
|
|
27
|
+
const tmp = `${MACHINE_FILE}.${process.pid}.tmp`;
|
|
28
|
+
fs.writeFileSync(tmp, JSON.stringify({ machineId: id, createdAt: new Date().toISOString() }, null, 2), { mode: 0o600 });
|
|
29
|
+
try { fs.linkSync(tmp, MACHINE_FILE); } catch { /* 别的进程抢先写好了,用它的 */ }
|
|
30
|
+
fs.rmSync(tmp, { force: true });
|
|
31
|
+
return JSON.parse(fs.readFileSync(MACHINE_FILE, 'utf8')).machineId;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const desktopDeviceId = (machine) => `desktop-${machine}`;
|
|
35
|
+
|
|
16
36
|
export function ensureHome() {
|
|
17
37
|
fs.mkdirSync(HOME, { recursive: true, mode: 0o700 });
|
|
18
38
|
return HOME;
|