@bingjiang0611/rotom 0.1.0-alpha.0 → 0.1.0-alpha.2
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 +19 -2
- package/bin/pi-agent +4 -2
- package/extensions/browser/browser-relay.ts +4 -3
- package/extensions/browser/chrome-extension/manifest.json +4 -4
- package/extensions/browser/install-chrome-relay.mjs +54 -19
- package/extensions/browser/native-host.mjs +25 -13
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/runtime/product-config.mjs +3 -0
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ npm 包名为 `@bingjiang0611/rotom`,Alpha 使用 `alpha` tag,CLI 仍为 `ro
|
|
|
9
9
|
要求 Node.js 24+ 和 POSIX shell。当前验证范围为 macOS;Linux 安装/终端运行仍待真实验证,Windows 原生入口不受支持。
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
|
-
npm install -g --ignore-scripts @bingjiang0611/rotom@alpha
|
|
12
|
+
npm install -g --ignore-scripts @bingjiang0611/rotom@alpha --registry=https://registry.npmjs.org --prefer-online
|
|
13
13
|
cd /path/to/your/project
|
|
14
14
|
rotom
|
|
15
15
|
```
|
|
@@ -22,6 +22,12 @@ rotom
|
|
|
22
22
|
|
|
23
23
|
升级重新执行上述 npm 安装命令;也支持将包名替换为已验证的本地 `.tgz` 路径安装。卸载用 `npm uninstall -g @bingjiang0611/rotom`。旧本地无 scope Alpha 包须先用 `npm uninstall -g rotom` 卸载,以免两个包争用同一个 CLI(不要使用 `--force` 覆盖)。不会自动删除用户凭据、会话或 Chrome relay 注册,后者需单独显式卸载。
|
|
24
24
|
|
|
25
|
+
## Alpha 修复
|
|
26
|
+
|
|
27
|
+
`0.1.0-alpha.2` 将用户级 Browser 目录统一为 `rotom`,扩展显示名改为 `rotom Browser Relay`;Native Host 与客户端使用同一新 socket 路径。保留旧目录、扩展 ID 和 Native Messaging Host ID,不扩大权限。
|
|
28
|
+
|
|
29
|
+
`0.1.0-alpha.1` 修复 `setup browser` 在用户或项目存在 `.claude/skills` 时误收 session 参数、报「无效参数」的问题;不需要删除或移动 skills。普通 Pi 会话的 skills 发现行为保持不变。
|
|
30
|
+
|
|
25
31
|
## 配置 Chrome 浏览器(macOS)
|
|
26
32
|
|
|
27
33
|
```sh
|
|
@@ -34,7 +40,7 @@ rotom setup browser
|
|
|
34
40
|
|
|
35
41
|
1. 打开 `chrome://extensions`,开启开发者模式。
|
|
36
42
|
2. 点击「加载已解压的扩展程序」,选择命令显示的目录:
|
|
37
|
-
`~/Library/Application Support/
|
|
43
|
+
`~/Library/Application Support/rotom/browser-relay/extension`。
|
|
38
44
|
3. 等待终端显示「浏览器已连接,协议兼容」。如果此前从 npm/源码目录加载过同一扩展,请手动移除旧条目,再加载这个固定目录。
|
|
39
45
|
|
|
40
46
|
交互终端最多等候 120 秒,Ctrl+C 只结束等候;非交互默认只检查一次。也可以单独检查或设置等待时间:
|
|
@@ -48,6 +54,17 @@ rotom setup browser --help
|
|
|
48
54
|
|
|
49
55
|
安装准备成功不等于 Chrome 已加载。`--status` 未就绪返回 3,无法确认/不可信状态返回 1;默认 setup 在文件已就绪但等待手动加载时可返回 0,终端会明确显示未连接。检查仅使用有界 hello 握手,验证 nonce、协议和必需能力,不请求页面内容。握手兼容不证明 Chrome 已重载最新文件。
|
|
50
56
|
|
|
57
|
+
### 从旧 pi-agent 目录升级
|
|
58
|
+
|
|
59
|
+
旧版使用 `~/Library/Application Support/pi-agent/browser-relay`。升级到 alpha.2 后:
|
|
60
|
+
|
|
61
|
+
1. 在 `chrome://extensions` 手动停用旧扩展,等待旧 Host 退出。
|
|
62
|
+
2. 运行 `rotom setup browser`。命令从当前包重建新目录并更新 Host 注册,不复制旧目录中的文件。
|
|
63
|
+
3. 移除 Chrome 中旧路径的扩展条目,加载命令显示的 `rotom` 新目录;重新启动 rotom 会话。
|
|
64
|
+
4. 用 `rotom setup browser --status` 确认新路径的连接。
|
|
65
|
+
|
|
66
|
+
旧目录及其文件保留原状(安装期间只使用临时互斥锁);不会自动删除、搬运、创建目录 symlink 或中断 Host。新/旧路径有活动或无法确认的 Host、旧安装锁、目录身份异常或未知 Host 注册时,拒绝覆盖。新客户端不回退到旧 socket,旧连接在线不算新配置已就绪。`dev.pi_agent.browser_relay` 与扩展 ID 是兼容协议身份,不是对外产品名。
|
|
67
|
+
|
|
51
68
|
日常直接运行 `rotom`,Chrome 扩展负责启动 Host,工具按需连接。更新 rotom、变更安装位置或更换 Node 后重新运行 setup;固定扩展目录不变。若检测到活动 Host 且需要更新文件,会拒绝覆盖:请先手动停用扩展、等待 Host 退出,完成 setup 后重新启用/重载。未知文件、symlink、权限异常或未完成的安装锁会阻断,不自动清理、杀进程或重放写入。失败可能留下部分安装,需检查后明确重跑。
|
|
52
69
|
|
|
53
70
|
当前只有 macOS 配置路径受支持。自动安装与连接分支通过隔离 HOME/模拟 relay 验证;真实 Chrome 首次加载和升级重载仍需人工验收。
|
package/bin/pi-agent
CHANGED
|
@@ -176,7 +176,9 @@ claude_user_skill_dir=
|
|
|
176
176
|
if [ "${HOME+x}" = x ]; then
|
|
177
177
|
claude_user_skill_dir="$HOME/.claude/skills"
|
|
178
178
|
fi
|
|
179
|
-
|
|
179
|
+
# Setup has no session skills: prepending --skill would corrupt its original
|
|
180
|
+
# setup/browser argv before the checked dispatch below consumes those two words.
|
|
181
|
+
if [ "$setup_browser" -eq 0 ] && [ "$load_claude_project_skills" -eq 1 ]; then
|
|
180
182
|
claude_repo_root=
|
|
181
183
|
claude_probe_dir=$PWD
|
|
182
184
|
while :; do
|
|
@@ -206,7 +208,7 @@ if [ "$load_claude_project_skills" -eq 1 ]; then
|
|
|
206
208
|
claude_scan_dir=$claude_parent_dir
|
|
207
209
|
done
|
|
208
210
|
fi
|
|
209
|
-
if [ -n "$claude_user_skill_dir" ] && [ -d "$claude_user_skill_dir" ]; then
|
|
211
|
+
if [ "$setup_browser" -eq 0 ] && [ -n "$claude_user_skill_dir" ] && [ -d "$claude_user_skill_dir" ]; then
|
|
210
212
|
set -- --skill "$claude_user_skill_dir" "$@"
|
|
211
213
|
fi
|
|
212
214
|
|
|
@@ -7,6 +7,7 @@ import { join } from "node:path";
|
|
|
7
7
|
import { connect, type Socket } from "node:net";
|
|
8
8
|
|
|
9
9
|
import { compatibleRelayReady } from "./relay-protocol.mjs";
|
|
10
|
+
import { BROWSER_RELAY_SUPPORT_RELATIVE } from "../../runtime/product-config.mjs";
|
|
10
11
|
export {
|
|
11
12
|
BROWSER_RELAY_PROTOCOL_REVISION_V1, BROWSER_RELAY_FULL_READ_CAPABILITY_V1,
|
|
12
13
|
BROWSER_RELAY_MULTI_CLIENT_CAPABILITY_V1, BROWSER_RELAY_COORDINATE_CLICK_CAPABILITY_V1,
|
|
@@ -26,7 +27,7 @@ const MAX_MODEL_NODES = 1_000;
|
|
|
26
27
|
const MAX_STRING_BYTES = 4_096;
|
|
27
28
|
|
|
28
29
|
export function browserRelaySocketPathV1(): string {
|
|
29
|
-
return join(homedir(),
|
|
30
|
+
return join(homedir(), BROWSER_RELAY_SUPPORT_RELATIVE, "relay.sock");
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
function record(value: unknown): value is Record<string, unknown> {
|
|
@@ -349,12 +350,12 @@ export class BrowserRelayClientV1 {
|
|
|
349
350
|
if (handshakeRemainingMs <= 0) throw new Error("browser relay 连接超时;请确认 Chrome 扩展已启用");
|
|
350
351
|
const nonce = randomUUID();
|
|
351
352
|
const ready = await client.request("hello", { nonce }, { timeoutMs: Math.max(1, handshakeRemainingMs) });
|
|
352
|
-
if (!compatibleRelayReady(ready, nonce)) throw new Error("browser relay 版本过旧;请在 chrome://extensions 重载
|
|
353
|
+
if (!compatibleRelayReady(ready, nonce)) throw new Error("browser relay 版本过旧;请在 chrome://extensions 重载 rotom Browser Relay");
|
|
353
354
|
return client;
|
|
354
355
|
} catch (error) {
|
|
355
356
|
client.close();
|
|
356
357
|
const failure = error instanceof Error ? error : new Error(String(error));
|
|
357
|
-
if (/browser relay busy; another operation is active/iu.test(failure.message)) throw new Error("browser relay 协议过旧且仍使用单 client busy 仲裁;请在 chrome://extensions 重载
|
|
358
|
+
if (/browser relay busy; another operation is active/iu.test(failure.message)) throw new Error("browser relay 协议过旧且仍使用单 client busy 仲裁;请在 chrome://extensions 重载 rotom Browser Relay", { cause: failure });
|
|
358
359
|
throw failure;
|
|
359
360
|
}
|
|
360
361
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
|
-
"name": "
|
|
4
|
-
|
|
5
|
-
"description": "Connects
|
|
3
|
+
"name": "rotom Browser Relay",
|
|
4
|
+
"version": "0.9.1",
|
|
5
|
+
"description": "Connects rotom to Chrome tabs with direct trusted-local inspection and interaction.",
|
|
6
6
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArCtbXZfW6PA0XsZFuZcFOxDP3k5PgUj5OnXjcsFQlkal7dq4g/FTg99qoiFj1d60bnGOtFrlPzkSImwREFz3R1ax9xwtSK8yAI8aUSqbY3UMoLoDSuD4gQ/eLBskCJju4b1UJ6aBH+047GEYPhC7532lrEuIeFoReXkkCJP4GPWmU2Ac0TrcYVni7nfet4LlnfbVnp7Sp2IRH0/WNpGUCHlGFvYwHqm9Kw4AzI9FcBe08cPPkNaCUr+bjpnkPkvi1/gQz2wDNPJlCTXzPsdYDog0BsWHn69Pm6y7N40TkNtj2jPZsmcd6KciZQpUkPAl895q/3f6hZnDovDOGJs4NQIDAQAB",
|
|
7
7
|
"minimum_chrome_version": "125",
|
|
8
8
|
"permissions": [
|
|
@@ -18,6 +18,6 @@
|
|
|
18
18
|
"type": "module"
|
|
19
19
|
},
|
|
20
20
|
"action": {
|
|
21
|
-
"default_title": "
|
|
21
|
+
"default_title": "rotom Browser Relay"
|
|
22
22
|
}
|
|
23
23
|
}
|
|
@@ -7,7 +7,7 @@ import { homedir } from "node:os";
|
|
|
7
7
|
import { dirname, join, resolve } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { probeRelay } from "./relay-setup-probe.mjs";
|
|
10
|
-
import { DISTRIBUTION_PACKAGE_NAME } from "../../runtime/product-config.mjs";
|
|
10
|
+
import { BROWSER_RELAY_SUPPORT_RELATIVE, DISTRIBUTION_PACKAGE_NAME, LEGACY_BROWSER_RELAY_SUPPORT_RELATIVE } from "../../runtime/product-config.mjs";
|
|
11
11
|
|
|
12
12
|
const HOST_NAME = "dev.pi_agent.browser_relay";
|
|
13
13
|
const EXTENSION_ID = "kgadcllokaodnoknakblocmhidemimdi";
|
|
@@ -62,14 +62,14 @@ async function context({ home = homedir(), sourceDir = ROOT, platform = process.
|
|
|
62
62
|
if (id !== EXTENSION_ID || manifest.manifest_version !== 3 || manifest.background?.service_worker !== "service-worker.js") throw new Error("Chrome extension identity 无效");
|
|
63
63
|
const nativeHostSource = join(sourceDir, "native-host.mjs");
|
|
64
64
|
await regularFile(nativeHostSource);
|
|
65
|
-
const supportRelative =
|
|
65
|
+
const supportRelative = BROWSER_RELAY_SUPPORT_RELATIVE;
|
|
66
66
|
const chromeRelative = "Library/Application Support/Google/Chrome/NativeMessagingHosts";
|
|
67
67
|
const supportDir = join(home, supportRelative);
|
|
68
68
|
const extensionDir = join(supportDir, "extension");
|
|
69
69
|
const launcherPath = join(supportDir, "native-host-launcher.sh");
|
|
70
70
|
const nativeHostManifest = join(home, chromeRelative, `${HOST_NAME}.json`);
|
|
71
71
|
const launcher = Buffer.from(`#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(nativeHostSource)}\n`);
|
|
72
|
-
const nativeManifest = Buffer.from(`${JSON.stringify({ name: HOST_NAME, description: "
|
|
72
|
+
const nativeManifest = Buffer.from(`${JSON.stringify({ name: HOST_NAME, description: "rotom Browser Relay Native Messaging Host", path: launcherPath, type: "stdio", allowed_origins: [`chrome-extension://${EXTENSION_ID}/`] }, null, 2)}\n`);
|
|
73
73
|
return { home, files, supportRelative, chromeRelative, supportDir, extensionDir, launcherPath, nativeHostManifest, launcher, nativeManifest, socketPath: join(supportDir, "relay.sock"), extensionVersion: manifest.version };
|
|
74
74
|
}
|
|
75
75
|
|
|
@@ -89,9 +89,16 @@ async function installationMatches(c) {
|
|
|
89
89
|
&& (await regularFile(c.nativeHostManifest, true)).equals(c.nativeManifest);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
async function legacyDirectory(c) {
|
|
93
|
+
const path = join(c.home, LEGACY_BROWSER_RELAY_SUPPORT_RELATIVE);
|
|
94
|
+
try { await directory(c.home, LEGACY_BROWSER_RELAY_SUPPORT_RELATIVE, false, true); return { state: "present", path }; }
|
|
95
|
+
catch (error) { return { state: error.code === "ENOENT" ? "absent" : "blocked", path }; }
|
|
96
|
+
}
|
|
97
|
+
|
|
92
98
|
async function currentStatus(c) {
|
|
93
|
-
|
|
94
|
-
|
|
99
|
+
const legacy = await legacyDirectory(c);
|
|
100
|
+
try { return { ...details(c), legacyDirectory: legacy, installed: await installationMatches(c) }; }
|
|
101
|
+
catch (error) { return { ...details(c), legacyDirectory: legacy, installed: false, reason: error.code === "ENOENT" ? "not-installed-or-partial" : "identity-or-permission-mismatch" }; }
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
export async function browserRelayStatus(options = {}) {
|
|
@@ -116,15 +123,24 @@ async function atomicWrite(c, path, bytes, mode) {
|
|
|
116
123
|
|
|
117
124
|
export async function installBrowserRelay(options = {}) {
|
|
118
125
|
const c = await context(options); // Validate bundled bytes before any HOME writes.
|
|
119
|
-
await
|
|
126
|
+
const legacy = await legacyDirectory(c);
|
|
127
|
+
if (legacy.state === "blocked") throw new Error("旧 pi-agent relay 目录身份或权限不可信;保留原状,请人工检查");
|
|
128
|
+
await directory(c.home, dirname(c.supportRelative), true, true);
|
|
120
129
|
await directory(c.home, c.supportRelative, true, true);
|
|
121
130
|
await directory(c.home, c.chromeRelative, true);
|
|
122
|
-
const
|
|
123
|
-
let lock;
|
|
124
|
-
try { lock = await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600); }
|
|
125
|
-
catch { throw new Error("setup 被锁定;另一次安装或未完成的安装需先确认,不能自动清理锁"); }
|
|
126
|
-
const lockIdentity = await lock.stat();
|
|
131
|
+
const locks = [];
|
|
127
132
|
try {
|
|
133
|
+
// Coordinate with an existing alpha.0/alpha.1 installer as well as new
|
|
134
|
+
// setup processes. Do not create a legacy tree on a fresh installation.
|
|
135
|
+
const directories = legacy.state === "present" ? [legacy.path, c.supportDir] : [c.supportDir];
|
|
136
|
+
for (const path of directories) {
|
|
137
|
+
await directory(c.home, path === legacy.path ? LEGACY_BROWSER_RELAY_SUPPORT_RELATIVE : c.supportRelative, false, true);
|
|
138
|
+
const lockPath = join(path, ".setup.lock");
|
|
139
|
+
let handle;
|
|
140
|
+
try { handle = await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600); }
|
|
141
|
+
catch { throw new Error("setup 被锁定;另一次安装或未完成的安装需先确认,不能自动清理锁"); }
|
|
142
|
+
locks.push({ handle, path: lockPath, identity: await handle.stat() });
|
|
143
|
+
}
|
|
128
144
|
await directory(c.home, `${c.supportRelative}/extension`, true, true);
|
|
129
145
|
// Never delete unknown files or follow symlinks to make an install succeed.
|
|
130
146
|
for (const name of await readdir(c.extensionDir)) {
|
|
@@ -136,20 +152,34 @@ export async function installBrowserRelay(options = {}) {
|
|
|
136
152
|
}
|
|
137
153
|
const before = await currentStatus(c);
|
|
138
154
|
if (before.installed) return { ...before, changed: false, reloadRequired: false };
|
|
139
|
-
|
|
140
|
-
|
|
155
|
+
// This manifest name is shared with the old installation. Only replace
|
|
156
|
+
// our two known launcher coordinates, never an unrelated registration.
|
|
157
|
+
try {
|
|
158
|
+
const registered = JSON.parse(await regularFile(c.nativeHostManifest, true));
|
|
159
|
+
if (registered.name !== HOST_NAME || registered.type !== "stdio" || JSON.stringify(registered.allowed_origins) !== JSON.stringify([`chrome-extension://${EXTENSION_ID}/`]) || ![c.launcherPath, join(legacy.path, "native-host-launcher.sh")].includes(registered.path)) throw new Error("未知 Native Host 注册;不会覆盖");
|
|
160
|
+
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
161
|
+
for (const path of [c.socketPath, ...(legacy.state === "present" ? [join(legacy.path, "relay.sock")] : [])]) {
|
|
162
|
+
const connection = await probeRelay(path);
|
|
163
|
+
if (connection.state !== "not-connected") throw new Error("检测到新/旧目录中活动或无法确认的 relay;请先在 Chrome 停用旧扩展并等待 Host 退出,再运行 setup。不会中断会话");
|
|
164
|
+
}
|
|
141
165
|
// Manifest last: no claim of completion until all bytes read back exactly.
|
|
142
166
|
for (const [name, bytes] of c.files) if (name !== "manifest.json") await atomicWrite(c, join(c.extensionDir, name), bytes, 0o600);
|
|
143
167
|
await atomicWrite(c, join(c.extensionDir, "manifest.json"), c.files.get("manifest.json"), 0o600);
|
|
144
168
|
await atomicWrite(c, c.launcherPath, c.launcher, 0o700);
|
|
145
169
|
await atomicWrite(c, c.nativeHostManifest, c.nativeManifest, 0o600);
|
|
146
170
|
if (!await installationMatches(c)) throw new Error("setup readback 未通过;安装可能部分完成");
|
|
147
|
-
return { ...details(c), installed: true, changed: true, reloadRequired: true };
|
|
171
|
+
return { ...details(c), legacyDirectory: legacy, installed: true, changed: true, reloadRequired: true };
|
|
148
172
|
} finally {
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
173
|
+
let cleanupError;
|
|
174
|
+
for (const lock of locks.reverse()) {
|
|
175
|
+
try {
|
|
176
|
+
await lock.handle.close();
|
|
177
|
+
const current = await lstat(lock.path);
|
|
178
|
+
if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1 || current.dev !== lock.identity.dev || current.ino !== lock.identity.ino) throw new Error("setup lock identity 漂移;未删除未知锁");
|
|
179
|
+
await unlink(lock.path);
|
|
180
|
+
} catch (error) { cleanupError ??= error; }
|
|
181
|
+
}
|
|
182
|
+
if (cleanupError) throw cleanupError;
|
|
153
183
|
}
|
|
154
184
|
}
|
|
155
185
|
|
|
@@ -175,6 +205,8 @@ export async function runBrowserSetup(args, { stdout = process.stdout, interacti
|
|
|
175
205
|
const installation = statusOnly ? await currentStatus(c) : await installBrowserRelay();
|
|
176
206
|
stdout.write(installation.installed ? "本机准备完成(不等于 Chrome 已加载扩展)。\n" : "本机安装缺失或漂移,请运行 rotom setup browser。\n");
|
|
177
207
|
stdout.write(`扩展目录:${installation.extensionDir}\n`);
|
|
208
|
+
if (installation.legacyDirectory.state === "present") stdout.write(`旧目录已保留:${installation.legacyDirectory.path}\n请移除 Chrome 中旧路径的扩展条目,再加载上面的 rotom 目录,并重启 rotom 会话。不会复制旧文件或自动删除旧目录。\n`);
|
|
209
|
+
if (installation.legacyDirectory.state === "blocked") stdout.write("旧目录身份或权限异常,无法确认迁移状态。\n");
|
|
178
210
|
stdout.write("首次使用:打开 chrome://extensions → 开启开发者模式 → 加载已解压的扩展程序 → 选择上述目录。\n已有旧路径安装时,请手动移除旧条目并加载此固定目录;不要同时加载同一扩展 ID。\n");
|
|
179
211
|
if (installation.reloadRequired) stdout.write("本次写入了文件:请加载/重载扩展。连接检查只证明协议兼容,不证明 Chrome 已重载最新文件。\n");
|
|
180
212
|
const deadline = Date.now() + seconds * 1000;
|
|
@@ -187,7 +219,7 @@ export async function runBrowserSetup(args, { stdout = process.stdout, interacti
|
|
|
187
219
|
}
|
|
188
220
|
stdout.write(connection.state === "connected" ? (installation.installed ? "浏览器已连接,协议兼容。\n" : "发现兼容连接,但当前本机安装尚未就绪。\n") : connection.state === "incompatible" ? "扩展协议不兼容,请在 Chrome 重载或更新扩展。\n" : "尚未确认浏览器连接;完成 Chrome 操作后运行 rotom setup browser --status。\n");
|
|
189
221
|
stdout.write(`${JSON.stringify({ installation, connection })}\n`);
|
|
190
|
-
if (connection.state === "unknown" || connection.state === "blocked") return 1;
|
|
222
|
+
if (connection.state === "unknown" || connection.state === "blocked" || installation.legacyDirectory.state === "blocked") return 1;
|
|
191
223
|
return statusOnly && (!installation.installed || connection.state !== "connected") || connection.state === "incompatible" ? 3 : 0;
|
|
192
224
|
}
|
|
193
225
|
|
|
@@ -196,6 +228,9 @@ async function uninstall() {
|
|
|
196
228
|
for (const relative of [c.supportRelative, c.chromeRelative]) {
|
|
197
229
|
try { await directory(c.home, relative); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
198
230
|
}
|
|
231
|
+
try {
|
|
232
|
+
if (!(await regularFile(c.nativeHostManifest, true)).equals(c.nativeManifest)) throw new Error("Host 注册不属于当前 rotom 安装;不会删除旧版或未知注册");
|
|
233
|
+
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
199
234
|
for (const path of [c.nativeHostManifest, c.launcherPath]) {
|
|
200
235
|
try { await regularFile(path, true); await unlink(path); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
201
236
|
}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { chmodSync, existsSync, lstatSync, mkdirSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, realpathSync, unlinkSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { BROWSER_RELAY_SUPPORT_RELATIVE } from "../../runtime/product-config.mjs";
|
|
6
7
|
import { createConnection, createServer } from "node:net";
|
|
7
8
|
|
|
8
9
|
const SCHEMA_VERSION = 1;
|
|
9
10
|
const MAX_MESSAGE_BYTES = 1024 * 1024;
|
|
10
|
-
const relayDir = join(homedir(),
|
|
11
|
+
const relayDir = join(homedir(), BROWSER_RELAY_SUPPORT_RELATIVE);
|
|
11
12
|
const socketPath = join(relayDir, "relay.sock");
|
|
13
|
+
let boundSocketIdentity;
|
|
12
14
|
let nativeBuffer = Buffer.alloc(0);
|
|
13
15
|
const clients = new Set();
|
|
14
16
|
const clientBuffers = new Map();
|
|
@@ -19,13 +21,16 @@ let stdoutChain = Promise.resolve();
|
|
|
19
21
|
function ensurePrivateDirectory(path) {
|
|
20
22
|
if (!existsSync(path)) mkdirSync(path, { mode: 0o700 });
|
|
21
23
|
const info = lstatSync(path);
|
|
22
|
-
if (!info.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0) throw new Error(`browser relay private directory 无效:${path}`);
|
|
24
|
+
if (!info.isDirectory() || info.isSymbolicLink() || realpathSync(path) !== path || info.uid !== process.getuid() || (info.mode & 0o077) !== 0) throw new Error(`browser relay private directory 无效:${path}`);
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
function setupDirectories() {
|
|
26
28
|
const applicationSupport = join(homedir(), "Library", "Application Support");
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
for (const path of [homedir(), join(homedir(), "Library"), applicationSupport]) {
|
|
30
|
+
const info = lstatSync(path);
|
|
31
|
+
if (!info.isDirectory() || info.isSymbolicLink() || realpathSync(path) !== path || info.uid !== process.getuid() || (info.mode & 0o022) !== 0) throw new Error("browser relay parent directory 不可信");
|
|
32
|
+
}
|
|
33
|
+
ensurePrivateDirectory(dirname(relayDir));
|
|
29
34
|
ensurePrivateDirectory(relayDir);
|
|
30
35
|
}
|
|
31
36
|
|
|
@@ -125,7 +130,10 @@ function cleanupClient(client) {
|
|
|
125
130
|
|
|
126
131
|
function cleanupSocket() {
|
|
127
132
|
try {
|
|
128
|
-
if (
|
|
133
|
+
if (!boundSocketIdentity) return;
|
|
134
|
+
const info = lstatSync(socketPath);
|
|
135
|
+
if (info.isSocket() && !info.isSymbolicLink() && info.dev === boundSocketIdentity.dev && info.ino === boundSocketIdentity.ino) unlinkSync(socketPath);
|
|
136
|
+
boundSocketIdentity = undefined;
|
|
129
137
|
} catch { /* process exit cleanup is best effort; next start validates stale socket */ }
|
|
130
138
|
}
|
|
131
139
|
|
|
@@ -142,9 +150,9 @@ async function listen(server) {
|
|
|
142
150
|
async function socketIsLive() {
|
|
143
151
|
return new Promise((resolve) => {
|
|
144
152
|
const client = createConnection(socketPath);
|
|
145
|
-
const timer = setTimeout(() => { client.destroy(); resolve(
|
|
146
|
-
client.once("connect", () => { clearTimeout(timer); client.destroy(); resolve(
|
|
147
|
-
client.once("error", () => { clearTimeout(timer); resolve(
|
|
153
|
+
const timer = setTimeout(() => { client.destroy(); resolve("unknown"); }, 500);
|
|
154
|
+
client.once("connect", () => { clearTimeout(timer); client.destroy(); resolve("live"); });
|
|
155
|
+
client.once("error", (error) => { clearTimeout(timer); resolve(error.code === "ECONNREFUSED" || error.code === "ENOENT" ? "stale" : "unknown"); });
|
|
148
156
|
});
|
|
149
157
|
}
|
|
150
158
|
|
|
@@ -165,12 +173,16 @@ async function main() {
|
|
|
165
173
|
await listen(server);
|
|
166
174
|
} catch (error) {
|
|
167
175
|
if (error?.code !== "EADDRINUSE") throw error;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (
|
|
176
|
+
const before = lstatSync(socketPath);
|
|
177
|
+
if (!before.isSocket() || before.isSymbolicLink() || before.uid !== process.getuid() || (before.mode & 0o077) !== 0) throw new Error("browser relay existing socket 不可信");
|
|
178
|
+
if (await socketIsLive() !== "stale") throw new Error("另一个 browser relay native host 已运行或状态未知");
|
|
179
|
+
const after = lstatSync(socketPath);
|
|
180
|
+
if (before.dev !== after.dev || before.ino !== after.ino) throw new Error("browser relay stale socket identity 漂移");
|
|
171
181
|
unlinkSync(socketPath);
|
|
172
182
|
await listen(server);
|
|
173
183
|
}
|
|
184
|
+
// A failed second host must never unlink the first host's socket on exit.
|
|
185
|
+
boundSocketIdentity = lstatSync(socketPath);
|
|
174
186
|
server.on("error", () => process.exit(73));
|
|
175
187
|
chmodSync(socketPath, 0o600);
|
|
176
188
|
process.stdin.on("data", (chunk) => {
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bingjiang0611/rotom",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.2",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bingjiang0611/rotom",
|
|
9
|
-
"version": "0.1.0-alpha.
|
|
9
|
+
"version": "0.1.0-alpha.2",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"os": [
|
|
12
12
|
"darwin",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bingjiang0611/rotom",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.2",
|
|
4
4
|
"publishConfig": { "access": "public", "registry": "https://registry.npmjs.org", "tag": "alpha" },
|
|
5
5
|
"description": "Personal coding agent built on Pi, with explicit tools and runtime contracts",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export const DISTRIBUTION_PACKAGE_NAME = "@bingjiang0611/rotom";
|
|
2
|
+
export const BROWSER_RELAY_SUPPORT_RELATIVE = "Library/Application Support/rotom/browser-relay";
|
|
3
|
+
// Migration detection only; new sessions never fall back to the old socket.
|
|
4
|
+
export const LEGACY_BROWSER_RELAY_SUPPORT_RELATIVE = "Library/Application Support/pi-agent/browser-relay";
|
|
2
5
|
export const VERIFIED_PI_PACKAGE = "@earendil-works/pi-coding-agent";
|
|
3
6
|
export const DISTRIBUTION_PI_VERSION = "0.85.1";
|
|
4
7
|
export const DISTRIBUTION_PI_INTEGRITY = "sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ==";
|