@forsion/tangu-computer-use 0.5.8

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.
Files changed (120) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/LICENSE +26 -0
  3. package/LICENSE.upstream +21 -0
  4. package/README.md +169 -0
  5. package/UPSTREAM.md +277 -0
  6. package/check.mjs +369 -0
  7. package/icon.png +0 -0
  8. package/install.sh +112 -0
  9. package/main.js +541 -0
  10. package/manifest.json +47 -0
  11. package/native/linux/bridge-rs/Cargo.lock +1204 -0
  12. package/native/linux/bridge-rs/Cargo.toml +18 -0
  13. package/native/linux/bridge-rs/src/atspi.rs +640 -0
  14. package/native/linux/bridge-rs/src/error.rs +65 -0
  15. package/native/linux/bridge-rs/src/lib.rs +11 -0
  16. package/native/linux/bridge-rs/src/main.rs +1074 -0
  17. package/native/linux/bridge-rs/src/protocol.rs +49 -0
  18. package/native/linux/bridge-rs/src/state.rs +293 -0
  19. package/native/linux/bridge-rs/src/wayland.rs +89 -0
  20. package/native/linux/bridge-rs/src/x11.rs +909 -0
  21. package/native/linux/bridge-rs/tests/protocol_tests.rs +73 -0
  22. package/native/macos/agent_cursor.swift +184 -0
  23. package/native/macos/agent_cursor_motion.swift +252 -0
  24. package/native/macos/agent_cursor_tests.swift +128 -0
  25. package/native/macos/agent_highlight.swift +229 -0
  26. package/native/macos/agent_highlight_tests.swift +99 -0
  27. package/native/macos/bridge.swift +3852 -0
  28. package/native/macos/foreground_activity.swift +57 -0
  29. package/native/macos/foreground_activity_tests.swift +31 -0
  30. package/native/macos/live_stream.swift +289 -0
  31. package/native/windows/bridge-rs/Cargo.lock +396 -0
  32. package/native/windows/bridge-rs/Cargo.toml +30 -0
  33. package/native/windows/bridge-rs/src/capture.rs +518 -0
  34. package/native/windows/bridge-rs/src/error.rs +81 -0
  35. package/native/windows/bridge-rs/src/input.rs +501 -0
  36. package/native/windows/bridge-rs/src/lib.rs +16 -0
  37. package/native/windows/bridge-rs/src/main.rs +1683 -0
  38. package/native/windows/bridge-rs/src/protocol.rs +53 -0
  39. package/native/windows/bridge-rs/src/refs.rs +237 -0
  40. package/native/windows/bridge-rs/src/state.rs +55 -0
  41. package/native/windows/bridge-rs/src/uia.rs +1252 -0
  42. package/native/windows/bridge-rs/src/window.rs +718 -0
  43. package/native/windows/bridge-rs/tests/protocol_tests.rs +252 -0
  44. package/native/windows/bridge-rs/tests/refs_tests.rs +86 -0
  45. package/native/windows/bridge-rs/tests/state_tests.rs +33 -0
  46. package/package.json +74 -0
  47. package/prebuilt/linux/arm64/linux-bridge +0 -0
  48. package/prebuilt/linux/x64/linux-bridge +0 -0
  49. package/prebuilt/macos/arm64/bridge +0 -0
  50. package/prebuilt/macos/arm64/tangu-computer-use.app.json +10 -0
  51. package/prebuilt/macos/arm64/tangu-computer-use.app.zip +0 -0
  52. package/prebuilt/macos/x64/bridge +0 -0
  53. package/prebuilt/macos/x64/tangu-computer-use.app.json +10 -0
  54. package/prebuilt/macos/x64/tangu-computer-use.app.zip +0 -0
  55. package/prebuilt/windows/windows-bridge.exe +0 -0
  56. package/scripts/blind-click.check.mjs +128 -0
  57. package/scripts/build-native.mjs +302 -0
  58. package/scripts/build.mjs +84 -0
  59. package/scripts/calc-fixture.mjs +78 -0
  60. package/scripts/cli-exit.check.mjs +49 -0
  61. package/scripts/helper-path.check.mjs +82 -0
  62. package/scripts/helper-refresh.check.mjs +62 -0
  63. package/scripts/helper-signal.check.mjs +59 -0
  64. package/scripts/highlight.check.mjs +32 -0
  65. package/scripts/keychain-free-install.check.mjs +140 -0
  66. package/scripts/live-view.check.mjs +124 -0
  67. package/scripts/macos-bundle.d.mts +1 -0
  68. package/scripts/macos-bundle.mjs +119 -0
  69. package/scripts/make-signing-cert.sh +57 -0
  70. package/scripts/mini-foreground.check.mjs +11 -0
  71. package/scripts/no-foreground.check.mjs +81 -0
  72. package/scripts/overlay-visible.check.mjs +221 -0
  73. package/scripts/package-macos-app.mjs +39 -0
  74. package/scripts/permissions.check.mjs +199 -0
  75. package/scripts/platform-contract.check.mjs +30 -0
  76. package/scripts/setup-helper.mjs +154 -0
  77. package/scripts/tangu-computer-use.entitlements +10 -0
  78. package/scripts/verify-macos-bundles.mjs +25 -0
  79. package/scripts/verify-package.mjs +74 -0
  80. package/skills/computer-use/SKILL.md +110 -0
  81. package/src/foregroundNote.ts +43 -0
  82. package/src/helperState.ts +31 -0
  83. package/src/index.ts +63 -0
  84. package/src/onboarding.ts +198 -0
  85. package/src/pi-compat.ts +49 -0
  86. package/src/settings.ts +11 -0
  87. package/src/setup.ts +97 -0
  88. package/src/tools.ts +289 -0
  89. package/src/vendor/actions.ts +130 -0
  90. package/src/vendor/bridge.ts +2405 -0
  91. package/src/vendor/cdp.ts +658 -0
  92. package/src/vendor/config.ts +113 -0
  93. package/src/vendor/contract.ts +104 -0
  94. package/src/vendor/note.ts +195 -0
  95. package/src/vendor/outline.ts +651 -0
  96. package/src/vendor/output.ts +134 -0
  97. package/src/vendor/permissions.ts +111 -0
  98. package/src/vendor/platform/architecture.ts +23 -0
  99. package/src/vendor/platform/coerce.ts +16 -0
  100. package/src/vendor/platform/index.ts +59 -0
  101. package/src/vendor/platform/linux/backend.ts +186 -0
  102. package/src/vendor/platform/linux/helper.ts +238 -0
  103. package/src/vendor/platform/macos/backend.ts +131 -0
  104. package/src/vendor/platform/macos/browser.ts +110 -0
  105. package/src/vendor/platform/macos/helper-path.d.mts +9 -0
  106. package/src/vendor/platform/macos/helper-path.mjs +34 -0
  107. package/src/vendor/platform/macos/helper.ts +291 -0
  108. package/src/vendor/platform/macos/permissions.ts +146 -0
  109. package/src/vendor/platform/types.ts +222 -0
  110. package/src/vendor/platform/windows/backend.ts +142 -0
  111. package/src/vendor/platform/windows/helper.ts +140 -0
  112. package/src/vendor/root-selection.ts +25 -0
  113. package/src/vendor/runtime.ts +129 -0
  114. package/src/vendor/state.ts +146 -0
  115. package/src/vendor/view.ts +147 -0
  116. package/tangu-plugins/computer-use/dist/foregroundNote.js +19 -0
  117. package/tangu-plugins/computer-use/dist/index.js +5672 -0
  118. package/tangu-plugins/computer-use/tangu-plugin.json +9 -0
  119. package/tsconfig.json +22 -0
  120. package/types/tangu-agent.d.ts +142 -0
