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/doctor.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""体检:只读地检查安装、配置、环境变量、各账号状态,以及配置与实际文件是否一致。
|
|
2
|
+
|
|
3
|
+
不修复任何东西:已有的 `apply` 就是修复手段,这里只给出建议命令。不联网,不运行 `codex login status`。
|
|
4
|
+
检查项与 fail / warn 条件见 feature-account-insight §5.1.3。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
from typing import List, NamedTuple, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from . import accounts, identity, migrate, platform, switch
|
|
14
|
+
from .actions import CONFLICT, CREATE, DELETE, SKIP, UPDATE
|
|
15
|
+
from .config import ConfigError, load_config
|
|
16
|
+
from .fsutil import KIND_DIR, KIND_LINK, KIND_MISSING, entry_kind, expand
|
|
17
|
+
|
|
18
|
+
OK = "ok"
|
|
19
|
+
WARN = "warn"
|
|
20
|
+
FAIL = "fail"
|
|
21
|
+
|
|
22
|
+
# usage --live 依赖的 account/rateLimits/read 从 0.48.0 起提供。
|
|
23
|
+
MIN_LIVE_CODEX_VERSION = (0, 48, 0)
|
|
24
|
+
_VERSION_TIMEOUT_SEC = 10
|
|
25
|
+
_VERSION_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Check(NamedTuple):
|
|
29
|
+
id: str
|
|
30
|
+
status: str
|
|
31
|
+
message: str
|
|
32
|
+
hint: Optional[str] = None
|
|
33
|
+
details: Tuple[str, ...] = ()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_checks() -> List[Check]:
|
|
37
|
+
"""依次执行全部检查。配置无法读取时,后面依赖配置的检查全部跳过。"""
|
|
38
|
+
checks = [_check_codex()]
|
|
39
|
+
try:
|
|
40
|
+
config, exists = load_config()
|
|
41
|
+
except ConfigError as exc:
|
|
42
|
+
checks.append(Check("config", FAIL, str(exc)))
|
|
43
|
+
checks.append(_check_migration())
|
|
44
|
+
return checks
|
|
45
|
+
if not exists:
|
|
46
|
+
checks.append(Check("config", WARN, "no configuration yet", "multi-codex add NAME"))
|
|
47
|
+
checks.append(_check_migration())
|
|
48
|
+
checks.append(_check_env())
|
|
49
|
+
return checks
|
|
50
|
+
checks.append(Check("config", OK, "loaded {} account(s)".format(len(config.accounts))))
|
|
51
|
+
checks.append(_check_migration())
|
|
52
|
+
checks.append(_check_path(expand(config.bin_dir)))
|
|
53
|
+
checks.append(_check_env())
|
|
54
|
+
checks.append(_check_default_dir(config))
|
|
55
|
+
checks.append(_check_drift(config))
|
|
56
|
+
identities = []
|
|
57
|
+
for name in config.accounts:
|
|
58
|
+
check, found = _check_account(config, name)
|
|
59
|
+
checks.append(check)
|
|
60
|
+
if found is not None:
|
|
61
|
+
identities.append((name, found))
|
|
62
|
+
checks.append(_check_duplicates(identities))
|
|
63
|
+
checks.append(_check_bindings(config))
|
|
64
|
+
return checks
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _check_codex() -> Check:
|
|
68
|
+
path = shutil.which("codex")
|
|
69
|
+
if path is None:
|
|
70
|
+
return Check("codex", FAIL, "codex was not found in PATH", "install Codex CLI and put it on PATH")
|
|
71
|
+
try:
|
|
72
|
+
proc = subprocess.run([path, "--version"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
73
|
+
universal_newlines=True, timeout=_VERSION_TIMEOUT_SEC)
|
|
74
|
+
output = proc.stdout.strip()
|
|
75
|
+
except subprocess.TimeoutExpired:
|
|
76
|
+
return Check("codex", WARN, "{} --version did not finish within {}s".format(path, _VERSION_TIMEOUT_SEC))
|
|
77
|
+
except OSError as exc:
|
|
78
|
+
return Check("codex", WARN, "cannot run {} --version: {}".format(path, exc))
|
|
79
|
+
match = _VERSION_RE.search(output)
|
|
80
|
+
if match is None:
|
|
81
|
+
return Check("codex", WARN, "cannot parse the version of {}: {!r}".format(path, output[:100]))
|
|
82
|
+
version = tuple(int(part) for part in match.groups())
|
|
83
|
+
message = "{} ({})".format(output.splitlines()[-1] if output else match.group(0), path)
|
|
84
|
+
if version < MIN_LIVE_CODEX_VERSION:
|
|
85
|
+
return Check("codex", WARN, message + "; `usage --live` needs Codex 0.48.0 or newer", "upgrade Codex")
|
|
86
|
+
return Check("codex", OK, message)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _check_migration() -> Check:
|
|
90
|
+
try:
|
|
91
|
+
notice = migrate.pending_journal_notice()
|
|
92
|
+
except migrate.JournalError as exc:
|
|
93
|
+
return Check("migration", FAIL, str(exc),
|
|
94
|
+
"move the complete data back to the source path, then delete {}".format(migrate.journal_path()))
|
|
95
|
+
if notice:
|
|
96
|
+
return Check("migration", FAIL, notice, "rerun the same `multi-codex migrate-default` command")
|
|
97
|
+
# restore 做到一半同样会拦下其它写命令,这里一并报告。
|
|
98
|
+
try:
|
|
99
|
+
restore = switch.load_restore_journal()
|
|
100
|
+
except switch.RestoreJournalError as exc:
|
|
101
|
+
return Check("migration", FAIL, str(exc), "check ~/.codex and the account directory, then delete {}".format(
|
|
102
|
+
switch.restore_journal_path()))
|
|
103
|
+
if restore:
|
|
104
|
+
return Check("migration", FAIL, "unfinished restore of {}".format(restore["name"]),
|
|
105
|
+
"multi-codex restore {}".format(restore["name"]))
|
|
106
|
+
return Check("migration", OK, "no unfinished migration or restore")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _check_path(bin_dir: str) -> Check:
|
|
110
|
+
if platform.dir_on_path(bin_dir):
|
|
111
|
+
return Check("path", OK, "{} is in PATH".format(bin_dir))
|
|
112
|
+
return Check("path", WARN, "{} is not in PATH; codex-<name> launchers cannot be found".format(bin_dir),
|
|
113
|
+
"add {} to PATH in your shell profile".format(bin_dir))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _check_env() -> Check:
|
|
117
|
+
found = [name for name in ("CODEX_HOME",) + platform.ISOLATION_BREAKING_ENV if os.environ.get(name)]
|
|
118
|
+
if not found:
|
|
119
|
+
return Check("env", OK, "no variables that override per-account isolation")
|
|
120
|
+
return Check("env", WARN, "set in this shell: {}; they override or bypass per-account isolation".format(
|
|
121
|
+
", ".join(found)), "unset {}".format(" ".join(found)))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _check_default_dir(config) -> Check:
|
|
125
|
+
# default_source() 返回字面量 "~/.codex",必须先展开;输出里仍用短写法 source 方便阅读。
|
|
126
|
+
source = platform.default_source()
|
|
127
|
+
path = expand(source)
|
|
128
|
+
kind = entry_kind(path)
|
|
129
|
+
if kind == KIND_MISSING:
|
|
130
|
+
return Check("default-dir", OK, "{} does not exist".format(source))
|
|
131
|
+
if kind == KIND_DIR:
|
|
132
|
+
return Check("default-dir", OK, "{} is a real directory (not migrated)".format(source))
|
|
133
|
+
if kind == KIND_LINK:
|
|
134
|
+
real = os.path.realpath(path)
|
|
135
|
+
for name in config.accounts:
|
|
136
|
+
if os.path.realpath(accounts.account_dir(config, name)) == real:
|
|
137
|
+
return Check("default-dir", OK, "{} links to account {}".format(source, name))
|
|
138
|
+
return Check("default-dir", WARN, "{} links to {}, which is not a registered account".format(source, real))
|
|
139
|
+
return Check("default-dir", WARN, "{} is neither a directory nor a link".format(source))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _check_drift(config) -> Check:
|
|
143
|
+
"""复用收敛计划判断配置与文件是否一致。plan 只读文件系统,副作用都在动作的 run 回调里,这里不执行。
|
|
144
|
+
|
|
145
|
+
第二个参数必须传副本:plan_shared 会就地改写 new 的 managed_links(shared.py:92),
|
|
146
|
+
传同一个对象会让 old 也被改掉,计划结果就不再反映真实差异。
|
|
147
|
+
"""
|
|
148
|
+
actions = accounts.plan(config, config.copy(), orphan_scope=accounts.ALL_ORPHANS)
|
|
149
|
+
conflicts = [action for action in actions if action.status == CONFLICT]
|
|
150
|
+
pending = [action for action in actions if action.status in (CREATE, UPDATE, DELETE)]
|
|
151
|
+
# skip 是持久状态(例如共享目录里本来就没有某项),apply 也消除不了,只作为说明列出。
|
|
152
|
+
notes = [action for action in actions if action.status == SKIP]
|
|
153
|
+
details = tuple(_describe(action) for action in conflicts + pending + notes)
|
|
154
|
+
if conflicts:
|
|
155
|
+
return Check("drift", FAIL, "{} conflict(s) with files multi-codex does not own".format(len(conflicts)),
|
|
156
|
+
"resolve the conflicts listed below by hand", details)
|
|
157
|
+
if pending:
|
|
158
|
+
return Check("drift", WARN, "{} change(s) pending".format(len(pending)), "multi-codex apply", details)
|
|
159
|
+
return Check("drift", OK, "files match the configuration", None, details)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _describe(action) -> str:
|
|
163
|
+
text = "{} {} {}".format(action.status, action.kind, action.path)
|
|
164
|
+
return text + (" ({})".format(action.reason) if action.reason else "")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _check_account(config, name: str) -> Tuple[Check, Optional[identity.Identity]]:
|
|
168
|
+
check_id = "account:" + name
|
|
169
|
+
directory = accounts.account_dir(config, name)
|
|
170
|
+
if not os.path.isdir(directory):
|
|
171
|
+
return Check(check_id, FAIL, "account directory {} does not exist".format(directory),
|
|
172
|
+
"multi-codex apply"), None
|
|
173
|
+
found = identity.read_identity(directory)
|
|
174
|
+
problems = []
|
|
175
|
+
hint = None
|
|
176
|
+
if found.login == identity.LOGIN_LOGGED_OUT:
|
|
177
|
+
problems.append("not logged in")
|
|
178
|
+
hint = "multi-codex login {}".format(name)
|
|
179
|
+
elif found.login == identity.LOGIN_UNREADABLE:
|
|
180
|
+
problems.append("auth.json cannot be read")
|
|
181
|
+
hint = "multi-codex login {}".format(name)
|
|
182
|
+
elif found.login == identity.LOGIN_KEYRING:
|
|
183
|
+
problems.append("credentials are in the system keyring (cli_auth_credentials_store = {!r}); "
|
|
184
|
+
"the login cannot be read from files".format(found.store))
|
|
185
|
+
if os.path.islink(os.path.join(directory, "sessions")):
|
|
186
|
+
problems.append("sessions is a link; local usage data may belong to another account")
|
|
187
|
+
if problems:
|
|
188
|
+
return Check(check_id, WARN, "; ".join(problems), hint), found
|
|
189
|
+
return Check(check_id, OK, _identity_summary(found)), found
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _identity_summary(found: identity.Identity) -> str:
|
|
193
|
+
if found.login == identity.LOGIN_CHATGPT:
|
|
194
|
+
return "logged in as {}{}".format(found.email or "(no email)",
|
|
195
|
+
" ({})".format(found.plan) if found.plan else "")
|
|
196
|
+
if found.login == identity.LOGIN_APIKEY:
|
|
197
|
+
return "logged in with an API key"
|
|
198
|
+
return "login type: {}".format(found.login)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _check_duplicates(identities) -> Check:
|
|
202
|
+
groups = identity.duplicate_groups(identities)
|
|
203
|
+
if not groups:
|
|
204
|
+
return Check("duplicates", OK, "no two accounts share one ChatGPT login")
|
|
205
|
+
return Check("duplicates", WARN, "; ".join("{} are logged in as the same ChatGPT account".format(
|
|
206
|
+
", ".join(group)) for group in groups), "log one of them in with another ChatGPT account")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _check_bindings(config) -> Check:
|
|
210
|
+
"""悬空的目录绑定:账号已注销或目录已删除。只报 warn,run 遇到时会给出明确的报错。"""
|
|
211
|
+
problems = []
|
|
212
|
+
for path in sorted(config.bindings):
|
|
213
|
+
name = config.bindings[path]
|
|
214
|
+
if config.find(name) is None:
|
|
215
|
+
problems.append("{} -> {} (account is not registered)".format(path, name))
|
|
216
|
+
elif not os.path.isdir(path):
|
|
217
|
+
problems.append("{} -> {} (directory does not exist)".format(path, name))
|
|
218
|
+
if problems:
|
|
219
|
+
return Check("bindings", WARN, "{} stale binding(s)".format(len(problems)),
|
|
220
|
+
"multi-codex unbind <directory>", tuple(problems))
|
|
221
|
+
return Check("bindings", OK, "{} binding(s)".format(len(config.bindings)))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def to_json(checks: List[Check]) -> dict:
|
|
225
|
+
return {
|
|
226
|
+
"version": 1,
|
|
227
|
+
"ok": not any(check.status == FAIL for check in checks),
|
|
228
|
+
"checks": [{"id": check.id, "status": check.status, "message": check.message, "hint": check.hint,
|
|
229
|
+
"details": list(check.details)} for check in checks],
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def format_checks(checks: List[Check]) -> List[str]:
|
|
234
|
+
width = max(len(check.id) for check in checks)
|
|
235
|
+
lines = []
|
|
236
|
+
for check in checks:
|
|
237
|
+
lines.append("{:<4} {} {}".format(check.status, check.id.ljust(width), check.message))
|
|
238
|
+
for detail in check.details:
|
|
239
|
+
lines.append(" {}".format(detail))
|
|
240
|
+
if check.hint and check.status != OK:
|
|
241
|
+
lines.append(" fix: {}".format(check.hint))
|
|
242
|
+
counts = {status: sum(1 for check in checks if check.status == status) for status in (OK, WARN, FAIL)}
|
|
243
|
+
lines.append("{} ok, {} warn, {} fail".format(counts[OK], counts[WARN], counts[FAIL]))
|
|
244
|
+
return lines
|
multi_codex/fsutil.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""文件系统工具:原子写入、路径判定、文件清单与校验、跳过特殊文件的复制。
|
|
2
|
+
|
|
3
|
+
本模块只做与业务无关的文件操作,不读取配置,也不输出日志;
|
|
4
|
+
调用方负责决定何时调用以及如何报告结果。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import stat
|
|
11
|
+
import tempfile
|
|
12
|
+
from typing import Callable, Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
# entry_kind() 的返回值
|
|
15
|
+
KIND_MISSING = "missing"
|
|
16
|
+
KIND_LINK = "link"
|
|
17
|
+
KIND_DIR = "dir"
|
|
18
|
+
KIND_FILE = "file"
|
|
19
|
+
# socket、FIFO、设备文件等运行时产物
|
|
20
|
+
KIND_SPECIAL = "special"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def expand(path: str) -> str:
|
|
24
|
+
"""展开 `~` 并转成绝对路径;不解析软链,调用方需要真实路径时自行 realpath。"""
|
|
25
|
+
return os.path.abspath(os.path.expanduser(path))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_under(path: str, root: str) -> bool:
|
|
29
|
+
"""path 等于 root 或位于 root 之下。
|
|
30
|
+
|
|
31
|
+
必须按路径段比较:直接用 startswith(root) 会把 `~/.codex-shared`
|
|
32
|
+
误判为位于 `~/.codex` 之下。
|
|
33
|
+
"""
|
|
34
|
+
root = root.rstrip(os.sep) or os.sep
|
|
35
|
+
if path == root:
|
|
36
|
+
return True
|
|
37
|
+
prefix = root if root.endswith(os.sep) else root + os.sep
|
|
38
|
+
return path.startswith(prefix)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def entry_kind(path: str) -> str:
|
|
42
|
+
"""按 lstat 判断目录项类型,不跟随软链;断开的软链返回 KIND_LINK。"""
|
|
43
|
+
try:
|
|
44
|
+
st = os.lstat(path)
|
|
45
|
+
except FileNotFoundError:
|
|
46
|
+
return KIND_MISSING
|
|
47
|
+
if stat.S_ISLNK(st.st_mode):
|
|
48
|
+
return KIND_LINK
|
|
49
|
+
if stat.S_ISDIR(st.st_mode):
|
|
50
|
+
return KIND_DIR
|
|
51
|
+
if stat.S_ISREG(st.st_mode):
|
|
52
|
+
return KIND_FILE
|
|
53
|
+
return KIND_SPECIAL
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def same_target(link_path: str, target: str) -> bool:
|
|
57
|
+
"""link_path 解析后的真实路径是否与 target 的真实路径相同。
|
|
58
|
+
|
|
59
|
+
两边都用 realpath,这样 `~` 写法、多级软链或尾部斜杠都不影响判断。
|
|
60
|
+
"""
|
|
61
|
+
return os.path.realpath(link_path) == os.path.realpath(target)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def atomic_write(path: str, content: str, mode: int = 0o644) -> None:
|
|
65
|
+
"""原子替换 path 的内容:写临时文件、fsync,再用 os.replace 覆盖。
|
|
66
|
+
|
|
67
|
+
进程在任何时刻被杀,path 要么是旧内容,要么是完整的新内容,
|
|
68
|
+
不会出现写了一半的文件。临时文件与目标在同一目录,保证 replace 不跨设备。
|
|
69
|
+
"""
|
|
70
|
+
directory = os.path.dirname(path) or "."
|
|
71
|
+
os.makedirs(directory, exist_ok=True)
|
|
72
|
+
fd, tmp_path = tempfile.mkstemp(prefix=".multi-codex-", dir=directory)
|
|
73
|
+
try:
|
|
74
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
75
|
+
handle.write(content)
|
|
76
|
+
handle.flush()
|
|
77
|
+
os.fsync(handle.fileno())
|
|
78
|
+
os.chmod(tmp_path, mode)
|
|
79
|
+
os.replace(tmp_path, path)
|
|
80
|
+
except BaseException:
|
|
81
|
+
try:
|
|
82
|
+
os.unlink(tmp_path)
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
pass
|
|
85
|
+
raise
|
|
86
|
+
_fsync_dir(directory)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _fsync_dir(directory: str) -> None:
|
|
90
|
+
# 让 rename 本身落盘;部分文件系统不支持对目录 fsync,失败可以忽略。
|
|
91
|
+
try:
|
|
92
|
+
dir_fd = os.open(directory, os.O_RDONLY)
|
|
93
|
+
except OSError:
|
|
94
|
+
return
|
|
95
|
+
try:
|
|
96
|
+
os.fsync(dir_fd)
|
|
97
|
+
except OSError:
|
|
98
|
+
pass
|
|
99
|
+
finally:
|
|
100
|
+
os.close(dir_fd)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def read_text(path: str) -> Optional[str]:
|
|
104
|
+
"""读取 UTF-8 文本;文件不存在、不可读或不是文本时返回 None。"""
|
|
105
|
+
try:
|
|
106
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
107
|
+
return handle.read()
|
|
108
|
+
except (OSError, UnicodeDecodeError):
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def remove_path(path: str) -> None:
|
|
113
|
+
"""删除一个目录项:软链只删链接本身,目录整体删除,普通文件直接删除。
|
|
114
|
+
|
|
115
|
+
路径不存在时什么也不做,便于中断后重跑时重复调用。
|
|
116
|
+
"""
|
|
117
|
+
kind = entry_kind(path)
|
|
118
|
+
if kind == KIND_MISSING:
|
|
119
|
+
return
|
|
120
|
+
if kind == KIND_DIR:
|
|
121
|
+
_grant_owner_access(path)
|
|
122
|
+
shutil.rmtree(path)
|
|
123
|
+
else:
|
|
124
|
+
os.unlink(path)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _grant_owner_access(root: str) -> None:
|
|
128
|
+
"""删除前给目录树里的每个目录补上本人的读、写、执行权限。
|
|
129
|
+
|
|
130
|
+
没有读或执行权限的目录,rmtree 既列不出内容也删不掉里面的文件;
|
|
131
|
+
而用 rmtree 的错误回调补权限再重试并不可靠(新版 rmtree 按文件描述符遍历,
|
|
132
|
+
回调拿到的函数可能是 os.open,直接重调会抛 TypeError)。
|
|
133
|
+
所以先自上而下补齐权限:父目录先改,os.walk 才能继续列出子目录。软链不跟随、不修改。
|
|
134
|
+
权限已齐全的目录不动:中途遇到无权修改的目录会抛错,此时已处理过的目录应尽量保持原样。
|
|
135
|
+
"""
|
|
136
|
+
_add_owner_rwx(root)
|
|
137
|
+
for dirpath, dirnames, _filenames in os.walk(root, followlinks=False):
|
|
138
|
+
for name in dirnames:
|
|
139
|
+
full = os.path.join(dirpath, name)
|
|
140
|
+
if entry_kind(full) == KIND_DIR:
|
|
141
|
+
_add_owner_rwx(full)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _add_owner_rwx(path: str) -> None:
|
|
145
|
+
mode = stat.S_IMODE(os.lstat(path).st_mode)
|
|
146
|
+
if mode & stat.S_IRWXU != stat.S_IRWXU:
|
|
147
|
+
os.chmod(path, mode | stat.S_IRWXU)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# 清单条目:(类型, 大小, SHA-256 或软链目标)。目录的大小与哈希固定为 0 和空串。
|
|
151
|
+
ManifestEntry = Tuple[str, int, str]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def build_manifest(root: str) -> Tuple[Dict[str, ManifestEntry], List[str]]:
|
|
155
|
+
"""递归生成 root 的文件清单,返回 (清单, 跳过的特殊文件相对路径)。
|
|
156
|
+
|
|
157
|
+
软链不跟随,只记录链接目标;socket、FIFO、设备文件属于运行时产物,
|
|
158
|
+
不进清单,单独返回给调用方写日志。
|
|
159
|
+
"""
|
|
160
|
+
manifest: Dict[str, ManifestEntry] = {}
|
|
161
|
+
skipped: List[str] = []
|
|
162
|
+
# onerror 必须抛出:os.walk 默认静默跳过读不了的目录,两侧清单会同样缺一整棵子树,
|
|
163
|
+
# 校验因此误判“一致”,随后 park 并删除备份,数据就丢了。
|
|
164
|
+
for dirpath, dirnames, filenames in os.walk(root, onerror=_raise, followlinks=False):
|
|
165
|
+
rel_dir = os.path.relpath(dirpath, root)
|
|
166
|
+
entries = [(name, True) for name in dirnames] + [(name, False) for name in filenames]
|
|
167
|
+
for name, _is_dir_hint in entries:
|
|
168
|
+
full = os.path.join(dirpath, name)
|
|
169
|
+
rel = name if rel_dir == "." else os.path.join(rel_dir, name)
|
|
170
|
+
kind = entry_kind(full)
|
|
171
|
+
if kind == KIND_LINK:
|
|
172
|
+
manifest[rel] = (KIND_LINK, 0, os.readlink(full))
|
|
173
|
+
elif kind == KIND_DIR:
|
|
174
|
+
manifest[rel] = (KIND_DIR, 0, "")
|
|
175
|
+
elif kind == KIND_FILE:
|
|
176
|
+
manifest[rel] = (KIND_FILE, os.lstat(full).st_size, _sha256(full))
|
|
177
|
+
elif kind == KIND_SPECIAL:
|
|
178
|
+
skipped.append(rel)
|
|
179
|
+
return manifest, skipped
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _raise(exc: OSError) -> None:
|
|
183
|
+
raise exc
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _sha256(path: str) -> str:
|
|
187
|
+
digest = hashlib.sha256()
|
|
188
|
+
with open(path, "rb") as handle:
|
|
189
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
190
|
+
digest.update(chunk)
|
|
191
|
+
return digest.hexdigest()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def copy_tree(src: str, dst: str, on_progress: Optional[Callable[[str], None]] = None) -> List[str]:
|
|
195
|
+
"""把 src 复制到尚不存在的 dst,返回跳过的特殊文件相对路径。
|
|
196
|
+
|
|
197
|
+
与 shutil.copytree 的区别:遇到 socket、FIFO、设备文件时跳过而不是报错
|
|
198
|
+
(copytree 会抛 `Operation not supported on socket` 并留下半份目标)。
|
|
199
|
+
软链按软链本身复制;普通文件用 copy2 保留权限位和时间戳。
|
|
200
|
+
目录权限在内容复制完后再设置,避免只读目录挡住后续写入。
|
|
201
|
+
on_progress 在每个普通文件复制后调用一次,供测试在复制中途注入中断。
|
|
202
|
+
"""
|
|
203
|
+
skipped: List[str] = []
|
|
204
|
+
dir_modes: List[Tuple[str, str]] = []
|
|
205
|
+
os.makedirs(dst)
|
|
206
|
+
dir_modes.append((src, dst))
|
|
207
|
+
for dirpath, dirnames, filenames in os.walk(src, onerror=_raise, followlinks=False):
|
|
208
|
+
rel_dir = os.path.relpath(dirpath, src)
|
|
209
|
+
target_dir = dst if rel_dir == "." else os.path.join(dst, rel_dir)
|
|
210
|
+
for name in list(dirnames) + list(filenames):
|
|
211
|
+
full = os.path.join(dirpath, name)
|
|
212
|
+
target = os.path.join(target_dir, name)
|
|
213
|
+
kind = entry_kind(full)
|
|
214
|
+
if kind == KIND_LINK:
|
|
215
|
+
os.symlink(os.readlink(full), target)
|
|
216
|
+
elif kind == KIND_DIR:
|
|
217
|
+
os.mkdir(target)
|
|
218
|
+
dir_modes.append((full, target))
|
|
219
|
+
elif kind == KIND_FILE:
|
|
220
|
+
shutil.copy2(full, target, follow_symlinks=False)
|
|
221
|
+
if on_progress is not None:
|
|
222
|
+
on_progress(target)
|
|
223
|
+
elif kind == KIND_SPECIAL:
|
|
224
|
+
skipped.append(os.path.relpath(full, src))
|
|
225
|
+
for source_dir, target_dir in reversed(dir_modes):
|
|
226
|
+
shutil.copystat(source_dir, target_dir, follow_symlinks=False)
|
|
227
|
+
return skipped
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def diff_manifests(expected: Dict[str, ManifestEntry], actual: Dict[str, ManifestEntry]) -> List[str]:
|
|
231
|
+
"""返回两份清单不一致的相对路径(排序后),空列表表示完全一致。"""
|
|
232
|
+
mismatched = [rel for rel in expected if actual.get(rel) != expected[rel]]
|
|
233
|
+
mismatched.extend(rel for rel in actual if rel not in expected)
|
|
234
|
+
return sorted(set(mismatched))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def display_path(path: str) -> str:
|
|
238
|
+
"""把 HOME 下的路径缩写成 `~/…`,只用于给人看的输出。
|
|
239
|
+
|
|
240
|
+
脚本读取的输出(`path` 命令、`--json`)不得使用它:`~` 交给不经 shell 的程序时不会被展开。
|
|
241
|
+
HOME 为空或为 `/` 时不缩写,否则所有路径都会变成 `~` 开头。
|
|
242
|
+
"""
|
|
243
|
+
home = os.path.expanduser("~")
|
|
244
|
+
if not home or home == os.sep:
|
|
245
|
+
return path
|
|
246
|
+
if path == home:
|
|
247
|
+
return "~"
|
|
248
|
+
if path.startswith(home + os.sep):
|
|
249
|
+
return "~" + path[len(home):]
|
|
250
|
+
return path
|
multi_codex/identity.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""账号的凭据存储模式与登录身份:只读本地文件,从不联网,也不输出任何令牌。
|
|
2
|
+
|
|
3
|
+
两类调用方:
|
|
4
|
+
- 迁移预检(fix-keyring-migration):凭据存在系统钥匙串时,迁移会改变目录真实路径,
|
|
5
|
+
Codex 按新路径算出的钥匙串键找不到旧条目,登录就丢了,所以要先知道存储模式;
|
|
6
|
+
- list / usage / doctor(feature-account-insight):显示每个账号登录的邮箱与套餐,检测重复登录。
|
|
7
|
+
|
|
8
|
+
字段与取值都以 openai/codex 源码为准(基线 6b4daafd),出处见两份方案文档 §1 / §3。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from typing import Dict, Iterable, List, NamedTuple, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
STORE_FILE = "file"
|
|
18
|
+
STORE_KEYRING = "keyring"
|
|
19
|
+
STORE_AUTO = "auto"
|
|
20
|
+
STORE_EPHEMERAL = "ephemeral"
|
|
21
|
+
|
|
22
|
+
# 未设置时 Codex 的默认值(上游 config/defaults.toml:6)。
|
|
23
|
+
DEFAULT_STORE = STORE_FILE
|
|
24
|
+
|
|
25
|
+
# 系统级配置层,优先级低于账号目录里的 config.toml(上游 config/src/loader/mod.rs:79)。
|
|
26
|
+
SYSTEM_CONFIG = "/etc/codex/config.toml"
|
|
27
|
+
# 测试专用:把系统级配置换成临时文件,避免开发机上真实的 /etc/codex/config.toml 影响结果。
|
|
28
|
+
SYSTEM_CONFIG_ENV = "MULTI_CODEX_TEST_SYSTEM_CONFIG"
|
|
29
|
+
|
|
30
|
+
LOGIN_CHATGPT = "chatgpt"
|
|
31
|
+
LOGIN_APIKEY = "apikey"
|
|
32
|
+
LOGIN_LOGGED_OUT = "logged-out"
|
|
33
|
+
LOGIN_KEYRING = "keyring"
|
|
34
|
+
LOGIN_UNREADABLE = "unreadable"
|
|
35
|
+
LOGIN_OTHER = "other"
|
|
36
|
+
|
|
37
|
+
_AUTH_CLAIMS = "https://api.openai.com/auth"
|
|
38
|
+
_PROFILE_CLAIMS = "https://api.openai.com/profile"
|
|
39
|
+
|
|
40
|
+
# 只认顶层的 `cli_auth_credentials_store = "..."`,值可用单引号或双引号,允许行尾注释。
|
|
41
|
+
_STORE_LINE = re.compile(r"""^\s*cli_auth_credentials_store\s*=\s*(?:"([^"]*)"|'([^']*)')\s*(?:#.*)?$""")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Identity(NamedTuple):
|
|
45
|
+
"""一个账号目录的登录状态。
|
|
46
|
+
|
|
47
|
+
user_id、workspace_id 只用于比较重复登录,不显示、不写进 JSON。
|
|
48
|
+
plan 是 id_token 签发时的套餐,下次刷新令牌后才会更新。
|
|
49
|
+
"""
|
|
50
|
+
login: str
|
|
51
|
+
email: Optional[str]
|
|
52
|
+
plan: Optional[str]
|
|
53
|
+
user_id: Optional[str]
|
|
54
|
+
workspace_id: Optional[str]
|
|
55
|
+
store: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _system_config_path() -> str:
|
|
59
|
+
return os.environ.get(SYSTEM_CONFIG_ENV) or SYSTEM_CONFIG
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _read_store_key(path: str) -> Optional[str]:
|
|
63
|
+
"""在第一个表头之前找 cli_auth_credentials_store;找不到或读不了时返回 None。
|
|
64
|
+
|
|
65
|
+
Python 3.8 没有 tomllib,这里只做最小解析。已知局限:多行字符串中以 `[` 开头的行会被当成表头,
|
|
66
|
+
带引号的键名识别不了。两种情况都按“没有这个键”处理,最坏是漏报,与没有这项检查时一样。
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
70
|
+
lines = handle.read().splitlines()
|
|
71
|
+
except OSError:
|
|
72
|
+
return None
|
|
73
|
+
for line in lines:
|
|
74
|
+
if line.strip().startswith("["):
|
|
75
|
+
# 这个键属于顶层 ConfigToml,写在任何表(如 [profiles.x])里都不生效。
|
|
76
|
+
return None
|
|
77
|
+
match = _STORE_LINE.match(line)
|
|
78
|
+
if match:
|
|
79
|
+
return match.group(1) if match.group(1) is not None else match.group(2)
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def credentials_store(account_dir: str) -> Tuple[str, Optional[str]]:
|
|
84
|
+
"""返回 (存储模式, 取值来源文件)。两层都没有设置时返回 (file, None)。
|
|
85
|
+
|
|
86
|
+
按 Codex 的配置层优先级:账号目录的 config.toml(User 层)高于系统级配置(System 层)。
|
|
87
|
+
托管配置、MDM、项目目录 .codex/ 中的设置不在考虑范围内(方案 §2 非目标)。
|
|
88
|
+
"""
|
|
89
|
+
for path in (os.path.join(account_dir, "config.toml"), _system_config_path()):
|
|
90
|
+
value = _read_store_key(path)
|
|
91
|
+
if value is not None:
|
|
92
|
+
return value, path
|
|
93
|
+
return DEFAULT_STORE, None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _decode_jwt_payload(token: str) -> Optional[dict]:
|
|
97
|
+
"""只做 base64url 解码,不验签,与 Codex 自己读 id_token 的方式一致(上游 token_data.rs:129-140)。"""
|
|
98
|
+
parts = token.split(".")
|
|
99
|
+
if len(parts) < 2:
|
|
100
|
+
return None
|
|
101
|
+
payload = parts[1] + "=" * (-len(parts[1]) % 4)
|
|
102
|
+
try:
|
|
103
|
+
claims = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")).decode("utf-8"))
|
|
104
|
+
except (ValueError, UnicodeError):
|
|
105
|
+
return None
|
|
106
|
+
return claims if isinstance(claims, dict) else None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _text(value: object) -> Optional[str]:
|
|
110
|
+
return value if isinstance(value, str) and value else None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def read_identity(account_dir: str) -> Identity:
|
|
114
|
+
"""读取一个账号目录的登录身份(判定顺序见 feature-account-insight §5.1.1)。
|
|
115
|
+
|
|
116
|
+
令牌只在本函数内部解码,不放进返回值,也不出现在任何异常信息里。
|
|
117
|
+
"""
|
|
118
|
+
store, _ = credentials_store(account_dir)
|
|
119
|
+
if store == STORE_KEYRING:
|
|
120
|
+
# keyring 模式只读钥匙串、不回落读文件(上游 storage.rs:310-313),
|
|
121
|
+
# 从 file 模式切过来时残留的 auth.json 不代表当前登录。
|
|
122
|
+
return Identity(LOGIN_KEYRING, None, None, None, None, store)
|
|
123
|
+
path = os.path.join(account_dir, "auth.json")
|
|
124
|
+
if not os.path.lexists(path):
|
|
125
|
+
login = LOGIN_KEYRING if store == STORE_AUTO else LOGIN_LOGGED_OUT
|
|
126
|
+
return Identity(login, None, None, None, None, store)
|
|
127
|
+
try:
|
|
128
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
129
|
+
data = json.load(handle)
|
|
130
|
+
except (OSError, ValueError):
|
|
131
|
+
return Identity(LOGIN_UNREADABLE, None, None, None, None, store)
|
|
132
|
+
if not isinstance(data, dict):
|
|
133
|
+
return Identity(LOGIN_UNREADABLE, None, None, None, None, store)
|
|
134
|
+
|
|
135
|
+
tokens = data.get("tokens")
|
|
136
|
+
id_token = tokens.get("id_token") if isinstance(tokens, dict) else None
|
|
137
|
+
if isinstance(id_token, str) and id_token:
|
|
138
|
+
claims = _decode_jwt_payload(id_token)
|
|
139
|
+
if claims is None:
|
|
140
|
+
return Identity(LOGIN_UNREADABLE, None, None, None, None, store)
|
|
141
|
+
auth = claims.get(_AUTH_CLAIMS)
|
|
142
|
+
auth = auth if isinstance(auth, dict) else {}
|
|
143
|
+
profile = claims.get(_PROFILE_CLAIMS)
|
|
144
|
+
profile = profile if isinstance(profile, dict) else {}
|
|
145
|
+
email = _text(claims.get("email")) or _text(profile.get("email"))
|
|
146
|
+
user_id = _text(auth.get("chatgpt_user_id")) or _text(auth.get("user_id"))
|
|
147
|
+
workspace_id = _text(auth.get("chatgpt_account_id")) or _text(tokens.get("account_id"))
|
|
148
|
+
return Identity(LOGIN_CHATGPT, email, _text(auth.get("chatgpt_plan_type")), user_id, workspace_id, store)
|
|
149
|
+
if data.get("auth_mode") == "apikey" or _text(data.get("OPENAI_API_KEY")):
|
|
150
|
+
return Identity(LOGIN_APIKEY, None, None, None, None, store)
|
|
151
|
+
return Identity(LOGIN_OTHER, None, None, None, None, store)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def duplicate_groups(identities: Iterable[Tuple[str, Identity]]) -> List[List[str]]:
|
|
155
|
+
"""找出登录了同一个 ChatGPT 账号和同一个工作区的账号组(每组至少两个)。
|
|
156
|
+
|
|
157
|
+
同一个邮箱在不同工作区登录不算重复:它们的额度互相独立。
|
|
158
|
+
任一 ID 缺失的账号不参与比较,否则两个都缺 ID 的账号会被误判为同一个。
|
|
159
|
+
"""
|
|
160
|
+
groups: Dict[Tuple[str, str], List[str]] = {}
|
|
161
|
+
for name, identity in identities:
|
|
162
|
+
if identity.login == LOGIN_CHATGPT and identity.user_id and identity.workspace_id:
|
|
163
|
+
groups.setdefault((identity.user_id, identity.workspace_id), []).append(name)
|
|
164
|
+
return [names for names in groups.values() if len(names) > 1]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def display_login(identity: Identity) -> str:
|
|
168
|
+
"""list 表格中 LOGIN 列的取值。"""
|
|
169
|
+
if identity.login == LOGIN_CHATGPT:
|
|
170
|
+
return identity.email or "chatgpt"
|
|
171
|
+
return {LOGIN_APIKEY: "api-key", LOGIN_LOGGED_OUT: "-"}.get(identity.login, identity.login)
|