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/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
@@ -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)