@foxden-app/foxclaw 0.7.2 → 0.10.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.
Files changed (71) hide show
  1. package/.env.example +14 -0
  2. package/CHANGELOG.md +129 -0
  3. package/README.md +22 -2
  4. package/README_EN.md +22 -2
  5. package/dist/antigravity/adapter.d.ts +17 -0
  6. package/dist/antigravity/adapter.js +256 -0
  7. package/dist/antigravity/auth.d.ts +121 -0
  8. package/dist/antigravity/auth.js +896 -0
  9. package/dist/antigravity/client.d.ts +32 -0
  10. package/dist/antigravity/client.js +238 -0
  11. package/dist/antigravity/controller.d.ts +96 -0
  12. package/dist/antigravity/controller.js +1922 -0
  13. package/dist/antigravity/conversations.d.ts +29 -0
  14. package/dist/antigravity/conversations.js +289 -0
  15. package/dist/antigravity/events.d.ts +101 -0
  16. package/dist/antigravity/events.js +114 -0
  17. package/dist/antigravity/runtime.d.ts +47 -0
  18. package/dist/antigravity/runtime.js +82 -0
  19. package/dist/auth/cross_node_sync.js +2 -2
  20. package/dist/channels/telegram/telegram_channel_adapter.d.ts +10 -3
  21. package/dist/channels/telegram/telegram_channel_adapter.js +2 -1
  22. package/dist/channels/weixin/weixin_channel_adapter.d.ts +5 -1
  23. package/dist/codex_app/adapter.d.ts +20 -0
  24. package/dist/codex_app/adapter.js +213 -0
  25. package/dist/codex_app/client.d.ts +2 -1
  26. package/dist/codex_app/client.js +47 -4
  27. package/dist/codex_app/force_takeover.d.ts +12 -0
  28. package/dist/codex_app/force_takeover.js +27 -0
  29. package/dist/config.d.ts +12 -0
  30. package/dist/config.js +42 -0
  31. package/dist/controller/controller.d.ts +15 -5
  32. package/dist/controller/controller.js +252 -20
  33. package/dist/core/attachments.d.ts +11 -0
  34. package/dist/core/attachments.js +56 -0
  35. package/dist/core/engine_spi.d.ts +76 -0
  36. package/dist/core/engine_spi.js +1 -0
  37. package/dist/core/orchestrator.d.ts +101 -0
  38. package/dist/core/orchestrator.js +1404 -0
  39. package/dist/core/stream_preview.d.ts +29 -0
  40. package/dist/core/stream_preview.js +146 -0
  41. package/dist/core/turn_queue.d.ts +19 -0
  42. package/dist/core/turn_queue.js +41 -0
  43. package/dist/i18n.d.ts +36 -6
  44. package/dist/i18n.js +81 -6
  45. package/dist/main.js +190 -20
  46. package/dist/opencode/adapter.d.ts +11 -0
  47. package/dist/opencode/adapter.js +168 -0
  48. package/dist/store/database.d.ts +41 -0
  49. package/dist/store/database.js +242 -43
  50. package/dist/store/token_usage.d.ts +23 -0
  51. package/dist/store/token_usage.js +61 -0
  52. package/dist/telegram/api.js +34 -1
  53. package/dist/telegram/bot_home.d.ts +3 -0
  54. package/dist/telegram/bot_home.js +107 -0
  55. package/dist/telegram/gateway.d.ts +5 -0
  56. package/dist/telegram/gateway.js +21 -0
  57. package/dist/types.d.ts +1 -0
  58. package/dist/update.d.ts +6 -0
  59. package/dist/update.js +78 -6
  60. package/dist/voice/target.js +2 -1
  61. package/dist/voice/tts.js +4 -2
  62. package/docs/devlogs//346/212/200/346/234/257/351/200/232/350/256/257/02./347/273/237/344/270/200/351/200/232/351/201/223/350/260/203/345/272/246/345/231/250/344/270/216/345/217/257/346/217/222/346/213/224/345/274/225/346/223/216SPI/357/274/232FoxClaw/344/273/216/345/215/225/344/275/223/350/265/260/345/220/221/345/244/232Agent/345/272/225/345/272/247.md +192 -0
  63. package/docs/devlogs//347/254/254/344/270/200/344/272/272/347/247/260/347/211/210/02./347/257/207/344/272/214_/345/275/223/344/270/200/344/270/252/346/241/245/346/216/245/345/231/250/345/274/200/345/247/213/345/255/246/344/274/232/345/220/254/346/207/202/346/211/200/346/234/211Agent.md +45 -0
  64. package/docs/user-manual.md +11 -1
  65. package/docs/zh/2026-09-05-bot-home-migration.md +26 -0
  66. package/docs/zh/2026-09-05-reliability-review.md +53 -0
  67. package/docs/zh/troubleshooting.md +12 -0
  68. package/docs/zh/user-manual.md +13 -1
  69. package/package.json +2 -2
  70. package/scripts/force-takeover.py +118 -0
  71. package/scripts/force-takeover.test.py +122 -0
