@forsion/tangu-computer-use 0.5.8 → 0.6.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/CHANGELOG.md +23 -0
- package/README.md +8 -0
- package/UPSTREAM.md +2 -1
- package/manifest.json +1 -1
- package/native/macos/activity_recorder.swift +1806 -0
- package/native/macos/activity_recorder_tests.swift +832 -0
- package/native/macos/bridge.swift +45 -2
- package/package.json +5 -3
- package/prebuilt/macos/arm64/bridge +0 -0
- package/prebuilt/macos/arm64/tangu-computer-use.app.json +4 -4
- package/prebuilt/macos/arm64/tangu-computer-use.app.zip +0 -0
- package/prebuilt/macos/x64/bridge +0 -0
- package/prebuilt/macos/x64/tangu-computer-use.app.json +4 -4
- package/prebuilt/macos/x64/tangu-computer-use.app.zip +0 -0
- package/prebuilt/windows/windows-bridge.exe +0 -0
- package/scripts/build-native.mjs +1 -0
- package/scripts/dev-recorder.mjs +106 -0
- package/scripts/probe-recorder/probe.swift +221 -0
- package/scripts/probe-recorder.mjs +47 -0
- package/scripts/recorder-logic.check.mjs +34 -0
- package/scripts/recorder.check.mjs +169 -0
- package/src/vendor/platform/macos/helper.ts +1 -1
- package/tangu-plugins/computer-use/dist/index.js +1 -1
- package/tangu-plugins/computer-use/tangu-plugin.json +1 -1
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 电脑历史 recordSubscribe(协议 13)的接线检查,不需要任何授权:直接起刚编好的 helper 二进制
|
|
3
|
+
// (serve 模式、临时 socket;不走 open,因为这里只查接线,不查 TCC 归属),然后断言:
|
|
4
|
+
// 1. diagnostics 报协议 13、订阅者 0,无痕扫描计数(recorderPrivateScan*)在且为 0;
|
|
5
|
+
// 2. recordSubscribe 的首行回包形状正确 —— ok:true + subscribed,或(dev 构建没授权时)ok:false + accessibility_denied;
|
|
6
|
+
// 3. 这条连接保持打开 1 秒;之后收到的每一行都是 {"ev":{t,kind}};
|
|
7
|
+
// 4. 订阅成功时 diagnostics 报 1 个订阅者,客户端关掉后回到 0(这里只看注册表;观察者 / 全局监听真的拆掉了
|
|
8
|
+
// 由 check:recorder-logic 的 debugState 断言覆盖);
|
|
9
|
+
// 被拒时这条连接仍是普通连接,照常应答 diagnostics;
|
|
10
|
+
// 5. 最后 shutdown(兜底 SIGKILL),删临时目录。
|
|
11
|
+
// 若本机终端恰好有辅助功能授权,订阅会真的开始记录:这里只打印事件的 kind 计数,绝不打印内容。
|
|
12
|
+
// 需要先 `npm run build:native`。
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
16
|
+
import net from 'node:net';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
if (process.platform !== 'darwin') {
|
|
22
|
+
console.log('recorder check skipped (not macOS)');
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
27
|
+
const arch = process.arch === 'x64' ? 'x64' : 'arm64';
|
|
28
|
+
const binary = process.argv[2] ?? path.join(root, 'prebuilt', 'macos', arch, 'bridge');
|
|
29
|
+
if (!existsSync(binary)) {
|
|
30
|
+
console.error(`FAIL missing helper binary ${binary} — run \`npm run build:native\` first`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const temp = mkdtempSync(path.join(os.tmpdir(), 'cu-rec-'));
|
|
35
|
+
const socketPath = path.join(temp, 'bridge.sock');
|
|
36
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
37
|
+
const EVENT_KINDS = new Set(['app', 'window', 'text', 'click', 'key', 'system']);
|
|
38
|
+
|
|
39
|
+
/** 一条连接:按行收 JSON。 */
|
|
40
|
+
function connect() {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const socket = net.createConnection(socketPath);
|
|
43
|
+
const lines = [];
|
|
44
|
+
const waiters = [];
|
|
45
|
+
let buffer = '';
|
|
46
|
+
let closed = false;
|
|
47
|
+
socket.setEncoding('utf8');
|
|
48
|
+
socket.on('data', (chunk) => {
|
|
49
|
+
buffer += chunk;
|
|
50
|
+
let index;
|
|
51
|
+
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
52
|
+
const line = buffer.slice(0, index);
|
|
53
|
+
buffer = buffer.slice(index + 1);
|
|
54
|
+
if (!line) continue;
|
|
55
|
+
lines.push(JSON.parse(line));
|
|
56
|
+
waiters.splice(0).forEach((wake) => wake());
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
socket.on('close', () => { closed = true; waiters.splice(0).forEach((wake) => wake()); });
|
|
60
|
+
socket.once('error', reject);
|
|
61
|
+
socket.once('connect', () => {
|
|
62
|
+
socket.removeListener('error', reject);
|
|
63
|
+
socket.on('error', () => {});
|
|
64
|
+
resolve({
|
|
65
|
+
socket,
|
|
66
|
+
lines,
|
|
67
|
+
get closed() { return closed; },
|
|
68
|
+
send(object) { socket.write(`${JSON.stringify(object)}\n`); },
|
|
69
|
+
async next(timeoutMs = 3000) {
|
|
70
|
+
const deadline = Date.now() + timeoutMs;
|
|
71
|
+
while (lines.length === 0) {
|
|
72
|
+
if (closed) throw new Error('connection closed before a reply');
|
|
73
|
+
if (Date.now() > deadline) throw new Error('timed out waiting for a line');
|
|
74
|
+
await new Promise((wake) => { waiters.push(wake); setTimeout(wake, 100); });
|
|
75
|
+
}
|
|
76
|
+
return lines.shift();
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function request(object) {
|
|
84
|
+
const connection = await connect();
|
|
85
|
+
try {
|
|
86
|
+
connection.send(object);
|
|
87
|
+
return await connection.next();
|
|
88
|
+
} finally {
|
|
89
|
+
connection.socket.destroy();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let child;
|
|
94
|
+
try {
|
|
95
|
+
child = spawn(binary, ['serve', '--socket', socketPath], { stdio: ['ignore', 'ignore', 'inherit'] });
|
|
96
|
+
let diagnostics;
|
|
97
|
+
for (let attempt = 0; attempt < 100 && !diagnostics; attempt++) {
|
|
98
|
+
if (child.exitCode !== null) throw new Error(`helper exited early (code ${child.exitCode})`);
|
|
99
|
+
try { diagnostics = await request({ id: 'diag-0', cmd: 'diagnostics' }); } catch { await pause(100); }
|
|
100
|
+
}
|
|
101
|
+
assert.equal(diagnostics?.ok, true, 'helper must answer diagnostics');
|
|
102
|
+
assert.equal(diagnostics.result.protocolVersion, 13, `protocol must be 13, got ${diagnostics.result.protocolVersion}`);
|
|
103
|
+
assert.equal(diagnostics.result.recorderSubscribers, 0, 'no subscribers at startup');
|
|
104
|
+
console.log('PASS helper answers diagnostics with protocol 13 and no subscribers');
|
|
105
|
+
// 无痕扫描计数(只有计数):真机探针据此判断「扫描不完整时仍按普通窗口记」要不要改。刚起的 helper 全是 0。
|
|
106
|
+
assert.equal(diagnostics.result.recorderPrivateScans, 0, 'recorderPrivateScans is a number, 0 at startup');
|
|
107
|
+
assert.equal(diagnostics.result.recorderPrivateScanIncomplete, 0, 'recorderPrivateScanIncomplete is a number, 0 at startup');
|
|
108
|
+
assert.deepEqual(diagnostics.result.recorderPrivateScanStops, { afterWebArea: 0, depthCap: 0, nodeCap: 0, deadline: 0 }, 'recorderPrivateScanStops lists every stop reason');
|
|
109
|
+
console.log('PASS diagnostics reports private-scan counters');
|
|
110
|
+
|
|
111
|
+
const subscription = await connect();
|
|
112
|
+
subscription.send({
|
|
113
|
+
id: 'rec-1',
|
|
114
|
+
cmd: 'recordSubscribe',
|
|
115
|
+
policy: { excludeBundleIds: ['com.example.never'], titleOnlyBundleIds: ['com.forsion.*'], excludeDomains: ['example.com'], text: true, clicks: true, keys: true },
|
|
116
|
+
});
|
|
117
|
+
const reply = await subscription.next();
|
|
118
|
+
assert.equal(reply.id, 'rec-1', 'reply echoes the request id');
|
|
119
|
+
let subscribed = false;
|
|
120
|
+
if (reply.ok) {
|
|
121
|
+
assert.equal(reply.result?.subscribed, true, 'ok reply says subscribed');
|
|
122
|
+
assert.equal(reply.result?.protocolVersion, 13, 'ok reply carries protocol 13');
|
|
123
|
+
assert.equal(typeof reply.result?.axTrusted, 'boolean', 'ok reply carries axTrusted');
|
|
124
|
+
subscribed = true;
|
|
125
|
+
console.log('PASS recordSubscribe replied ok:true subscribed (this process tree has an Accessibility grant)');
|
|
126
|
+
} else {
|
|
127
|
+
assert.equal(reply.error?.code, 'accessibility_denied', `denied reply must use accessibility_denied, got ${reply.error?.code}`);
|
|
128
|
+
assert.equal(typeof reply.error?.message, 'string', 'denied reply carries a message');
|
|
129
|
+
console.log('PASS recordSubscribe replied ok:false accessibility_denied (expected for an ungranted dev build)');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await pause(1000);
|
|
133
|
+
assert.equal(subscription.closed, false, 'the subscription connection stays open for 1s');
|
|
134
|
+
console.log('PASS connection stays open for 1s after the reply');
|
|
135
|
+
|
|
136
|
+
if (subscribed) {
|
|
137
|
+
const kinds = {};
|
|
138
|
+
for (const line of subscription.lines.splice(0)) {
|
|
139
|
+
assert.equal(typeof line.ev?.t, 'number', 'every streamed line is {"ev":{t,...}}');
|
|
140
|
+
assert.ok(EVENT_KINDS.has(line.ev.kind), `unknown event kind ${line.ev?.kind}`);
|
|
141
|
+
kinds[line.ev.kind] = (kinds[line.ev.kind] ?? 0) + 1;
|
|
142
|
+
}
|
|
143
|
+
console.log(`PASS streamed lines are well-formed events (kinds only: ${JSON.stringify(kinds)})`);
|
|
144
|
+
const during = await request({ id: 'diag-1', cmd: 'diagnostics' });
|
|
145
|
+
assert.equal(during.result.recorderSubscribers, 1, 'one subscriber while the connection is open');
|
|
146
|
+
subscription.socket.destroy();
|
|
147
|
+
let after;
|
|
148
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
149
|
+
after = await request({ id: 'diag-2', cmd: 'diagnostics' });
|
|
150
|
+
if (after.result.recorderSubscribers === 0) break;
|
|
151
|
+
await pause(100);
|
|
152
|
+
}
|
|
153
|
+
assert.equal(after.result.recorderSubscribers, 0, 'closing the client unsubscribes it');
|
|
154
|
+
console.log('PASS closing the client drops the subscriber back to 0');
|
|
155
|
+
} else {
|
|
156
|
+
assert.equal(subscription.lines.length, 0, 'a denied connection streams no events');
|
|
157
|
+
subscription.send({ id: 'diag-3', cmd: 'diagnostics' });
|
|
158
|
+
const same = await subscription.next();
|
|
159
|
+
assert.equal(same.id, 'diag-3', 'a denied connection keeps serving ordinary requests');
|
|
160
|
+
assert.equal(same.result.recorderSubscribers, 0, 'denied subscription registers nothing');
|
|
161
|
+
subscription.socket.destroy();
|
|
162
|
+
console.log('PASS denied connection stays a normal request connection with no subscriber');
|
|
163
|
+
}
|
|
164
|
+
} finally {
|
|
165
|
+
await request({ id: 'bye', cmd: 'shutdown' }).catch(() => {});
|
|
166
|
+
for (let attempt = 0; attempt < 30 && child && child.exitCode === null && child.signalCode === null; attempt++) await pause(100);
|
|
167
|
+
if (child && child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); // 只杀我们自己起的隔离实例,绝不碰用户的常驻 helper
|
|
168
|
+
rmSync(temp, { recursive: true, force: true });
|
|
169
|
+
}
|
|
@@ -10,7 +10,7 @@ import type { PlatformDiagnostics } from "../types.ts";
|
|
|
10
10
|
import { resolveMacosHelperAppPath } from "./helper-path.mjs";
|
|
11
11
|
|
|
12
12
|
const COMMAND_TIMEOUT_MS = 15_000;
|
|
13
|
-
const HELPER_PROTOCOL_VERSION =
|
|
13
|
+
const HELPER_PROTOCOL_VERSION = 13;
|
|
14
14
|
const HELPER_SETUP_TIMEOUT_MS = 60_000;
|
|
15
15
|
|
|
16
16
|
export const HELPER_BUNDLE_ID = "com.forsion.tangu-computer-use";
|
|
@@ -171,7 +171,7 @@ var init_helper = __esm({
|
|
|
171
171
|
init_coerce();
|
|
172
172
|
init_helper_path();
|
|
173
173
|
COMMAND_TIMEOUT_MS2 = 15e3;
|
|
174
|
-
HELPER_PROTOCOL_VERSION =
|
|
174
|
+
HELPER_PROTOCOL_VERSION = 13;
|
|
175
175
|
HELPER_SETUP_TIMEOUT_MS = 6e4;
|
|
176
176
|
HELPER_BUNDLE_ID = "com.forsion.tangu-computer-use";
|
|
177
177
|
HELPER_APP_PATH = resolveMacosHelperAppPath();
|