@foxden-app/foxclaw 0.7.2 → 0.7.3
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/.env.example +1 -0
- package/CHANGELOG.md +20 -0
- package/README.md +1 -1
- package/README_EN.md +1 -1
- package/dist/auth/cross_node_sync.js +2 -2
- package/dist/codex_app/client.d.ts +1 -0
- package/dist/codex_app/client.js +35 -4
- package/dist/codex_app/force_takeover.d.ts +12 -0
- package/dist/codex_app/force_takeover.js +27 -0
- package/dist/controller/controller.d.ts +7 -0
- package/dist/controller/controller.js +214 -12
- package/dist/i18n.d.ts +30 -6
- package/dist/i18n.js +32 -6
- package/dist/main.js +44 -8
- package/dist/telegram/api.js +4 -0
- package/dist/telegram/bot_home.d.ts +3 -0
- package/dist/telegram/bot_home.js +107 -0
- package/dist/telegram/gateway.d.ts +1 -0
- package/dist/telegram/gateway.js +10 -0
- package/dist/voice/target.js +2 -1
- package/docs/user-manual.md +11 -1
- package/docs/zh/2026-09-05-bot-home-migration.md +26 -0
- package/docs/zh/2026-09-05-reliability-review.md +53 -0
- package/docs/zh/troubleshooting.md +12 -0
- package/docs/zh/user-manual.md +13 -1
- package/package.json +1 -1
- package/scripts/force-takeover.py +118 -0
- package/scripts/force-takeover.test.py +122 -0
package/docs/zh/user-manual.md
CHANGED
|
@@ -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
|
|
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
|
@@ -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()
|