@@ -0,0 +1,302 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from "node:child_process";
4
+ import fs from "node:fs/promises";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { packageMacosApp } from "./package-macos-app.mjs";
9
+
10
+ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
11
+ const macosSourcePaths = [
12
+ "foreground_activity.swift",
13
+ "agent_cursor.swift",
14
+ "agent_cursor_motion.swift",
15
+ "agent_highlight.swift",
16
+ "live_stream.swift",
17
+ "bridge.swift",
18
+ ].map((file) => path.join(rootDir, "native", "macos", file));
19
+ const windowsCrateDir = path.join(rootDir, "native", "windows", "bridge-rs");
20
+ const linuxCrateDir = path.join(rootDir, "native", "linux", "bridge-rs");
21
+ const linuxTargets = { x64: "x86_64-unknown-linux-gnu", arm64: "aarch64-unknown-linux-gnu" };
22
+ const archTriples = {
23
+ arm64: "arm64-apple-macosx",
24
+ x64: "x86_64-apple-macosx",
25
+ };
26
+ const deploymentTarget = "14.0";
27
+ const frameworks = ["ApplicationServices", "AppKit", "ScreenCaptureKit", "Foundation", "SwiftUI"];
28
+ const defaultCodeSignIdentifier = "com.forsion.tangu-computer-use";
29
+
30
+ async function exists(filePath) {
31
+ try {
32
+ await fs.access(filePath);
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ function getArg(name) {
40
+ const index = process.argv.indexOf(name);
41
+ if (index >= 0 && index + 1 < process.argv.length) {
42
+ return process.argv[index + 1];
43
+ }
44
+ return undefined;
45
+ }
46
+
47
+ function hasArg(name) {
48
+ return process.argv.includes(name);
49
+ }
50
+
51
+ function normalizeArch(arch) {
52
+ if (arch === "universal" || arch === "all") return arch;
53
+ if (arch === "arm64" || arch === "x64") return arch;
54
+ throw new Error(`Unsupported architecture '${arch}'. Supported: arm64, x64, universal, all.`);
55
+ }
56
+
57
+ async function run(command, args) {
58
+ await new Promise((resolve, reject) => {
59
+ const child = spawn(command, args, { stdio: "inherit" });
60
+ child.on("error", reject);
61
+ child.on("close", (code) => {
62
+ if (code === 0) {
63
+ resolve();
64
+ return;
65
+ }
66
+ reject(new Error(`Command failed (${code}): ${command} ${args.join(" ")}`));
67
+ });
68
+ });
69
+ }
70
+
71
+ function defaultOutputPath(arch) {
72
+ return path.join(rootDir, "prebuilt", "macos", arch, "bridge");
73
+ }
74
+
75
+ function moduleCachePath(arch) {
76
+ return path.join(os.tmpdir(), `tangu-computer-use-swift-module-cache-${arch}`);
77
+ }
78
+
79
+ function swiftArgsForArch(arch, outputPath) {
80
+ const args = [
81
+ "swiftc",
82
+ "-target",
83
+ `${archTriples[arch]}${deploymentTarget}`,
84
+ "-module-cache-path",
85
+ moduleCachePath(arch),
86
+ "-O",
87
+ ];
88
+ for (const framework of frameworks) args.push("-framework", framework);
89
+ args.push(...macosSourcePaths, "-o", outputPath);
90
+ return args;
91
+ }
92
+
93
+ function signingArgs() {
94
+ const identity = getArg("--sign-identity") ?? process.env.PI_COMPUTER_USE_CODESIGN_IDENTITY ?? "-";
95
+ const identifier = getArg("--sign-identifier") ?? process.env.PI_COMPUTER_USE_CODESIGN_IDENTIFIER ?? defaultCodeSignIdentifier;
96
+ const args = ["--force", "-i", identifier];
97
+ if (hasArg("--hardened-runtime")) {
98
+ args.push("--options", "runtime");
99
+ }
100
+ if (hasArg("--timestamp")) {
101
+ args.push("--timestamp");
102
+ } else {
103
+ args.push("--timestamp=none");
104
+ }
105
+ args.push("--sign", identity);
106
+ return args;
107
+ }
108
+
109
+ async function signBinary(outputPath) {
110
+ if (hasArg("--no-sign") || process.env.PI_COMPUTER_USE_NO_SIGN === "1") return;
111
+ await run("/usr/bin/codesign", [...signingArgs(), outputPath]);
112
+ }
113
+
114
+ async function packageApp(binary, arch) {
115
+ const version = JSON.parse(await fs.readFile(path.join(rootDir, 'package.json'), 'utf8')).version;
116
+ // Even unsigned dev binaries need a sealed app for installation. This
117
+ // build-time ad-hoc fallback never discovers or creates a private key.
118
+ const sign = hasArg('--no-sign') || process.env.PI_COMPUTER_USE_NO_SIGN === '1'
119
+ ? ['--force', '-i', defaultCodeSignIdentifier, '--timestamp=none', '--sign', '-'] : signingArgs();
120
+ await packageMacosApp(binary, path.join(rootDir, 'prebuilt/macos', arch), version, sign);
121
+ }
122
+
123
+ async function buildForArch(arch, outputPath) {
124
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
125
+ console.log(`Building native helper for ${arch}...`);
126
+ await run("xcrun", swiftArgsForArch(arch, outputPath));
127
+ await fs.chmod(outputPath, 0o755);
128
+ await signBinary(outputPath);
129
+ console.log(`Built helper at ${outputPath}`);
130
+ }
131
+
132
+ async function buildUniversal(outputPath) {
133
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "tangu-computer-use-build-"));
134
+ const x64Output = path.join(tempDir, "bridge-x64");
135
+ const arm64Output = path.join(tempDir, "bridge-arm64");
136
+ await buildForArch("x64", x64Output);
137
+ await buildForArch("arm64", arm64Output);
138
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
139
+ await run("lipo", ["-create", "-output", outputPath, x64Output, arm64Output]);
140
+ await fs.chmod(outputPath, 0o755);
141
+ await signBinary(outputPath);
142
+ console.log(`Built universal helper at ${outputPath}`);
143
+ await fs.rm(tempDir, { recursive: true, force: true });
144
+ }
145
+
146
+ /**
147
+ * Return the actual cargo binary path, handling platform suffix differences.
148
+ * On Windows, the binary is named "windows-bridge.exe"; on other platforms it's "windows-bridge".
149
+ */
150
+ function windowsBinaryPath(crateDir, target) {
151
+ const releaseDir = target
152
+ ? path.join(crateDir, "target", target, "release")
153
+ : path.join(crateDir, "target", "release");
154
+ return path.join(releaseDir, "windows-bridge.exe");
155
+ }
156
+
157
+ async function buildWindowsHelper(prebuiltOutput) {
158
+ const target = getArg("--target");
159
+ if (process.platform !== "win32" && !target?.includes("windows")) {
160
+ throw new Error("Refusing to label a host binary as Windows. Build on Windows or pass an explicit Windows --target triple.");
161
+ }
162
+ const prebuiltDir = prebuiltOutput
163
+ ? path.resolve(process.cwd(), prebuiltOutput, "..")
164
+ : path.join(rootDir, "prebuilt", "windows");
165
+ const manifestPath = path.join(windowsCrateDir, "Cargo.toml");
166
+
167
+ console.log("Building Windows helper with cargo...");
168
+ // Tangu: link the MSVC runtime statically. The default dynamic link imports VCRUNTIME140.dll, which Windows does
169
+ // not ship, so the helper would not start on a clean machine; verify-package.mjs rejects such a helper. RUSTFLAGS,
170
+ // not the crate's .cargo/config.toml: cargo reads config from the caller's cwd. A duplicate flag (Genesis CI sets
171
+ // it too) is harmless.
172
+ // ponytail: a set RUSTFLAGS makes cargo ignore config rustflags (build.rustflags, target.<triple>.rustflags,
173
+ // CARGO_TARGET_*_RUSTFLAGS), so a cross-build's extra flags (xwin's -Lnative) must come in RUSTFLAGS. If that
174
+ // bites, pass `--config target.'cfg(target_env = "msvc")'.rustflags=[...]` whenever RUSTFLAGS is unset.
175
+ process.env.RUSTFLAGS = `${process.env.RUSTFLAGS ?? ""} -C target-feature=+crt-static`.trim();
176
+ const cargoArgs = [
177
+ "build",
178
+ "--release",
179
+ "--manifest-path",
180
+ manifestPath,
181
+ ];
182
+ if (target) cargoArgs.push("--target", target);
183
+ await run("cargo", cargoArgs);
184
+
185
+ await fs.mkdir(prebuiltDir, { recursive: true });
186
+
187
+ const cargoOutput = windowsBinaryPath(windowsCrateDir, target);
188
+ const handle = await fs.open(cargoOutput, "r");
189
+ try {
190
+ const signature = Buffer.alloc(2);
191
+ await handle.read(signature, 0, 2, 0);
192
+ if (signature.toString("ascii") !== "MZ") {
193
+ throw new Error(`Cargo output is not a Windows PE executable: ${cargoOutput}`);
194
+ }
195
+ } finally {
196
+ await handle.close();
197
+ }
198
+ const prebuiltDest = path.join(prebuiltDir, "windows-bridge.exe");
199
+ await fs.copyFile(cargoOutput, prebuiltDest);
200
+ await fs.chmod(prebuiltDest, 0o755);
201
+ console.log(`Built Windows helper at ${prebuiltDest}`);
202
+ }
203
+
204
+ function linuxBinaryPath(crateDir, target) {
205
+ const targetDir = target ? path.join("target", target) : "target";
206
+ return path.join(crateDir, targetDir, "release", "linux-bridge");
207
+ }
208
+
209
+ function defaultLinuxOutputPath(arch) {
210
+ return path.join(rootDir, "prebuilt", "linux", arch, "linux-bridge");
211
+ }
212
+
213
+ async function buildLinuxForArch(arch, outputPath) {
214
+ const explicitTarget = getArg("--target");
215
+ if (explicitTarget && !explicitTarget.includes("linux")) throw new Error(`Linux builds require a Linux --target triple, got ${explicitTarget}.`);
216
+ const target = explicitTarget ?? (process.platform === "linux" && process.arch === arch ? undefined : linuxTargets[arch]);
217
+ console.log(`Building Linux helper for ${arch} with cargo...`);
218
+ const cargoArgs = ["build", "--release", "--manifest-path", path.join(linuxCrateDir, "Cargo.toml")];
219
+ if (target) cargoArgs.push("--target", target);
220
+ await run("cargo", cargoArgs);
221
+ const cargoOutput = linuxBinaryPath(linuxCrateDir, target);
222
+ const header = await fs.readFile(cargoOutput).then((data) => data.subarray(0, 20));
223
+ if (header.length < 20 || !header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) throw new Error(`Cargo output is not a Linux ELF executable: ${cargoOutput}`);
224
+ const machine = header.readUInt16LE(18);
225
+ const expectedMachine = arch === "x64" ? 0x3e : 0xb7;
226
+ if (machine !== expectedMachine) throw new Error(`Cargo output architecture does not match ${arch}: ${cargoOutput}`);
227
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
228
+ await fs.copyFile(cargoOutput, outputPath);
229
+ await fs.chmod(outputPath, 0o755);
230
+ console.log(`Built Linux helper at ${outputPath}`);
231
+ }
232
+
233
+ async function buildLinuxHelper() {
234
+ const arch = normalizeArch(getArg("--arch") ?? process.arch);
235
+ if (arch === "universal") throw new Error("Linux does not support --arch universal. Use x64, arm64, or all.");
236
+ const outputArg = getArg("--output");
237
+ if (arch === "all") {
238
+ if (outputArg) throw new Error("--output is not supported with --arch all. Use a single architecture for one output.");
239
+ for (const nextArch of ["x64", "arm64"]) await buildLinuxForArch(nextArch, defaultLinuxOutputPath(nextArch));
240
+ return;
241
+ }
242
+ await buildLinuxForArch(arch, outputArg ? path.resolve(process.cwd(), outputArg) : defaultLinuxOutputPath(arch));
243
+ }
244
+
245
+ async function main() {
246
+ const explicitPlatform = getArg("--platform");
247
+
248
+ if (explicitPlatform === "windows") {
249
+ await buildWindowsHelper(getArg("--output"));
250
+ return;
251
+ }
252
+
253
+ if (explicitPlatform === "linux") {
254
+ await buildLinuxHelper();
255
+ return;
256
+ }
257
+
258
+ if (explicitPlatform === "darwin") {
259
+ // Fall through to macOS build logic below.
260
+ } else if (process.platform === "win32") {
261
+ await buildWindowsHelper(getArg("--output"));
262
+ return;
263
+ } else if (process.platform === "linux") {
264
+ await buildLinuxHelper();
265
+ return;
266
+ } else if (process.platform !== "darwin") {
267
+ console.log(
268
+ `Skipping native build: unsupported platform "${process.platform}". ` +
269
+ "Use --platform windows or --platform linux to build a helper, or run on macOS for the macOS helper.",
270
+ );
271
+ return;
272
+ }
273
+
274
+ const arch = normalizeArch(getArg("--arch") ?? process.arch);
275
+ const outputArg = getArg("--output");
276
+
277
+ if (arch === "all") {
278
+ if (outputArg) {
279
+ throw new Error("--output is not supported with --arch all. Use a single architecture for one output.");
280
+ }
281
+ for (const nextArch of ["x64", "arm64"]) {
282
+ await buildForArch(nextArch, defaultOutputPath(nextArch));
283
+ await packageApp(defaultOutputPath(nextArch), nextArch);
284
+ }
285
+ return;
286
+ }
287
+
288
+ const outputPath = outputArg ? path.resolve(process.cwd(), outputArg) : defaultOutputPath(arch);
289
+ if (arch === "universal") {
290
+ await buildUniversal(outputPath);
291
+ if (!outputArg) await packageApp(outputPath, arch);
292
+ return;
293
+ }
294
+
295
+ await buildForArch(arch, outputPath);
296
+ if (!outputArg) await packageApp(outputPath, arch);
297
+ }
298
+
299
+ main().catch((error) => {
300
+ console.error(error instanceof Error ? error.message : String(error));
301
+ process.exit(1);
302
+ });
@@ -0,0 +1,84 @@
1
+ // esbuild bundle:src/index.ts → tangu-plugins/computer-use/dist/index.js(单文件 ESM,运行时零 node_modules)。
2
+ // pi 依赖靠 alias 指到 pi-compat;node 内置 external;banner 注入 __dirname/__filename/require(vendor 可能用)。
3
+ //
4
+ // 产物为什么放这么深:本仓已升级为 **Forsion 捆绑包** —— 仓根就是 bundle 根(manifest.json + main.js +
5
+ // skills/),引擎侧内容按 bundles.ts 的约定住在 tangu-plugins/<pid>/。
6
+ import { build } from 'esbuild';
7
+ import { readFile, access } from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
+ const outDir = path.join(root, 'tangu-plugins/computer-use/dist');
13
+
14
+ // vendor 三个 helper(macos/windows/linux)用 import.meta.url 往上 3 级找包根,再拼 scripts/setup-helper.mjs
15
+ // 去装 native helper。源码树里 src/vendor/platform/<os>/ → 包根,对;打成单文件后就看产物放哪:
16
+ // 放 <root>/dist/index.js → 上跳 3 级冲出仓外,spawn 必失败(曾是 P0,靠构建期改写补救)
17
+ // 放 <root>/tangu-plugins/computer-use/dist/index.js → 上跳 3 级正好是 <root> ✔
18
+ // 所以捆绑包化之后**不再需要改写 vendor**,只需守住这个不变量。两道守卫:
19
+ // ① 每个平台文件里那行还在(上游改写法就炸,别静默失配)
20
+ // ② 构建完按产物位置实算一遍,3 级上跳必须落在含 scripts/setup-helper.mjs 的目录
21
+ const PLATFORMS = ['macos', 'windows', 'linux'];
22
+ const PKG_ROOT_NEEDLE = 'path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..")';
23
+ const seen = new Set();
24
+ const checkPackageRoot = {
25
+ name: 'check-vendor-package-root',
26
+ setup(b) {
27
+ b.onLoad({ filter: new RegExp(`vendor[\\\\/]platform[\\\\/](${PLATFORMS.join('|')})[\\\\/]helper\\.ts$`) }, async (args) => {
28
+ const src = await readFile(args.path, 'utf8');
29
+ if (!src.includes(PKG_ROOT_NEEDLE)) {
30
+ throw new Error(`PACKAGE_ROOT pattern not found in ${args.path} — 上游改了写法,重新核对 build.mjs 里的产物层级`);
31
+ }
32
+ seen.add(path.basename(path.dirname(args.path)));
33
+ return { contents: src, loader: 'ts' };
34
+ });
35
+ },
36
+ };
37
+
38
+ await build({
39
+ entryPoints: [path.join(root, 'src/index.ts')],
40
+ bundle: true,
41
+ platform: 'node',
42
+ format: 'esm',
43
+ target: 'node20',
44
+ outfile: path.join(outDir, 'index.js'),
45
+ external: ['node:*'],
46
+ alias: { '@earendil-works/pi-coding-agent': path.join(root, 'src/pi-compat.ts') },
47
+ plugins: [checkPackageRoot],
48
+ banner: {
49
+ js: [
50
+ "import { createRequire as __createRequire } from 'node:module';",
51
+ "import { fileURLToPath as __fileURLToPath } from 'node:url';",
52
+ "import { dirname as __pathDirname } from 'node:path';",
53
+ 'const require = __createRequire(import.meta.url);',
54
+ 'const __filename = __fileURLToPath(import.meta.url);',
55
+ 'const __dirname = __pathDirname(__filename);',
56
+ ].join('\n'),
57
+ },
58
+ logLevel: 'info',
59
+ });
60
+
61
+ const missed = PLATFORMS.filter((p) => !seen.has(p));
62
+ if (missed.length) {
63
+ throw new Error(`${missed.join('/')} 后端没进 bundle —— 上游改了目录布局,或 platform/index.ts 不再引它`);
64
+ }
65
+
66
+ const resolvedPackageRoot = path.resolve(outDir, '..', '..', '..');
67
+ await access(path.join(resolvedPackageRoot, 'scripts', 'setup-helper.mjs')).catch(() => {
68
+ throw new Error(
69
+ `PACKAGE_ROOT 不变量破了:产物在 ${outDir},vendor 上跳 3 级得到 ${resolvedPackageRoot},那里没有 scripts/setup-helper.mjs。` +
70
+ '改产物位置就必须同步核对这条(装 native helper 全靠它)。',
71
+ );
72
+ });
73
+
74
+ // 纯前台透明化模块单独产一份,给 scripts/no-foreground.check.mjs 免依赖 import(node 跑不了 .ts)。
75
+ await build({
76
+ entryPoints: [path.join(root, 'src/foregroundNote.ts')],
77
+ bundle: true,
78
+ platform: 'node',
79
+ format: 'esm',
80
+ target: 'node20',
81
+ outfile: path.join(outDir, 'foregroundNote.js'),
82
+ });
83
+
84
+ console.log('✓ built tangu-plugins/computer-use/dist/index.js');
@@ -0,0 +1,78 @@
1
+ /**
2
+ * 三个真机仪器共用的「干净的计算器」夹具。
3
+ *
4
+ * 为什么值得单独拿出来:每个仪器跑完都 `pkill -x Calculator`,下一个立刻 `open` —— 会撞上**正在退出**
5
+ * 的旧进程,拿到一个马上就要消失的 windowId。表现是取景流绑到死窗口、整轮回同一张图,于是
6
+ * check:liveview 在单跑时绿、串跑时随机红。薄脆的仪器比没有更坏:它会训练人忽略红灯。
7
+ * 所以开之前先确认旧的**真的没了**,再开、再等到窗口可观察为止。
8
+ */
9
+ import { execFile } from 'node:child_process'
10
+ import fs from 'node:fs'
11
+ import os from 'node:os'
12
+ import path from 'node:path'
13
+ import { promisify } from 'node:util'
14
+ import { resolveMacosHelperAppPath } from '../src/vendor/platform/macos/helper-path.mjs'
15
+
16
+ const execFileP = promisify(execFile)
17
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
18
+ const BUNDLE_ID = 'com.apple.calculator'
19
+
20
+ export const SOCK = `${os.homedir()}/Library/Caches/tangu-computer-use/bridge.sock`
21
+ export const HELPER_APP = resolveMacosHelperAppPath()
22
+ export const HELPER_EXECUTABLE = path.join(HELPER_APP, 'Contents', 'MacOS', 'bridge')
23
+
24
+ /**
25
+ * daemon 没在跑就拉起来。仪器要能从**冷机**跑起来 —— 刚重装完 helper、或刚 `computer-use stop` 过,
26
+ * 否则每次都得先手动铺场,而「先手动铺场」的仪器迟早没人跑。
27
+ *
28
+ * ⚠️必须走 `open -n -g <APP>`(与 vendor 的 launchDaemon 一致),**不能**直接 spawn
29
+ * Contents/MacOS/bridge:TCC 认的是 responsible process,直接 spawn 会把归因挂到调用者
30
+ * (终端/node)身上,于是 diagnostics 报「没有辅助功能权限」—— 看起来像授权丢了,其实是启动方式错了。
31
+ */
32
+ export async function ensureDaemon(ask) {
33
+ for (let attempt = 0; attempt < 25; attempt++) {
34
+ try { return await ask({ cmd: 'diagnostics' }) } catch (e) {
35
+ if (attempt === 0 && (e.code === 'ECONNREFUSED' || e.code === 'ENOENT')) {
36
+ fs.rmSync(SOCK, { force: true })
37
+ await execFileP('open', ['-n', '-g', HELPER_APP, '--args', 'serve', '--socket', SOCK]).catch(() => {})
38
+ }
39
+ await sleep(300)
40
+ }
41
+ }
42
+ throw new Error(`helper daemon 起不来(${HELPER_APP})`)
43
+ }
44
+
45
+ async function listCalculator(ask) {
46
+ return (await ask({ cmd: 'listApps' })).find((a) => a.bundleId === BUNDLE_ID)
47
+ }
48
+
49
+ /** 结束掉当前的计算器,并等到 helper 也确认它不在了。 */
50
+ export async function killCalculator(ask, timeoutMs = 8_000) {
51
+ await execFileP('pkill', ['-x', 'Calculator']).catch(() => {})
52
+ const deadline = Date.now() + timeoutMs
53
+ while (Date.now() < deadline) {
54
+ if (!(await listCalculator(ask))) return
55
+ await sleep(150)
56
+ }
57
+ // ⚠️静默放行等于把夹具要消灭的竞态原样放回来:下一步会立刻 open,撞上还没退干净的旧实例。
58
+ // 宁可红,也别让后面那些断言在一个不确定的场子里跑。
59
+ throw new Error('计算器在 ' + timeoutMs + 'ms 内没有退出 —— 场子不干净,拒绝继续')
60
+ }
61
+
62
+ /** 开一个全新的、后台的、已经有可观察窗口的计算器。 */
63
+ export async function freshCalculator(ask, timeoutMs = 15_000) {
64
+ await killCalculator(ask)
65
+ await execFileP('open', ['-g', '-a', 'Calculator'])
66
+ const deadline = Date.now() + timeoutMs
67
+ let last = '还没看到计算器进程'
68
+ while (Date.now() < deadline) {
69
+ const calc = await listCalculator(ask)
70
+ if (calc) {
71
+ const win = (await ask({ cmd: 'listRoots', pid: calc.pid })).roots.find((r) => r.windowId)
72
+ if (win) return { calc, win }
73
+ last = '进程在但还没有可见窗口'
74
+ }
75
+ await sleep(300)
76
+ }
77
+ throw new Error(`等计算器就绪超时:${last}`)
78
+ }
@@ -0,0 +1,49 @@
1
+ // `tangu computer-use setup|doctor` must let the process exit by itself. On Windows and Linux the helper is a
2
+ // stdio child whose open pipes keep the event loop alive, and the host deliberately never force-exits after a
3
+ // plugin command (long-running commands such as `tangu worker` live on their open handles). Runs the real cliMain
4
+ // in its own process with a helper of that shape and asserts the process ends within the deadline, with the
5
+ // command's exit code.
6
+ import assert from 'node:assert/strict';
7
+ import { build } from 'esbuild';
8
+ import { spawnSync } from 'node:child_process';
9
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+
13
+ const temp = mkdtempSync(path.join(os.tmpdir(), 'cu-cli-exit-'));
14
+ try {
15
+ await build({ entryPoints: [path.resolve('src/setup.ts')], outfile: path.join(temp, 'cli.mjs'), bundle: true, platform: 'node', format: 'esm',
16
+ plugins: [{ name: 'stdio-helper', setup(b) {
17
+ b.onResolve({ filter: /\/vendor\/bridge\.ts$|\/helperState\.ts$/ }, (args) => ({ path: args.path, namespace: 'fixture' }));
18
+ b.onLoad({ filter: /.*/, namespace: 'fixture' }, (args) => ({ contents: args.path.endsWith('helperState.ts')
19
+ ? `export const helperInstalled = () => !process.env.CU_FIXTURE_NO_HELPER; export const helperExecutablePath = () => 'fixture-helper';`
20
+ : `import { spawn } from 'node:child_process';
21
+ let child;
22
+ export async function ensureComputerUseSetup() {
23
+ child = spawn(process.execPath, ['-e', 'process.stdin.resume()'], { stdio: 'pipe' }); // same shape as the Windows/Linux helper
24
+ if (process.env.CU_FIXTURE_FAIL) throw new Error('fixture helper is not ready');
25
+ }
26
+ export async function shutdownComputerUseSession() {
27
+ if (!child) return;
28
+ child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); child.kill();
29
+ child = undefined;
30
+ }` }));
31
+ } }], logLevel: 'silent' });
32
+ const runner = path.join(temp, 'run.mjs');
33
+ // Mirrors the host: the returned code becomes the exit code, nothing forces the process to exit.
34
+ writeFileSync(runner, `import { cliMain } from './cli.mjs';\nprocess.exitCode = await cliMain(process.argv.slice(2));\n`);
35
+ for (const [name, args, env, code] of [
36
+ ['setup succeeds', ['setup'], {}, 0],
37
+ ['doctor succeeds', ['doctor'], {}, 0],
38
+ ['setup fails after starting the helper', ['setup'], { CU_FIXTURE_FAIL: '1' }, 1],
39
+ ['doctor fails after starting the helper', ['doctor'], { CU_FIXTURE_FAIL: '1' }, 1],
40
+ ['doctor without an installed helper', ['doctor'], { CU_FIXTURE_NO_HELPER: '1' }, 1],
41
+ ]) {
42
+ const r = spawnSync(process.execPath, [runner, ...args], { env: { ...process.env, ...env }, encoding: 'utf8', timeout: 10_000 });
43
+ assert.equal(r.error?.code, undefined, `${name}: the process did not exit on its own (${r.error?.code})`);
44
+ assert.equal(r.status, code, `${name}: exit code\n${r.stdout}${r.stderr}`);
45
+ console.log(`PASS ${name}`);
46
+ }
47
+ } finally {
48
+ rmSync(temp, { recursive: true, force: true });
49
+ }
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // mac helper 安装位置的解析规则(照搬上游 check-macos-helper-path.mjs,路径改到 vendor)。
3
+ // 为什么值得留:v0.5.0 起 helper 不再固定装 /Applications —— 已有的可写系统安装留在原地,其余一律
4
+ // 装进 ~/Applications(标准用户无需管理员)。解析错 = 装到一处、运行时连另一处,doctor 全绿但真操作必失败。
5
+ import assert from 'node:assert/strict';
6
+ import path from 'node:path';
7
+ import { resolveMacosHelperAppPath } from '../src/vendor/platform/macos/helper-path.mjs';
8
+
9
+ const homeDir = path.join(path.sep, 'Users', 'standard-user');
10
+ const systemHelperAppPath = path.join(path.sep, 'Applications', 'tangu-computer-use.app');
11
+ const userHelperAppPath = path.join(homeDir, 'Applications', 'tangu-computer-use.app');
12
+
13
+ assert.equal(
14
+ resolveMacosHelperAppPath({ env: { PI_COMPUTER_USE_HELPER_APP_PATH: '/tmp/custom-helper.app' }, homeDir }),
15
+ '/tmp/custom-helper.app',
16
+ '显式路径优先',
17
+ );
18
+ assert.equal(
19
+ resolveMacosHelperAppPath({ env: {}, homeDir, systemHelperAppPath, fileExists: () => false }),
20
+ userHelperAppPath,
21
+ '全新安装走 ~/Applications',
22
+ );
23
+ assert.equal(
24
+ resolveMacosHelperAppPath({ env: {}, homeDir, systemHelperAppPath, fileExists: () => true, directoryIsWritable: () => true }),
25
+ systemHelperAppPath,
26
+ '已有的可写系统安装留在原地(不迁移 = 不用重新授权 TCC)',
27
+ );
28
+ assert.equal(
29
+ resolveMacosHelperAppPath({ env: {}, homeDir, systemHelperAppPath, fileExists: () => true, directoryIsWritable: () => false }),
30
+ userHelperAppPath,
31
+ '标准用户从不可写的系统安装迁走',
32
+ );
33
+
34
+ // 品牌:app 名必须是 tangu-*,否则 native/setup-helper 与运行时对不上(P0 复发防线)。
35
+ assert.ok(
36
+ resolveMacosHelperAppPath({ env: {}, homeDir, fileExists: () => false }).endsWith('tangu-computer-use.app'),
37
+ 'helper app 名必须品牌化',
38
+ );
39
+
40
+ // ── 自装/自更新的前置条件(症状:「首次安装和更新都要去终端敲 setup」)────────────────
41
+ // 自愈只有在 bundle **自带**安装脚本 + 随包二进制时才成立。这三样任缺其一,
42
+ // onboarding.ts 的 ensureHelperCurrent 就只能退回「请开终端」—— 而那正是要消灭的体验。
43
+ // 从**构建产物**的位置往上找,而不是从本脚本找:产物挪位过一次就是 P0(见 scripts/build.mjs)。
44
+ import { existsSync } from 'node:fs';
45
+ import { fileURLToPath } from 'node:url';
46
+
47
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
48
+ const artifact = path.join(repoRoot, 'tangu-plugins', 'computer-use', 'dist', 'index.js');
49
+ assert.ok(existsSync(artifact), `构建产物不在 ${artifact} —— 先 npm run build`);
50
+
51
+ function bundleRootFrom(startFile) {
52
+ let dir = path.dirname(startFile);
53
+ for (let up = 0; up < 6; up++) {
54
+ if (existsSync(path.join(dir, 'manifest.json')) && existsSync(path.join(dir, 'scripts', 'setup-helper.mjs'))) return dir;
55
+ const parent = path.dirname(dir);
56
+ if (parent === dir) break;
57
+ dir = parent;
58
+ }
59
+ return undefined;
60
+ }
61
+ assert.equal(bundleRootFrom(artifact), repoRoot, '从产物往上必须能找到 bundle 根(manifest.json + scripts/setup-helper.mjs)');
62
+
63
+ const arch = process.arch === 'x64' ? 'x64' : 'arm64';
64
+ assert.ok(
65
+ existsSync(path.join(repoRoot, 'prebuilt', 'macos', arch, 'bridge'))
66
+ || existsSync(path.join(repoRoot, 'prebuilt', 'macos', 'universal', 'tangu-computer-use.app')),
67
+ `bundle 里必须随包带 native helper(prebuilt/macos/${arch}/bridge 或 universal 的已签名 .app)—— 否则自装要联网,离线/内网必失败`,
68
+ );
69
+
70
+ // ⚠️自装绝不能杀安装子进程:它内部 codesign helper.app,被中断会留下 *.cstemp,下次 --deep 把它封进
71
+ // CodeResources 再删掉 → 签名恒定校验失败 → TCC 认签名 → 用户看到「权限开关怎么点都没用」。
72
+ // 这条错了**不会报错**,只会静默毁掉授权,所以用文本防线钉住(2026-07-28 真踩过)。
73
+ import { readFileSync } from 'node:fs';
74
+ const onboarding = readFileSync(path.join(repoRoot, 'src', 'onboarding.ts'), 'utf8');
75
+ assert.ok(!/child\.kill\(/.test(onboarding), 'src/onboarding.ts 不得 kill 安装子进程 —— 中断 codesign 会永久毁掉签名与 TCC 授权');
76
+ assert.ok(/ELECTRON_RUN_AS_NODE/.test(onboarding), '自装必须设 ELECTRON_RUN_AS_NODE,否则在 Electron 宿主里会去开窗口而不是跑脚本');
77
+ assert.ok(/macosHelperIsCurrent/.test(onboarding), 'helperNeedsUpdate 与安装器共用完整 App 与签名校验');
78
+ // 权限真值只能问 checkPermissions:diagnostics 的 screenRecording 只是 CGPreflight 缓存值
79
+ // (bridge.swift 自己的注释:"Permission truth comes from checkPermissions")。
80
+ assert.ok(/checkPermissions/.test(onboarding), '权限判定必须走 checkPermissions,不能用 diagnostics 的预检值');
81
+
82
+ console.log('helper path checks passed');
@@ -0,0 +1,62 @@
1
+ // Exercise the production onboarding flow with isolated installer/daemon boundaries.
2
+ import assert from 'node:assert/strict';
3
+ import { build } from 'esbuild';
4
+ import { createHash } from 'node:crypto';
5
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { pathToFileURL } from 'node:url';
9
+
10
+ const temp = mkdtempSync(path.join(os.tmpdir(), 'cu-refresh-'));
11
+ try {
12
+ for (const scenario of ['stale', 'missing', 'current', 'install-failure', 'restart-failure']) {
13
+ const root = path.join(temp, scenario), events = path.join(root, 'events');
14
+ const executable = path.join(root, 'installed/Contents/MacOS/bridge');
15
+ const stamp = path.join(root, 'installed/Contents/Resources/source.sha256');
16
+ const bundled = path.join(root, `prebuilt/macos/${process.arch}/tangu-computer-use.app.zip`);
17
+ const output = path.join(root, 'tangu-plugins/computer-use/dist/probe.mjs');
18
+ for (const file of [executable, stamp, bundled, output, path.join(root, 'scripts/setup-helper.mjs')]) mkdirSync(path.dirname(file), { recursive: true });
19
+ writeFileSync(path.join(root, 'manifest.json'), '{}');
20
+ writeFileSync(path.join(root, 'package.json'), JSON.stringify({ version: '0.5.3' }));
21
+ writeFileSync(bundled, 'new helper image');
22
+ if (scenario !== 'missing') writeFileSync(executable, 'locally signed image');
23
+ writeFileSync(stamp, scenario === 'current' ? createHash('sha256').update(readFileSync(bundled)).digest('hex') : 'old image');
24
+ const sealed = ['Contents/MacOS/bridge', 'Contents/Info.plist', 'Contents/_CodeSignature/CodeResources'];
25
+ const files = {};
26
+ for (const file of sealed) {
27
+ const target = path.join(root, 'installed', file);
28
+ mkdirSync(path.dirname(target), { recursive: true });
29
+ files[file] = createHash('sha256').update('new helper image').digest('hex');
30
+ if (scenario !== 'missing') writeFileSync(target, scenario === 'current' ? 'new helper image' : 'old helper image');
31
+ }
32
+ writeFileSync(bundled.replace(/\.zip$/, '.json'), JSON.stringify({ version: '0.5.3', bundleId: 'com.forsion.tangu-computer-use', archiveSha256: files[sealed[0]], files }));
33
+ writeFileSync(path.join(root, 'scripts/setup-helper.mjs'), `import {appendFileSync} from 'node:fs';
34
+ appendFileSync(${JSON.stringify(events)}, 'install\\n');
35
+ ${scenario === 'install-failure' ? "console.error('fixture install failure'); process.exitCode = 1;" : ''}`);
36
+ await build({ entryPoints: [path.resolve('src/onboarding.ts')], outfile: output, bundle: true, platform: 'node', format: 'esm',
37
+ define: { 'process.platform': '"darwin"' },
38
+ plugins: [{ name: 'isolated-helper', setup(b) {
39
+ b.onResolve({ filter: /^node:child_process$/ }, args => args.namespace !== 'process-fixture' ? { path: args.path, namespace: 'process-fixture' } : undefined);
40
+ b.onLoad({ filter: /.*/, namespace: 'process-fixture' }, () => ({ contents: `export {spawn} from 'node:child_process'; export const execFileSync = () => Buffer.alloc(0);` }));
41
+ b.onResolve({ filter: /\/helperState\.ts$|\/vendor\/platform\/macos\/helper\.ts$/ }, (args) => ({ path: args.path, namespace: 'fixture' }));
42
+ b.onLoad({ filter: /.*/, namespace: 'fixture' }, (args) => ({ contents: args.path.endsWith('helperState.ts')
43
+ ? `import {existsSync} from 'node:fs'; export const helperInstalled = () => existsSync(${JSON.stringify(executable)}); export const helperExecutablePath = () => ${JSON.stringify(executable)}; export const isSupportedPlatform = () => true;`
44
+ : `import {appendFileSync,readFileSync} from 'node:fs'; export const macosHelper = {restart: async () => {
45
+ if(readFileSync(${JSON.stringify(events)}, 'utf8') !== 'install\\n') throw Error('restarted before installer finished');
46
+ appendFileSync(${JSON.stringify(events)}, 'restart\\n');
47
+ ${scenario === 'restart-failure' ? "throw Error('fixture restart failure');" : ''}
48
+ }};` }));
49
+ } }], logLevel: 'silent' });
50
+ const mod = await import(pathToFileURL(output).href);
51
+ const [note, duplicate] = await Promise.all([mod.ensureHelperCurrent(), mod.ensureHelperCurrent()]);
52
+ const lines = existsSync(events) ? readFileSync(events, 'utf8').trim().split('\n') : [];
53
+ assert.deepEqual(lines, scenario === 'current' ? [] : ['install', ...(['stale', 'restart-failure'].includes(scenario) ? ['restart'] : [])], scenario);
54
+ assert.equal(duplicate, undefined, 'concurrent callers share one installation and one notification');
55
+ assert.equal(mod.autoInstallFailed(), scenario.endsWith('failure'));
56
+ if (scenario.endsWith('failure')) assert.match(note, /Could not install.*fixture (install|restart) failure/);
57
+ else if (scenario === 'stale') assert.match(note, /^Updated/);
58
+ else if (scenario === 'missing') assert.match(note, /^Installed/);
59
+ else assert.equal(note, undefined);
60
+ console.log(`PASS ${scenario}`);
61
+ }
62
+ } finally { rmSync(temp, { recursive: true, force: true }); }