@@ -357,6 +357,14 @@ Watch mode mirrors live turn progress and approval requests. It still does not t
357
357
 
358
358
  Older Codex versions do not expose this queue API. FoxClaw reports the required upgrade instead of falling back to writer takeover or session-file mutation. Use `/unwatch` first only when you want Telegram to start a separate turn itself.
359
359
 
360
+ ### `/takeover --force <message>`: hand off a local CLI writer
361
+
362
+ For `already has an active writer`, send `/takeover --force continue the task`, verify the thread, PID and working directory, then confirm or cancel. Confirmation is scoped to the requesting Telegram user/chat and expires after 60 seconds. Ordinary `/watch`, `/queue`, and `/takeover` never automatically stop an external CLI.
363
+
364
+ Requires Linux/WSL, Python 3.9+ and kernel pidfd support. Only an interactive, same-OS-user Codex CLI holding the target bot home's thread lock is eligible. Servers, remote clients, bridge ancestors and processes holding other thread locks are refused. After revalidating process/lock identity, the helper sends SIGTERM via pidfd, escalating to SIGKILL after 5 seconds. Only after verifying lock release does the bridge resume the original thread and submit the prompt. It never deletes locks, edits session files, or retries submissions automatically.
365
+
366
+ Unfinished work can be interrupted, child commands may remain running, and file edits are not rolled back. Identity changes, retained locks and resume failures are reported without submitting a new prompt. The bridge's old pending queue is cancelled only after acquiring the writer; the CLI cross-client queue is not cleared. Multi-thread CLI processes require manual handoff.
367
+
360
368
  ## 6. Codex Login And Auth Rotation
361
369
 
362
370
  This is a key FoxClaw feature. Codex auth is usually stored at `~/.codex/auth.json`. FoxClaw stores multiple accounts as candidate files and switches which candidate the active `auth.json` points to. In `TG_BOT_TOKENS` mode, each bot has an isolated Codex home, app-server, and current candidate by default, so bots can run and switch accounts independently; isolated Telegram runtimes force file-backed credential storage. Validated login/refresh credentials are safely mirrored between bot homes, but isolated sessions are never shared.
@@ -365,7 +373,9 @@ To keep one Telegram bot interoperable with terminal Codex sessions, put the sam
365
373
 
366
374
  ### 6.1 File Format
367
375
 
368
- In single-bot compatibility mode, candidate files live in the Codex auth directory, usually `~/.codex/`. If `CODEX_AUTH_DIR` is set, FoxClaw uses that directory. Multi-bot mode treats that directory as its candidate source and stores isolated bot copies under `~/.foxclaw/codex/telegram/bot<id>/home/`. A default/shared-terminal bot does not get an isolated copy; it uses the default auth directory directly.
376
+ In single-bot compatibility mode, candidate files live in the Codex auth directory, usually `~/.codex/`. If `CODEX_AUTH_DIR` is set, FoxClaw uses that directory. Multi-bot mode stores isolated homes under `~/.foxclaw/codex/telegram/@TelegramUsername/home/`. Shared-terminal bots get a named home link to their existing Codex home, preserving terminal interoperability.
377
+
378
+ Startup resolves usernames in parallel. Existing numeric directories move to the named location with compatibility links retained; username changes also retain old paths. Offline restarts reuse the previous name. A first offline startup uses `bot<id>` until a later startup can resolve its username. Keep sessions idle during migration. Name collisions fail explicitly without merging or overwriting data. The parent `.foxclaw-bot.json` stores only the stable bot ID for media routing, never a token. Keep it and the compatibility links. Database bindings, logs, and runtime identities still use numeric bot IDs. Project working directories remain controlled by `DEFAULT_CWD` or `/new <path>`.
369
379
 
370
380
  Recommended layout:
371
381
 
