@foxden-app/foxclaw 0.7.1 → 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 +30 -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 +4 -0
- package/dist/codex_app/client.js +43 -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 +8 -0
- package/dist/controller/controller.js +266 -17
- package/dist/i18n.d.ts +36 -8
- package/dist/i18n.js +38 -8
- 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 +14 -2
- 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 +16 -2
- package/package.json +1 -1
- package/scripts/force-takeover.py +118 -0
- package/scripts/force-takeover.test.py +122 -0
|
@@ -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()
|