@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 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/pi-agent/browser-relay/extension`。
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
- if [ "$load_claude_project_skills" -eq 1 ]; then
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(), "Library", "Application Support", "pi-agent", "browser-relay", "relay.sock");
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 重载 Pi Agent Browser Relay");
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 重载 Pi Agent Browser Relay", { cause: failure });
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": "Pi Agent Browser Relay",
4
- "version": "0.9.0",
5
- "description": "Connects Pi Agent to Chrome tabs with direct trusted-local inspection and interaction.",
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": "Pi Agent Browser Relay"
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 = "Library/Application Support/pi-agent/browser-relay";
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: "Pi Agent Browser Relay Native Messaging Host", path: launcherPath, type: "stdio", allowed_origins: [`chrome-extension://${EXTENSION_ID}/`] }, null, 2)}\n`);
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
- try { return { ...details(c), installed: await installationMatches(c) }; }
94
- catch (error) { return { ...details(c), installed: false, reason: error.code === "ENOENT" ? "not-installed-or-partial" : "identity-or-permission-mismatch" }; }
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 directory(c.home, "Library/Application Support/pi-agent", true, true);
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 lockPath = join(c.supportDir, ".setup.lock");
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
- const connection = await probeRelay(c.socketPath);
140
- if (connection.state !== "not-connected") throw new Error("检测到活动或无法确认的 relay;请先在 Chrome 停用扩展并等待 Host 退出,再运行 setup。不会中断会话");
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
- await lock.close();
150
- const current = await lstat(lockPath);
151
- if (!current.isFile() || current.isSymbolicLink() || current.dev !== lockIdentity.dev || current.ino !== lockIdentity.ino) throw new Error("setup lock identity 漂移;未删除未知锁");
152
- await unlink(lockPath);
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(), "Library", "Application Support", "pi-agent", "browser-relay");
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
- if (!existsSync(applicationSupport) || !lstatSync(applicationSupport).isDirectory()) throw new Error("Application Support 目录不存在");
28
- ensurePrivateDirectory(join(applicationSupport, "pi-agent"));
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 (existsSync(socketPath) && lstatSync(socketPath).isSocket()) unlinkSync(socketPath);
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(false); }, 500);
146
- client.once("connect", () => { clearTimeout(timer); client.destroy(); resolve(true); });
147
- client.once("error", () => { clearTimeout(timer); resolve(false); });
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
- if (await socketIsLive()) throw new Error("另一个 browser relay native host 已运行");
169
- const info = lstatSync(socketPath);
170
- if (!info.isSocket() || info.isSymbolicLink()) throw new Error("browser relay stale path 不是 socket");
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) => {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bingjiang0611/rotom",
3
- "version": "0.1.0-alpha.0",
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.0",
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.0",
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==";