@@ -0,0 +1,26 @@
1
+ # Telegram 名称目录迁移验收
2
+
3
+ 功能随 `0.7.3` 发布,预览版已安装到 16P 和 T490。本次迁移针对 T490 的 6 个多 bot runtime;16P 单 bot 兼容模式继续使用原默认 home。
4
+
5
+ T490 的实际运行目录如下。
6
+
7
+ | Telegram bot | CODEX_HOME |
8
+ | --- | --- |
9
+ | @WuguiAI_Bot | /home/wuya/.foxclaw/codex/telegram/@WuguiAI_Bot/home |
10
+ | @WuguiAI2_Bot | /home/wuya/.foxclaw/codex/telegram/@WuguiAI2_Bot/home |
11
+ | @WuguiAI3_Bot | /home/wuya/.foxclaw/codex/telegram/@WuguiAI3_Bot/home |
12
+ | @WuguiAI4_Bot | /home/wuya/.foxclaw/codex/telegram/@WuguiAI4_Bot/home |
13
+ | @WuguiAI5_Bot | /home/wuya/.foxclaw/codex/telegram/@WuguiAI5_Bot/home |
14
+ | @walma10bot | /home/wuya/.foxclaw/codex/telegram/@walma10bot/home |
15
+
16
+ `@WuguiAI_Bot/home` 链接到 `/home/wuya/.codex-gjzn`,保留原终端共享数据。其他 5 个 bot 的原数字目录已重命名,数字路径作为兼容链接保留。项目工作目录、数据库绑定、日志及运行状态 ID 没有改名。
17
+
18
+ 迁移前确认桥内没有活动轮次、审批、输入请求或排队任务;逐个查询真实 app-server 的 `thread/loaded/list`,6 个服务均为空,然后停止桥。迁移程序逐文件计算 SHA-256,并比较目录/文件权限及符号链接目标。5 个独立目录分别校验 351、351、345、345、8420 个条目,合计 9812 个,迁移前后完全一致。共享 home 只验证链接与原目录解析到相同位置,没有搬移共享数据。
19
+
20
+ 新版本启动后,status 中的 6 个 home 均为上述名称路径,6 个子进程的真实 `CODEX_HOME` 环境变量也一致;数字旧路径均解析到相同数据。6 个 bot connected=true,systemd active/running、NRestarts=0、ExecMainStatus=0。媒体路由从名称目录身份记录解析到原数字 bot ID,6 个均正确。
21
+
22
+ 自动化验证为 410 项全量测试通过,typecheck、lint、build 和 diff check 通过。测试覆盖新目录、原目录迁移、用户名变化、离线启动、共享 home、同名冲突、非法路径、权限/链接保留及媒体目标路由。
23
+
24
+ 以后按 `.env.example` 配置 `TG_BOT_TOKENS` 即默认启用名称目录。用户名由 Telegram getMe 获取;已有目录断网时继续使用记录的名称。首次离线启动暂用数字 ID,后续启动获取用户名后再迁移。名称冲突明确报错,不自动合并目录。`.foxclaw-bot.json` 只记录 bot ID,不保存凭据。
25
+
26
+ 检查还发现一个原有授权问题:共享 home 中的 `auth.json` 指向 `/home/wuya/.codex/auth.json_GamsGo2`,该目标不存在。原目录本身即可复现断链;此次目录迁移保持该链接不变。桥连接正常不代表这个授权已可调用模型,本次没有为目录改名另行切换账号。
@@ -0,0 +1,53 @@
1
+ # 2026-09-05 桥可靠性修复与现场验收
2
+
3
+ ## 现场结论
4
+
5
+ - 16P 的 Codex 为 0.153.4,查询 npm 得到相同版本。真实 app-server 模型列表包含 gpt-6-astra。T490 当前为 0.153.0,也支持 `codex resume --remote`。
6
+ - 检查时最近两天日志有 75 次 Telegram polling 错误与 29 次 Codex WebSocket 警告。TLS 断连、明确的 `401 token_revoked`、本地连接状态必须分别判断。
7
+ - 旧 RPC 没有截止时间;`/takeover` 无限等待 completion 会一直占用 scope 队列。登录取消先等待 RPC,失败时不清理状态,且完成通知可能抢先触发重复清理。
8
+ - 旧 app-server attach 失败会清除仍存活服务的记录,可能另起服务并留下写入锁。外部观察轮次也可能被重连恢复错误地 resume。
9
+
10
+ ## GamsGo2024 同步
11
+
12
+ 时间为北京时间,证据来自 16P 与 T490 的 systemd 日志及只读 SQLite 查询。
13
+
14
+ - 12:29:45,16P 启动集群审计,request id 为 `6bce5971-d26f-497f-9dbb-bd906fae86a6`。
15
+ - T490 收到审计后在 12:30:02 返回报告。
16
+ - 12:40:07,T490 的 `auth.mirror.remote_imported` 与 `auth.sync.imported` 明确记录 `auth.json_GamsGo2024` 已导入。
17
+ - 12:40:18 的 “local candidate is already newer or equal” 是后续重复推送被跳过,不能据此推断前一次同步失败。
18
+ - 最终只读查询显示全局和 6 个 runtime 均为 `active`,未禁用。旧 Telegram 面板保留的是候选快照。
19
+ - 16P 原 peer 列表同时含 `@WuguiAI_Bot` 与 `@walma10bot`。两者在 T490 同一桥进程中,统一由第一个 bot 回复,因此后者被视为未回应,导致两轮各 5 分钟的等待。
20
+ - 16P 本地配置已去掉重复的 `@walma10bot` peer,保留 `@WuguiAI_Bot` 和 `@walte2026_bot`。T490 仍沿用 `workstation-GJZN` 节点名,本次未重命名或迁移账号。
21
+
22
+ ## 实现
23
+
24
+ - Codex RPC 30 秒超时,清理 pending,不自动重发结果未知的任务;WebSocket 握手有截止时间。
25
+ - Telegram 请求增加总时限与响应流错误处理,避免持续零碎数据或半断连接拖住请求。
26
+ - 活动会话断连与恢复失败有明确提示;保留存活但无法连接的 app-server 记录,重连共用启动锁。
27
+ - `/takeover` 中断确认等待 30 秒后退出,不会延迟启动替换任务。
28
+ - `/status`、`/cli`、`/interrupt`、`/login_cancel` 及只读 auth sync 查询可绕过普通消息队列,仍经过原有权限和目标检查。
29
+ - 设备登录、auth add、auth repair 提供取消按钮;本地状态在等待取消响应前认领清理,旧按钮及其他会话无法取消当前登录。
30
+ - 新增 `foxclaw resume [thread-id] [--bot-id <bot-id>]` 及 Telegram `/cli`,进入桥正在使用的同一 app-server,避免另起 writer。
31
+ - 重连恢复跳过独立 CLI 的只读观察轮次。独立 CLI 的 `/watch` 不增加强抢或删锁行为。
32
+ - 新增显式 `/takeover --force <消息>`。仅可信 Telegram 用户可在 60 秒内确认;实现按目标 thread 的真实 flock 定位同用户交互式 CLI,通过 pidfd 防 PID 复用,拒绝 app-server、远程客户端、桥祖先进程及持有多个 thread 的进程。先 SIGTERM,5 秒后仍存活才 SIGKILL;只有锁已释放且原 thread 恢复成功后才提交消息。不会删除锁、修改 session 文件或自动重试结果未知的提交。
33
+ - 点击旧问号、登录修复或删除按钮前重读授权状态,已恢复时刷新面板。
34
+ - 同步文案明确“已发送、远端导入未确认”;状态更新未满足身份/时间约束时记录 `skipped`。
35
+
36
+ ## 验证与部署
37
+
38
+ - 稳定版全量测试共 421 项:420 项通过,1 项因当前进程环境没有 OpenCode CLI 按既有条件跳过;此前带 OpenCode 环境的预览验收为 421 项全部通过。另有 8 项隔离进程测试,覆盖真实 flock、pidfd、SIGTERM/SIGKILL、PID 身份变化和不安全进程拒绝。typecheck、lint、build、diff check 通过。
39
+ - 新增回归覆盖 RPC 超时后的迟到响应、不重发、发送失败清理、存活服务记录保护、登录取消失败/竞态/旧按钮、中断超时不延迟发送、观察轮次不获取 writer、旧授权面板刷新、审计状态跳过、Telegram 流式拖延总超时、多 bot CLI 路由。
40
+ - 在独立临时 CODEX_HOME 中启动真实 Codex 0.153.4,两个客户端连接同一个 app-server 并 resume 同一个已有记录的 thread,验证同线程、同服务。测试不使用用户授权,不调用模型完成任务。空线程在产生记录前不能 resume。
41
+ - 预览安装包最终为 `/tmp/foxden-app-foxclaw-0.7.3-dev.3.tgz`。16P 使用 npm 安装,T490 使用其 pnpm 安装并更新 systemd 到实际包路径。
42
+ - 重启前确认两端桥内无活动任务,并通过只读 `thread/loaded/list` 确认所有受管 app-server 均无加载线程。
43
+ - 16P 与 T490 实际 runtime userAgent 均包含 `foxclaw; 0.7.3-dev.3`;两端 systemd active/running、NRestarts=0、ExecMainStatus=0。T490 六个 bot 均 connected=true。
44
+ - 真实强制接管验收中,目标 thread `01a06fd3-668d-7c81-96e1-d6394c2cf782`、PID `289937`、工作目录 `/home/wuya/git/foxclaw` 经用户确认后停止;日志记录 `codex.external_writer_stopped`。随后同一 thread 由桥的 app-server 持锁并启动新 turn,未删除锁文件。
45
+ - 调用真实 Telegram `getMyCommands`:16P 中英文菜单、T490 同步联系人与 walma10bot 中文菜单均包含 `login_cancel` 和 `cli`。
46
+
47
+ ## 剩余边界
48
+
49
+ - 本报告先记录本地预览与真实接管验收;正式 npm 和 GitHub Release 状态以发布后的 registry/workflow 验证为准。
50
+ - 没有代用户执行真实登录和 Telegram 按钮点击;按钮行为由回归测试验证,菜单已通过真实 Telegram API 验收。
51
+ - 去重后未再次触发整个集群的安全同步;原同步导入结果和去重后的运行配置已核实。
52
+ - 网络完全断开时无法即时发送 Telegram 错误提示,可使用本机 `foxclaw resume`。底层服务完全失联时需检查 `foxclaw status`;重启应确认其他会话空闲。
53
+ - 其他候选仍有过期或明确 revoked 的错误,不能视为本次 GamsGo2024 修复失败,也不能通过无条件覆盖或反复刷新解决。
@@ -21,6 +21,18 @@ launchctl print "gui/$(id -u)/app.foxden.foxclaw"
21
21
  tail -f ~/.foxclaw/logs/launchd.err.log ~/.foxclaw/logs/service.log
