multi-codex 0.7.0__py3-none-any.whl
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.
- multi_codex/__init__.py +3 -0
- multi_codex/__main__.py +5 -0
- multi_codex/accounts.py +245 -0
- multi_codex/actions.py +76 -0
- multi_codex/apps.py +74 -0
- multi_codex/binding.py +66 -0
- multi_codex/cli.py +994 -0
- multi_codex/completion.py +254 -0
- multi_codex/config.py +327 -0
- multi_codex/doctor.py +244 -0
- multi_codex/fsutil.py +250 -0
- multi_codex/identity.py +171 -0
- multi_codex/launcher.py +151 -0
- multi_codex/lock.py +47 -0
- multi_codex/migrate.py +672 -0
- multi_codex/platform.py +184 -0
- multi_codex/shared.py +127 -0
- multi_codex/switch.py +286 -0
- multi_codex/usage.py +475 -0
- multi_codex-0.7.0.dist-info/METADATA +452 -0
- multi_codex-0.7.0.dist-info/RECORD +25 -0
- multi_codex-0.7.0.dist-info/WHEEL +5 -0
- multi_codex-0.7.0.dist-info/entry_points.txt +2 -0
- multi_codex-0.7.0.dist-info/licenses/LICENSE +21 -0
- multi_codex-0.7.0.dist-info/top_level.txt +1 -0
multi_codex/launcher.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""启动命令 `<bin_dir>/codex-<名称>` 的生成与识别。
|
|
2
|
+
|
|
3
|
+
启动命令是由 config.json 推导出的产物,内容完全由 (账号名, 账号目录, 代理, 环境变量) 决定,
|
|
4
|
+
所以“内容相同即 unchanged”就是幂等判断。第 2 行的受管标记用来区分本工具生成的文件
|
|
5
|
+
和用户自己的同名文件:没有标记的文件一律不覆盖、不删除。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shlex
|
|
10
|
+
import stat
|
|
11
|
+
from typing import Dict, Optional
|
|
12
|
+
|
|
13
|
+
from .config import PROXY_ENV_VARS, PROXY_INHERIT, PROXY_OFF, is_socks
|
|
14
|
+
from .fsutil import KIND_FILE, entry_kind, read_text
|
|
15
|
+
|
|
16
|
+
MARKER_PREFIX = "# managed-by: multi-codex account="
|
|
17
|
+
LAUNCHER_PREFIX = "codex-"
|
|
18
|
+
LAUNCHER_MODE = 0o755
|
|
19
|
+
# 设置了账号环境变量的启动命令只允许本人读写执行:值里可能有 API key。
|
|
20
|
+
PRIVATE_LAUNCHER_MODE = 0o700
|
|
21
|
+
# `multi-codex run` 通过这个环境变量把脚本交给 sh,而不是放进命令行参数(其它用户能用 ps 看到)。
|
|
22
|
+
RUN_SCRIPT_ENV = "MULTI_CODEX_RUN_SCRIPT"
|
|
23
|
+
|
|
24
|
+
_PROXY_VARS = PROXY_ENV_VARS
|
|
25
|
+
_NO_PROXY_HOSTS = "localhost,127.0.0.1,::1"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def launcher_path(bin_dir: str, name: str) -> str:
|
|
29
|
+
return os.path.join(bin_dir, LAUNCHER_PREFIX + name)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def launcher_mode(env: Optional[Dict[str, str]]) -> int:
|
|
33
|
+
return PRIVATE_LAUNCHER_MODE if env else LAUNCHER_MODE
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def launcher_file_ok(path: str, content: str, env: Optional[Dict[str, str]]) -> bool:
|
|
37
|
+
"""启动命令是否与期望一致;_plan_launcher 与 launcher_status 共用,两处判断必须相同。
|
|
38
|
+
|
|
39
|
+
没有环境变量时只要求内容相同且可执行,与 0.3.0 一样不比较权限位:用户手工改成 0775,
|
|
40
|
+
或 bin_dir 所在文件系统忽略 chmod 时,不应被反复判为需要重写。
|
|
41
|
+
有环境变量时还要求组和其他用户没有任何权限,否则密钥会被其他用户读到。
|
|
42
|
+
"""
|
|
43
|
+
if read_text(path) != content or not os.access(path, os.X_OK):
|
|
44
|
+
return False
|
|
45
|
+
if env:
|
|
46
|
+
try:
|
|
47
|
+
return stat.S_IMODE(os.stat(path).st_mode) & 0o077 == 0
|
|
48
|
+
except OSError:
|
|
49
|
+
return False
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def render(name: str, account_dir: str, proxy: str, env: Optional[Dict[str, str]] = None,
|
|
54
|
+
command_mode: bool = False) -> str:
|
|
55
|
+
"""生成 POSIX sh 启动命令的完整内容。所有值都经 shlex.quote 转义。
|
|
56
|
+
|
|
57
|
+
env 为空时,输出必须与 0.3.0 逐字节相同,否则升级后所有启动命令都会被判为 stale。
|
|
58
|
+
command_mode 供 `multi-codex run` 使用:环境设置与启动命令完全相同,只是最后执行 "$@" 而不是 codex,
|
|
59
|
+
这样两者不会各算一套环境变量。
|
|
60
|
+
"""
|
|
61
|
+
q_dir = shlex.quote(account_dir)
|
|
62
|
+
lines = [
|
|
63
|
+
"#!/bin/sh",
|
|
64
|
+
MARKER_PREFIX + name,
|
|
65
|
+
"# Generated by multi-codex. Do not edit; use `multi-codex proxy` or `multi-codex apply`.",
|
|
66
|
+
"account_dir=" + q_dir,
|
|
67
|
+
# codex 遇到不存在的 CODEX_HOME 只会给出难懂的配置错误,这里提前给出明确提示。
|
|
68
|
+
'if [ ! -d "$account_dir" ]; then',
|
|
69
|
+
# 用 printf 而不是 echo:dash 的 echo 会解释路径里的反斜杠。
|
|
70
|
+
' printf \'multi-codex: account directory does not exist: %s\\n\' "$account_dir" >&2',
|
|
71
|
+
" exit 1",
|
|
72
|
+
"fi",
|
|
73
|
+
'CODEX_HOME="$account_dir"',
|
|
74
|
+
"export CODEX_HOME",
|
|
75
|
+
]
|
|
76
|
+
lines.extend(_proxy_lines(proxy))
|
|
77
|
+
for key in sorted(env or {}):
|
|
78
|
+
# 键名已按 [A-Za-z_][A-Za-z0-9_]* 校验,可以直接写;值按字面引用,不经 shell 展开。
|
|
79
|
+
lines.append("{}={}; export {}".format(key, shlex.quote(env[key]), key))
|
|
80
|
+
if command_mode:
|
|
81
|
+
# 脚本经环境变量传入;先清掉它,被运行的命令不会继承这份含密钥的脚本。
|
|
82
|
+
lines.insert(1, "unset " + RUN_SCRIPT_ENV)
|
|
83
|
+
lines.extend(['exec "$@"', ""])
|
|
84
|
+
return "\n".join(lines)
|
|
85
|
+
lines.extend([
|
|
86
|
+
"if ! command -v codex >/dev/null 2>&1; then",
|
|
87
|
+
' echo "multi-codex: codex not found in PATH" >&2',
|
|
88
|
+
" exit 127",
|
|
89
|
+
"fi",
|
|
90
|
+
'exec codex "$@"',
|
|
91
|
+
"",
|
|
92
|
+
])
|
|
93
|
+
return "\n".join(lines)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _proxy_lines(proxy: str) -> list:
|
|
97
|
+
if proxy == PROXY_INHERIT:
|
|
98
|
+
# 不动任何代理变量,沿用启动时 shell 里的值。
|
|
99
|
+
return []
|
|
100
|
+
if proxy == PROXY_OFF:
|
|
101
|
+
# 清掉全部 8 个变量;只是“不导出”挡不住从父进程继承来的代理。
|
|
102
|
+
return ["unset " + " ".join(_PROXY_VARS)]
|
|
103
|
+
q_proxy = shlex.quote(proxy)
|
|
104
|
+
lines = [
|
|
105
|
+
"HTTPS_PROXY={0}; HTTP_PROXY={0}; https_proxy={0}; http_proxy={0}".format(q_proxy),
|
|
106
|
+
"export HTTPS_PROXY HTTP_PROXY https_proxy http_proxy",
|
|
107
|
+
]
|
|
108
|
+
if is_socks(proxy):
|
|
109
|
+
lines.append("ALL_PROXY={0}; all_proxy={0}; export ALL_PROXY all_proxy".format(q_proxy))
|
|
110
|
+
else:
|
|
111
|
+
# http 代理不写 ALL_PROXY:curl 等程序会把它用于所有协议。同时清掉继承来的值,
|
|
112
|
+
# 否则父进程里的另一个 ALL_PROXY 可能把部分流量带到别的代理上。
|
|
113
|
+
lines.append("unset ALL_PROXY all_proxy")
|
|
114
|
+
# 在用户原有的 NO_PROXY 后追加本地地址,不覆盖用户原来的内网例外;原值为空时不留前导逗号。
|
|
115
|
+
lines.append('NO_PROXY="${{NO_PROXY:+$NO_PROXY,}}{}"'.format(_NO_PROXY_HOSTS))
|
|
116
|
+
lines.append('no_proxy="${{no_proxy:+$no_proxy,}}{}"'.format(_NO_PROXY_HOSTS))
|
|
117
|
+
lines.append("export NO_PROXY no_proxy")
|
|
118
|
+
return lines
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def managed_account(path: str) -> Optional[str]:
|
|
122
|
+
"""若 path 是本工具生成的启动命令,返回其中记录的账号名;否则返回 None。
|
|
123
|
+
|
|
124
|
+
只认普通文件:软链、目录或读不出文本的文件都视为“不归本工具管理”。
|
|
125
|
+
"""
|
|
126
|
+
if entry_kind(path) != KIND_FILE:
|
|
127
|
+
return None
|
|
128
|
+
content = read_text(path)
|
|
129
|
+
if content is None:
|
|
130
|
+
return None
|
|
131
|
+
lines = content.split("\n", 2)
|
|
132
|
+
if len(lines) < 2 or not lines[1].startswith(MARKER_PREFIX):
|
|
133
|
+
return None
|
|
134
|
+
return lines[1][len(MARKER_PREFIX):].strip() or None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def scan_managed(bin_dir: str) -> Dict[str, str]:
|
|
138
|
+
"""列出 bin_dir 中所有受管启动命令,返回 {账号名: 路径}。bin_dir 不存在时返回空。"""
|
|
139
|
+
found: Dict[str, str] = {}
|
|
140
|
+
try:
|
|
141
|
+
names = os.listdir(bin_dir)
|
|
142
|
+
except OSError:
|
|
143
|
+
return found
|
|
144
|
+
for entry in sorted(names):
|
|
145
|
+
if not entry.startswith(LAUNCHER_PREFIX):
|
|
146
|
+
continue
|
|
147
|
+
path = os.path.join(bin_dir, entry)
|
|
148
|
+
account = managed_account(path)
|
|
149
|
+
if account is not None:
|
|
150
|
+
found[account] = path
|
|
151
|
+
return found
|
multi_codex/lock.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""写操作互斥锁。
|
|
2
|
+
|
|
3
|
+
用 fcntl.flock 实现:进程退出(包括被 SIGKILL)时内核自动释放锁,
|
|
4
|
+
不会出现“持锁进程已死但锁还在”的情况,也不受 PID 复用影响。
|
|
5
|
+
锁文件里的 PID 只用于报错时告诉用户是谁持有锁,不参与判断。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import fcntl
|
|
9
|
+
import os
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from . import platform
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LockBusyError(Exception):
|
|
16
|
+
def __init__(self, holder_pid: Optional[str]) -> None:
|
|
17
|
+
super().__init__(holder_pid)
|
|
18
|
+
self.holder_pid = holder_pid
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WriteLock(object):
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self.path = os.path.join(platform.state_dir(), "lock")
|
|
24
|
+
self._fd: Optional[int] = None
|
|
25
|
+
|
|
26
|
+
def __enter__(self) -> "WriteLock":
|
|
27
|
+
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
|
28
|
+
# 不能带 O_TRUNC 打开:此时还没拿到锁,截断会清掉持锁进程写的 PID。
|
|
29
|
+
# 锁文件也不能用 atomic_write 写或被删除:换了 inode,两个进程会各自锁住不同的文件。
|
|
30
|
+
fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
31
|
+
try:
|
|
32
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
33
|
+
except (BlockingIOError, PermissionError):
|
|
34
|
+
holder = os.read(fd, 64).decode("ascii", "replace").strip() or None
|
|
35
|
+
os.close(fd)
|
|
36
|
+
raise LockBusyError(holder)
|
|
37
|
+
os.ftruncate(fd, 0)
|
|
38
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
39
|
+
os.write(fd, str(os.getpid()).encode("ascii"))
|
|
40
|
+
self._fd = fd
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
def __exit__(self, *_exc) -> None:
|
|
44
|
+
if self._fd is not None:
|
|
45
|
+
fcntl.flock(self._fd, fcntl.LOCK_UN)
|
|
46
|
+
os.close(self._fd)
|
|
47
|
+
self._fd = None
|