22
22
  ```
23
23
 
24
+ ## Telegram 无响应、登录取消与 CLI 恢复
25
+
26
+ Codex RPC 等待上限为 30 秒;超时代表结果未确认,不会自动重发原任务。先用 `/status` 检查。`/status`、`/cli`、`/interrupt`、`/login_cancel` 和 `/auth sync status` 不会排在普通消息的等待队列后面。网络完全不可用时 Telegram 无法送达错误提示,改用本机终端。
27
+
28
+ 在运行桥的机器上执行 `foxclaw resume <thread-id>`,CLI 会连接桥正在使用的 Codex app-server。省略 thread-id 会打开选择器。多服务时用 `--bot-id <bot-id>` 指定 `foxclaw status --json` 中的 bot。Telegram 的 `/cli` 也能显示当前线程的直接连接命令。该入口已在 Codex 0.153.4 的 CLI 参数中确认支持。
29
+
30
+ 普通 `codex resume` 会启动另一个服务,可能遭遇 `already has an active writer`。同一服务连接可避免争用写入锁。`/watch` 对独立 CLI 仍是只读观察;不要删除 writer lock 文件来强抢线程。`/takeover` 等待中断确认超过 30 秒就退出,不会在稍后继续发送替换任务。
31
+
32
+ 设备登录、新增授权和修复登录均提供取消按钮,也可输入 `/login_cancel`。取消按钮只作用于对应会话当前的登录。服务端取消失败时会明确提示“本地流程已退出、远端取消未确认”,不要再使用旧验证码。
33
+
34
+ 安全同步显示“已发送”并不等于对端已导入。每个桥进程只配置一个同步联系人;同一进程里的两个 bot 不应当作两个 peer。多 bot 当前使用配置列表中的第一个 bot 回复同步请求。节点未回应时,自检和复核各自最多等待 5 分钟。查看 `/auth sync events <候选名>` 与对端 `/auth`;旧问号按钮会重新读取修复后的状态。
35
+
24
36
  ## Doctor 检查失败
25
37
 
26
38
  | 现象 | 含义 | 处理方式 |
@@ -357,6 +357,14 @@ FoxClaw 的聊天是“绑定线程”的。你在手机上打开某个 Codex
357
357
 
358
358
  旧版 Codex 没有这个队列接口,FoxClaw 会明确提示升级,不会退回到抢占 writer 或改写 session 文件。要让 Telegram 自己另行启动 turn,仍需先 `/unwatch`。
359
359
 
360
+ ### `/takeover --force <消息>`:从本机 CLI 强制交接
361
+
362
+ 遇到 `already has an active writer` 时,可发送 `/takeover --force 继续处理`,核对 thread、PID 和工作目录,再点击“确认强制接管”;也可点“取消”。确认仅 60 秒有效,并绑定发起用户和聊天。普通 `/watch`、`/queue` 和 `/takeover` 不会自动停止外部 CLI。
363
+
364
+ 仅支持 Linux/WSL,需 `python3` 3.9+ 和内核 pidfd 支持。只接受当前 bot 的 Codex home 中、同一系统用户的交互式 Codex CLI。拒绝 app-server、远程客户端、桥的祖先进程,以及同时持有其他 thread 锁的进程。确认后重新核实进程启动时间及锁身份,通过 pidfd 先发 SIGTERM,5 秒不退出再发 SIGKILL;检查锁释放后,才恢复原 thread 并提交指定消息。不会删除锁或修改 session 文件,不会自动重试提交。
365
+
366
+ 强停可能打断未完成任务,已启动的子命令可能继续运行,文件修改不会回滚。身份变化、锁未释放或恢复失败都会明确报错,不投递新任务。桥自己的旧待执行队列只在成功取得写入权后取消;原 CLI 的跨客户端队列不会被此功能清空。多线程 CLI 请在终端手动交接。
367
+
360
368
  ## 6. Codex 登录和 auth 轮转
361
369
 
362
370
  这是 FoxClaw 的特色功能。Codex 的登录状态通常保存在 `~/.codex/auth.json`。FoxClaw 把多个账号保存成候选文件,并通过切换 `auth.json` 指向哪个候选来换号。启用 `TG_BOT_TOKENS` 多 bot 模式后,默认每个 bot 使用独立 Codex home、独立 app-server 和独立当前候选,因此可以并行运行、单独切号;隔离 Telegram runtime 会强制使用文件凭据存储。已验证的登录/刷新凭据会安全镜像到其他 bot home,但不会共享 session。
@@ -365,7 +373,11 @@ FoxClaw 的聊天是“绑定线程”的。你在手机上打开某个 Codex
365
373
 
366
374
  ### 6.1 文件格式
367
375
 
368
- 单 bot 兼容模式的候选文件放在 Codex auth 目录,默认是 `~/.codex/`。如果你设置了 `CODEX_AUTH_DIR`,则使用那个目录。多 bot 模式以这个目录作为候选源,并在 `~/.foxclaw/codex/telegram/bot<id>/home/` 下为隔离 bot 保存副本。默认/终端共享 bot 不创建隔离副本,而是直接使用这个默认 auth 目录。
376
+ 单 bot 兼容模式的候选文件放在 Codex auth 目录,默认是 `~/.codex/`。如果你设置了 `CODEX_AUTH_DIR`,则使用那个目录。多 bot 模式以这个目录作为候选源,并在 `~/.foxclaw/codex/telegram/@Telegram用户名/home/` 下为隔离 bot 保存副本,例如 `@WuguiAI_Bot/home/`。默认/终端共享 bot 的名称目录通过链接指向原来的 Codex home,保留终端互通能力。
377
+
378
+ 启动时并行读取 Telegram 的真实用户名。已有 `bot<id>` 目录会迁移到名称目录,旧路径保留兼容链接;更改用户名后,下次启动会更新目录名称并保留旧名称链接。首次启动无法联网时临时使用 `bot<id>`,之后启动取到用户名再迁移;已有名称在断网重启时继续使用。迁移前请让相关会话空闲。遇到同名冲突会明确停止,不合并或覆盖目录。
379
+
380
+ 名称目录中的 `.foxclaw-bot.json` 只保存稳定的数字 bot ID,不保存 token,供媒体发送判断目标账号。不要删除该文件或旧路径链接。数据库绑定、运行日志及服务状态仍用稳定 bot ID 标识;项目工作目录仍由 `DEFAULT_CWD` 或 `/new <目录>` 决定。
369
381
 
370
382
  推荐命名:
371
383
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.7.2",
4
- "description": "Foxden local execution claw for controlling Codex and OpenCode from trusted chat interfaces.",
3
+ "version": "0.10.0",
4
+ "description": "Foxden local execution claw for controlling Codex, OpenCode, and Antigravity from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
7
7
  "bin": {
@@ -0,0 +1,118 @@
1
+ """Linux-only, fail-closed handoff of one local interactive Codex writer.
2
+
3
+ No lock files are removed. pidfd keeps signals bound to the inspected process,
4
+ even if its numeric PID is reused. stdout is a small JSON protocol.
5
+ """
6
+ import fcntl
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import re
11
+ import select
12
+ import signal
13
+ import sys
14
+
15
+
16
+ def lock_owners(lock_path):
17
+ st = lock_path.stat()
18
+ key = (os.major(st.st_dev), os.minor(st.st_dev), st.st_ino)
19
+ owners = []
20
+ for line in Path('/proc/locks').read_text().splitlines():
21
+ fields = line.split()
22
+ if len(fields) != 8 or fields[1:4] != ['FLOCK', 'ADVISORY', 'WRITE']:
23
+ continue
24
+ major, minor, inode = fields[5].split(':')
25
+ if (int(major, 16), int(minor, 16), int(inode)) == key:
26
+ owners.append(int(fields[4]))
27
+ return owners
28
+
29
+
30
+ def process_stat(pid):
31
+ # comm can contain spaces and parentheses; fields after the final ')' are stable.
32
+ return Path(f'/proc/{pid}/stat').read_text().rsplit(')', 1)[1].split()
33
+
34
+
35
+ def inspect_writer(home, thread_id):
36
+ if sys.platform != 'linux' or not hasattr(os, 'pidfd_open') or not hasattr(signal, 'pidfd_send_signal'):
37
+ raise RuntimeError('Requires Linux/WSL with Python 3.9+ and pidfd support')
38
+ if not re.fullmatch(r'[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}', thread_id):
39
+ raise RuntimeError('Invalid thread ID')
40
+ lock_path = Path(home).resolve() / 'thread-writer-locks' / f'{thread_id}.lock'
41
+ owners = lock_owners(lock_path)
42
+ if len(owners) != 1 or owners[0] <= 1:
43
+ raise RuntimeError('No unique local writer; nothing was stopped')
44
+ pid = owners[0]
45
+ proc = Path(f'/proc/{pid}')
46
+ stat = process_stat(pid)
47
+ if proc.stat().st_uid != os.getuid():
48
+ raise RuntimeError('Writer belongs to a different OS user')
49
+ exe = os.readlink(proc / 'exe')
50
+ argv = (proc / 'cmdline').read_bytes().split(b'\0')
51
+ if Path(exe).name != 'codex' or int(stat[4]) == 0:
52
+ raise RuntimeError('Writer is not an interactive Codex CLI')
53
+ if any(arg in (b'app-server', b'exec', b'e', b'review', b'mcp-server', b'--remote') or arg.startswith(b'--remote=') for arg in argv[1:]):
54
+ raise RuntimeError('Refusing to stop a server, noninteractive CLI, or remote client')
55
+ ancestor = os.getpid()
56
+ while ancestor > 1:
57
+ if ancestor == pid:
58
+ raise RuntimeError('Refusing to stop an ancestor of this bridge')
59
+ ancestor = int(process_stat(ancestor)[1])
60
+ # A single CLI may own subagent threads: stopping it would affect them too.
61
+ held_threads = set()
62
+ for fd in (proc / 'fd').iterdir():
63
+ try:
64
+ target = os.readlink(fd)
65
+ if '/thread-writer-locks/' in target and not target.endswith('/.coordination.lock'):
66
+ held_threads.add(target)
67
+ except FileNotFoundError:
68
+ continue
69
+ if held_threads != {str(lock_path)}:
70
+ raise RuntimeError('Writer has additional or unidentifiable thread locks; manual handoff required')
71
+ st = lock_path.stat()
72
+ identity = {
73
+ 'pid': pid, 'startTime': stat[19], 'exe': exe,
74
+ 'lockDevice': str(st.st_dev), 'lockInode': str(st.st_ino),
75
+ 'cwd': os.readlink(proc / 'cwd'),
76
+ }
77
+ if lock_owners(lock_path) != [pid] or process_stat(pid)[19] != identity['startTime']:
78
+ raise RuntimeError('Writer changed during inspection; request confirmation again')
79
+ return identity
80
+
81
+
82
+ def stop_writer(home, thread_id, expected):
83
+ # Open first, then revalidate. A recycled PID can never receive our signal.
84
+ fd = os.pidfd_open(expected['pid'])
85
+ try:
86
+ if inspect_writer(home, thread_id) != expected:
87
+ raise RuntimeError('Writer changed since confirmation; nothing was stopped')
88
+ poller = select.poll()
89
+ poller.register(fd, select.POLLIN)
90
+ signal.pidfd_send_signal(fd, signal.SIGTERM)
91
+ if not poller.poll(5000):
92
+ # Recheck scope before escalation, retaining the original pidfd.
93
+ if inspect_writer(home, thread_id) != expected:
94
+ raise RuntimeError('Writer changed after SIGTERM; refused SIGKILL')
95
+ signal.pidfd_send_signal(fd, signal.SIGKILL)
96
+ if not poller.poll(3000):
97
+ raise RuntimeError('CLI exit timed out; handoff not completed')
98
+ finally:
99
+ os.close(fd)
100
+ lock_path = Path(home).resolve() / 'thread-writer-locks' / f'{thread_id}.lock'
101
+ # Actual flock check, not just PID disappearance. Never unlink the lock.
102
+ try:
103
+ with lock_path.open('rb') as lock:
104
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
105
+ except FileNotFoundError:
106
+ pass # A clean CLI exit removed its own lock.
107
+ except BlockingIOError:
108
+ raise RuntimeError('CLI exited but thread is still locked; handoff not completed') from None
109
+ return {'stopped': True}
110
+
111
+
112
+ if __name__ == '__main__':
113
+ try:
114
+ home, thread_id = sys.argv[1:3]
115
+ result = stop_writer(home, thread_id, json.loads(sys.argv[3])) if len(sys.argv) == 4 else inspect_writer(home, thread_id)
116
+ print(json.dumps({'ok': True, 'result': result}))
117
+ except Exception as error:
118
+ print(json.dumps({'ok': False, 'error': str(error)}))
@@ -0,0 +1,122 @@
1
+ import fcntl
2
+ import importlib.util
3
+ import os
4
+ from pathlib import Path
5
+ import pty
6
+ import shutil
7
+ import signal
8
+ import tempfile
9
+ import time
10
+ import unittest
11
+ from unittest.mock import patch
12
+
13
+ spec = importlib.util.spec_from_file_location('takeover', Path(__file__).with_name('force-takeover.py'))
14
+ takeover = importlib.util.module_from_spec(spec)
15
+ spec.loader.exec_module(takeover)
16
+ THREAD = '00000000-0000-0000-0000-000000000001'
17
+
18
+
19
+ class WriterTests(unittest.TestCase):
20
+ def setUp(self):
21
+ self.temp = tempfile.TemporaryDirectory(prefix='foxclaw-force-test-')
22
+ self.home = Path(self.temp.name)
23
+ (self.home / 'thread-writer-locks').mkdir()
24
+ self.lock = self.home / 'thread-writer-locks' / f'{THREAD}.lock'
25
+ self.pid = None
26
+ self.terminal = None
27
+
28
+ def tearDown(self):
29
+ if self.pid:
30
+ try:
31
+ os.kill(self.pid, signal.SIGKILL)
32
+ except ProcessLookupError:
33
+ pass
34
+ os.waitpid(self.pid, 0)
35
+ if self.terminal is not None:
36
+ os.close(self.terminal)
37
+ self.temp.cleanup()
38
+
39
+ def writer(self, extra_lock=False, ignore_term=False, name='codex'):
40
+ # Harmless sleep binary: real PTY, PID, flock and pidfd, never a user CLI.
41
+ executable = self.home / name
42
+ shutil.copyfile('/bin/sleep', executable)
43
+ executable.chmod(0o700)
44
+ pid, terminal = pty.fork()
45
+ if pid == 0:
46
+ if ignore_term:
47
+ signal.signal(signal.SIGTERM, signal.SIG_IGN)
48
+ locks = [self.lock]
49
+ if extra_lock:
50
+ locks.append(self.lock.with_name('00000000-0000-0000-0000-000000000002.lock'))
51
+ for lock in locks:
52
+ fd = os.open(lock, os.O_CREAT | os.O_RDWR, 0o600)
53
+ os.set_inheritable(fd, True)
54
+ fcntl.flock(fd, fcntl.LOCK_EX)
55
+ os.execl(str(executable), name, '60')
56
+ self.pid, self.terminal = pid, terminal
57
+ for _ in range(200):
58
+ if os.readlink(f'/proc/{pid}/exe') == str(executable):
59
+ return
60
+ time.sleep(0.01)
61
+ self.fail('Fixture did not start')
62
+
63
+ def test_inspect_does_not_signal_and_stop_releases_real_lock(self):
64
+ self.writer()
65
+ identity = takeover.inspect_writer(self.home, THREAD)
66
+ self.assertEqual(identity['pid'], self.pid)
67
+ os.kill(self.pid, 0)
68
+ self.assertEqual(takeover.stop_writer(self.home, THREAD, identity), {'stopped': True})
69
+ self.assertTrue(self.lock.exists(), 'helper must not unlink lock files')
70
+ with self.lock.open('rb') as lock:
71
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
72
+
73
+ def test_escalates_only_confirmed_process_after_term_timeout(self):
74
+ self.writer(ignore_term=True)
75
+ identity = takeover.inspect_writer(self.home, THREAD)
76
+ takeover.stop_writer(self.home, THREAD, identity)
77
+ self.assertEqual(takeover.process_stat(self.pid)[0], 'Z')
78
+
79
+ def test_stale_identity_never_signals(self):
80
+ self.writer()
81
+ identity = takeover.inspect_writer(self.home, THREAD)
82
+ identity['startTime'] = 'wrong-start-time'
83
+ with self.assertRaisesRegex(RuntimeError, 'changed since confirmation'):
84
+ takeover.stop_writer(self.home, THREAD, identity)
85
+ self.assertEqual(takeover.lock_owners(self.lock), [self.pid])
86
+
87
+ def test_additional_thread_refused(self):
88
+ self.writer(extra_lock=True)
89
+ with self.assertRaisesRegex(RuntimeError, 'additional'):
90
+ takeover.inspect_writer(self.home, THREAD)
91
+
92
+ def test_non_codex_refused(self):
93
+ self.writer(name='sleep')
94
+ with self.assertRaisesRegex(RuntimeError, 'not an interactive'):
95
+ takeover.inspect_writer(self.home, THREAD)
96
+
97
+ def test_server_remote_and_other_user_refused(self):
98
+ self.writer()
99
+ for arg in (b'app-server', b'exec', b'--remote', b'--remote=ws://localhost:9000'):
100
+ with patch.object(Path, 'read_bytes', return_value=b'codex\0' + arg + b'\0'):
101
+ with self.assertRaisesRegex(RuntimeError, 'Refusing to stop'):
102
+ takeover.inspect_writer(self.home, THREAD)
103
+ with patch.object(os, 'getuid', return_value=os.getuid() + 1):
104
+ with self.assertRaisesRegex(RuntimeError, 'different OS user'):
105
+ takeover.inspect_writer(self.home, THREAD)
106
+
107
+ def test_ancestor_refused(self):
108
+ self.writer()
109
+ with patch.object(os, 'getpid', return_value=self.pid):
110
+ with self.assertRaisesRegex(RuntimeError, 'ancestor'):
111
+ takeover.inspect_writer(self.home, THREAD)
112
+
113
+ def test_unlocked_file_and_invalid_id_refused(self):
114
+ self.lock.touch()
115
+ with self.assertRaisesRegex(RuntimeError, 'No unique'):
116
+ takeover.inspect_writer(self.home, THREAD)
117
+ with self.assertRaisesRegex(RuntimeError, 'Invalid thread'):
118
+ takeover.inspect_writer(self.home, '../../outside')
119
+
120
+
121
+ if __name__ == '__main__':
122
+ unittest.main()