From 7f5a8d600fc8888c553d7ea747837f7e704e47a4 Mon Sep 17 00:00:00 2001 From: FridrichMethod Date: Wed, 21 Oct 2026 04:13:42 -0700 Subject: [PATCH 1/3] test: model dotfiles' own coding-agent-kit package dotfiles now carries the kit at the root of its own Stow package, common/coding-agent-kit, with the 14 links at the same package-relative paths. The tests that build a dotfiles-style host (two-hop links from HOME through the package into the kit) follow that layout. --- tests/standalone-e2e.sh | 10 +++++----- tests/test_config.py | 23 ++++++++++++----------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/tests/standalone-e2e.sh b/tests/standalone-e2e.sh index a7e5740..233a2a2 100755 --- a/tests/standalone-e2e.sh +++ b/tests/standalone-e2e.sh @@ -391,17 +391,17 @@ done printf '==> refusals: a file, a directory, a link into another checkout, a Stow link into this one\n' home refusal mkdir -p "$H/.local/bin" "$H/.local/share/bash-completion/completions/codex-as" \ - "$H/.local/share/zsh/site-functions" "$H/dotfiles/common/codex/.local/bin" + "$H/.local/share/zsh/site-functions" "$H/dotfiles/common/coding-agent-kit/.local/bin" printf 'not a link\n' >"$H/.local/bin/claude-as" ln -s "$OTHER/completions/zsh/_claude-as" "$H/.local/share/zsh/site-functions/_claude-as" # A dotfiles host: HOME link (Stow, relative) -> package link -> this checkout. -ln -s "$KIT/bin/codex-as" "$H/dotfiles/common/codex/.local/bin/codex-as" -ln -s ../../dotfiles/common/codex/.local/bin/codex-as "$H/.local/bin/codex-as" -refuse refusal "install.sh: refusing $H/.local/bin/codex-as: a symlink to ../../dotfiles/common/codex/.local/bin/codex-as, not an absolute link into $KIT +ln -s "$KIT/bin/codex-as" "$H/dotfiles/common/coding-agent-kit/.local/bin/codex-as" +ln -s ../../dotfiles/common/coding-agent-kit/.local/bin/codex-as "$H/.local/bin/codex-as" +refuse refusal "install.sh: refusing $H/.local/bin/codex-as: a symlink to ../../dotfiles/common/coding-agent-kit/.local/bin/codex-as, not an absolute link into $KIT install.sh: refusing $H/.local/share/bash-completion/completions/codex-as: a directory, not an absolute link into $KIT install.sh: refusing $H/.local/bin/claude-as: a file, not an absolute link into $KIT install.sh: refusing $H/.local/share/zsh/site-functions/_claude-as: a symlink to $OTHER/completions/zsh/_claude-as, not an absolute link into $KIT" \ - "kept $H/.local/bin/codex-as: a symlink to ../../dotfiles/common/codex/.local/bin/codex-as, not an absolute link into $KIT + "kept $H/.local/bin/codex-as: a symlink to ../../dotfiles/common/coding-agent-kit/.local/bin/codex-as, not an absolute link into $KIT kept $H/.local/share/bash-completion/completions/codex-as: a directory, not an absolute link into $KIT kept $H/.local/bin/claude-as: a file, not an absolute link into $KIT kept $H/.local/share/zsh/site-functions/_claude-as: a symlink to $OTHER/completions/zsh/_claude-as, not an absolute link into $KIT" diff --git a/tests/test_config.py b/tests/test_config.py index afd1106..a57538b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -48,16 +48,16 @@ stream.write(json.dumps(record) + "\n") print("stdout sentinel") ''' -PACKAGES = ( # dotfiles package path (from common/) and kit path, as dotfiles links them - ("codex/.local/bin/codex-as", "bin/codex-as"), - *((f"codex/.local/lib/codex-as/{name}.py", f"lib/codex-as/{name}.py") +PACKAGES = ( # dotfiles' coding-agent-kit package path (from common/) and kit path, as dotfiles links them + ("coding-agent-kit/.local/bin/codex-as", "bin/codex-as"), + *((f"coding-agent-kit/.local/lib/codex-as/{name}.py", f"lib/codex-as/{name}.py") for name in ("launch", "check", "layout", "handoff")), - ("codex/.local/share/bash-completion/completions/codex-as", "completions/bash/codex-as"), - ("codex/.oh-my-zsh/custom/completions/_codex-as", "completions/zsh/_codex-as"), - ("claude/.local/bin/claude-as", "bin/claude-as"), - ("claude/.local/lib/claude-as/launch.py", "lib/claude-as/launch.py"), - ("claude/.local/share/bash-completion/completions/claude-as", "completions/bash/claude-as"), - ("claude/.oh-my-zsh/custom/completions/_claude-as", "completions/zsh/_claude-as"), + ("coding-agent-kit/.local/share/bash-completion/completions/codex-as", "completions/bash/codex-as"), + ("coding-agent-kit/.oh-my-zsh/custom/completions/_codex-as", "completions/zsh/_codex-as"), + ("coding-agent-kit/.local/bin/claude-as", "bin/claude-as"), + ("coding-agent-kit/.local/lib/claude-as/launch.py", "lib/claude-as/launch.py"), + ("coding-agent-kit/.local/share/bash-completion/completions/claude-as", "completions/bash/claude-as"), + ("coding-agent-kit/.oh-my-zsh/custom/completions/_claude-as", "completions/zsh/_claude-as"), ) spec = importlib.util.spec_from_file_location("kit_config_under_test", ROOT / "lib/coding-agent-kit/config.py") @@ -672,10 +672,11 @@ def credentials(secrets, name): "refresh_token": "fabricated-refresh-" + name, "account_id": name}})) def linked_home(self, home): - """A dotfiles host: HOME/X -> (Stow, relative) package X -> (relative) kit file; two hops.""" + """A dotfiles host: HOME/X -> (Stow, relative) package X -> (relative) kit file; two hops. + The kit is the checkout at the root of dotfiles' own coding-agent-kit package.""" self.make_home(home) common = home / "dotfiles/common" - kit = install(common / "codex/coding-agent-kit", ("bin", "lib", "docs", "completions")) + kit = install(common / "coding-agent-kit/coding-agent-kit", ("bin", "lib", "docs", "completions")) for package, target in PACKAGES: link = common / package link.parent.mkdir(parents=True, exist_ok=True) From 2b97706c739baa007896d388b5002c07ed9a36a2 Mon Sep 17 00:00:00 2001 From: FridrichMethod Date: Wed, 21 Oct 2026 04:13:57 -0700 Subject: [PATCH 2/3] feat: keep last-seen account details and print them offline Each launcher now keeps a small metadata cache per account in ${XDG_STATE_HOME:-~/.local/state}/coding-agent-kit/accounts/ (0700; each file 0600, replaced atomically and only on change, with one writer; no token, and no time from the launcher's clock): - codex-as records the email and plan from the id_token before the run, and after it the newest codex rate-limit snapshot of the run's own rollouts: the thread it resumed or recovered, or one whose session_meta names this account's ChatGPT user and workspace. Best effort; it never fails a launch or changes the exit code. New lib/codex-as/usage.py. - claude-as asks for an optional label after the enrolment confirmation and saves it after the token; control characters, more than 100 characters and anything containing sk-ant- are refused. claude-as --set-label NAME [LABEL] sets, asks for or clears it. - claude-as passes its child CODING_AGENT_KIT_USAGE_FILE and CODING_AGENT_KIT_TOKEN_ID; helpers/statusline.cjs records the session's rate_limits there only while CLAUDE_CODE_OAUTH_TOKEN hashes to that identifier, into an owned 0700 directory, never on Windows, without changing what it prints. New metadata-only queries, which create nothing, start nothing and open no credential: --account-info NAME and --account-labels for both launchers. The zsh completions show the email or label as descriptions. Config gains state_dir (not a key); lib/coding-agent-kit/accounts.py is the shared cache. The test entry points also unset the two new variables and XDG_STATE_HOME. --- .pre-commit-config.yaml | 6 + bin/claude-as | 2 + bin/codex-as | 24 +- completions/zsh/_claude-as | 6 +- completions/zsh/_codex-as | 6 +- helpers/statusline.cjs | 73 +++- lib/claude-as/launch.py | 103 +++++- lib/codex-as/check.py | 12 +- lib/codex-as/launch.py | 49 ++- lib/codex-as/layout.py | 36 +- lib/codex-as/usage.py | 167 +++++++++ lib/coding-agent-kit/accounts.py | 270 ++++++++++++++ lib/coding-agent-kit/config.py | 11 +- tests/COVERAGE.md | 13 +- tests/account-enrollment.sh | 2 +- tests/account-info.sh | 11 + tests/claude-customizations.cjs | 110 +++++- tests/codex-as.sh | 2 +- tests/config.sh | 2 +- tests/contract.sh | 4 +- tests/run.ps1 | 2 +- tests/run.sh | 3 +- tests/standalone-e2e.sh | 21 +- tests/test-entrypoints.sh | 4 +- tests/test_account_enrollment.py | 97 ++++- tests/test_account_info.py | 583 +++++++++++++++++++++++++++++++ tests/test_codex_as.py | 10 +- tests/test_config.py | 80 ++++- 28 files changed, 1651 insertions(+), 58 deletions(-) create mode 100644 lib/codex-as/usage.py create mode 100644 lib/coding-agent-kit/accounts.py create mode 100755 tests/account-info.sh create mode 100644 tests/test_account_info.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4a66ce3..705e56a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -89,6 +89,12 @@ repos: language: script pass_filenames: false files: ^(bin/.*|lib/.*|completions/.*|docs/account-launchers\.md|tests/(config\.sh|test_config\.py|test_codex_as\.py))$ + - id: account-info + name: account metadata, Codex usage scan and queries (isolated HOME and fakes) + entry: tests/account-info.sh + language: script + pass_filenames: false + files: ^(bin/.*|lib/.*|tests/(account-info\.sh|test_account_info\.py))$ - id: test-entrypoints name: test entry points and kit knob hygiene entry: tests/test-entrypoints.sh diff --git a/bin/claude-as b/bin/claude-as index d0a8170..026c3d4 100755 --- a/bin/claude-as +++ b/bin/claude-as @@ -62,6 +62,8 @@ if [ "$#" -eq 0 ] || [ "$1" = --help ]; then 'login/logout/setup-token, --bare, unsupported flags and auth/provider overrides are refused.' \ 'Daily configuration is inherited. No global process killing or concurrency lock.' \ 'Requires Python 3.11+ (stdlib).' \ + '--account-info NAME: its label and the last usage its status line saw (offline, no token read);' \ + '--account-labels: NAMElabel lines for completion; --set-label NAME [LABEL] sets or clears a label.' \ 'Configuration: ${XDG_CONFIG_HOME:-~/.config}/coding-agent-kit/config.toml; --show-config prints the effective values.' \ "Details and manual repair: $_claude_as_docs" [ "$#" -gt 0 ] && exit 0 diff --git a/bin/codex-as b/bin/codex-as index af7b736..af95032 100755 --- a/bin/codex-as +++ b/bin/codex-as @@ -72,6 +72,8 @@ if [ "$#" -eq 0 ] || [ "$1" = --help ]; then 'Same-account OAuth refresh retains native cross-process limitations.' \ 'Resume by UUID, --last or picker; explicit session names are refused.' \ 'Requires Python 3.11+ (stdlib) and Codex 0.161.0 or newer; no upper version limit.' \ + '--account-info NAME: last-seen email, plan and usage of a run of NAME (offline, no credential read);' \ + '--account-labels: NAMEemail lines for completion; --list-accounts: the names.' \ 'Configuration: ${XDG_CONFIG_HOME:-~/.config}/coding-agent-kit/config.toml; --show-config prints the effective values.' \ "Details and manual repair: $_codex_as_docs" [ "$#" -gt 0 ] && exit 0 @@ -79,16 +81,20 @@ if [ "$#" -eq 0 ] || [ "$1" = --help ]; then fi _codex_as_locate || exit 1 -# --list-accounts and --show-config alone are the launcher's own metadata queries; -# no account name starts with -. -if [ "$#" -ne 1 ] || { [ "$1" != --list-accounts ] && [ "$1" != --show-config ]; }; then - case $1 in - '' | -* | *[!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-]*) - _codex_as_message 31 2 'codex-as: invalid account name (ASCII letters, digits, _ or -; no leading -).' - exit 2 - ;; - esac +# --list-accounts, --account-labels and --show-config alone, and --account-info +# NAME, are the launcher's own metadata queries; no account name starts with -. +_codex_as_name=${1-} +if [ "$#" -eq 1 ] && { [ "$1" = --list-accounts ] || [ "$1" = --account-labels ] || [ "$1" = --show-config ]; }; then + _codex_as_name=query +elif [ "$#" -eq 2 ] && [ "$1" = --account-info ]; then + _codex_as_name=$2 fi +case $_codex_as_name in + '' | -* | *[!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-]*) + _codex_as_message 31 2 'codex-as: invalid account name (ASCII letters, digits, _ or -; no leading -).' + exit 2 + ;; +esac if ! command -v python3 >/dev/null 2>&1; then _codex_as_message 31 2 'codex-as: Python 3.11+ is required for local JSON and file checks.' exit 1 diff --git a/completions/zsh/_claude-as b/completions/zsh/_claude-as index 1804b51..f562c80 100644 --- a/completions/zsh/_claude-as +++ b/completions/zsh/_claude-as @@ -2,9 +2,11 @@ local -a names suggestions if ((CURRENT == 2)); then - # Metadata only: claude-as --list-accounts never starts native Claude. - names=("${(@f)$(command claude-as --list-accounts 2>/dev/null)}") + # Metadata only: claude-as --account-labels never starts native Claude or reads + # a credential. NAMELABEL lines become NAME:LABEL descriptions. + names=("${(@f)$(command claude-as --account-labels 2>/dev/null)}") names=("${(@)names:#}") + names=("${(@)names/$'\t'/:}") ((${#names})) && _describe -t accounts 'Claude accounts' names return fi diff --git a/completions/zsh/_codex-as b/completions/zsh/_codex-as index 05fd424..8cf203f 100644 --- a/completions/zsh/_codex-as +++ b/completions/zsh/_codex-as @@ -2,9 +2,11 @@ local -a names suggestions if ((CURRENT == 2)); then - # Metadata only: codex-as --list-accounts never starts native Codex. - names=("${(@f)$(command codex-as --list-accounts 2>/dev/null)}") + # Metadata only: codex-as --account-labels never starts native Codex or reads + # a credential. NAMELABEL lines become NAME:LABEL descriptions. + names=("${(@f)$(command codex-as --account-labels 2>/dev/null)}") names=("${(@)names:#}") + names=("${(@)names/$'\t'/:}") ((${#names})) && _describe -t accounts 'Codex accounts' names return fi diff --git a/helpers/statusline.cjs b/helpers/statusline.cjs index 40d4f7c..6206188 100644 --- a/helpers/statusline.cjs +++ b/helpers/statusline.cjs @@ -1,5 +1,6 @@ 'use strict'; +const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); const { spawnSync } = require('node:child_process'); @@ -37,6 +38,71 @@ function formatStatus(input, branch = '') { return [model, effort, location, `ctx ${percentage}`, dollars].filter(Boolean).join(' | '); } +const USAGE_FILE = /^claude-[A-Za-z0-9_][A-Za-z0-9_-]*\.usage\.json$/; +const RECORD_AGAIN = 300; +const LAST_TIME = 253402300800; // 10000-01-01 UTC: no calendar time from here on + +function usageWindow(value) { + const used = value?.used_percentage; + const resets = value?.resets_at; + if (typeof used !== 'number' || !Number.isFinite(used) || used < 0 || used > 100) return null; + return Number.isInteger(resets) && resets > 0 && resets < LAST_TIME ? { used_percentage: used, resets_at: resets } : null; +} + +// The last 5-hour and weekly rate limits of the claude-as account this session +// runs under, for claude-as --account-info. claude-as names the file and a hash +// of its token; nothing is recorded unless the session still runs under that +// token, on Windows, outside an owned 0700 directory, or when the values are +// unchanged and less than five minutes old. Returns whether it wrote. +function recordUsage(data, env = process.env, now = Date.now()) { + if (process.platform === 'win32') return false; + const file = env.CODING_AGENT_KIT_USAGE_FILE; + const token = env.CLAUDE_CODE_OAUTH_TOKEN; + if (!file || !token || !env.CODING_AGENT_KIT_TOKEN_ID) return false; + if (crypto.createHash('sha256').update(token).digest('hex').slice(0, 16) !== env.CODING_AGENT_KIT_TOKEN_ID) return false; + if (!path.isAbsolute(file) || !USAGE_FILE.test(path.basename(file))) return false; + const record = { + five_hour: usageWindow(data?.rate_limits?.five_hour), + seven_day: usageWindow(data?.rate_limits?.seven_day), + }; + if (!record.five_hour && !record.seven_day) return false; + const folder = path.dirname(file); + const info = fs.lstatSync(folder); + if (!info.isDirectory() || info.uid !== process.getuid() || (info.mode & 0o777) !== 0o700) return false; + const seen = Math.floor(now / 1000); + let old = null; + try { + if (fs.lstatSync(file).isFile()) old = JSON.parse(fs.readFileSync(file, 'utf8')); + } catch { + // No usable earlier record: write a fresh one. + } + if (old && JSON.stringify([old.five_hour, old.seven_day]) === JSON.stringify([record.five_hour, record.seven_day]) + && Number.isInteger(old.seen_at) && seen - old.seen_at < RECORD_AGAIN) return false; + // A status line run that Claude Code cancelled may have left its temporary file. + const prefix = `.${path.basename(file)}.`; + for (const name of fs.readdirSync(folder)) { + if (name.startsWith(prefix) && name.endsWith('.tmp')) { + const stale = path.join(folder, name); + if (now - fs.lstatSync(stale).mtimeMs > 60000) fs.rmSync(stale, { force: true }); + } + } + const temporary = path.join(folder, `${prefix}${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`); + const fd = fs.openSync(temporary, 'wx', 0o600); + try { + fs.fchmodSync(fd, 0o600); + fs.writeSync(fd, `${JSON.stringify({ ...record, seen_at: seen })}\n`); + } finally { + fs.closeSync(fd); + } + try { + fs.renameSync(temporary, file); + } catch (error) { + fs.rmSync(temporary, { force: true }); + throw error; + } + return true; +} + function main() { let data = {}; try { @@ -44,8 +110,13 @@ function main() { } catch { // Optional UI data can be absent during startup; keep the footer quiet. } + try { + recordUsage(data); + } catch { + // Recording is a side note; the footer never depends on it. + } process.stdout.write(`${formatStatus(data, gitBranch(data?.workspace?.current_dir || data?.cwd))}\n`); } -module.exports = { formatStatus, gitBranch, main }; +module.exports = { formatStatus, gitBranch, main, recordUsage }; if (require.main === module) main(); diff --git a/lib/claude-as/launch.py b/lib/claude-as/launch.py index a1a084d..8a8019a 100644 --- a/lib/claude-as/launch.py +++ b/lib/claude-as/launch.py @@ -1,6 +1,7 @@ """Small native-login adapter; no OAuth endpoints, refresh, logs or credential output.""" import codecs +import hashlib import importlib.util import json import os @@ -12,6 +13,7 @@ import sys import tempfile import termios +import time from urllib.parse import urlsplit @@ -343,27 +345,108 @@ def save(path, token): raise -def kit_config(): - """lib/coding-agent-kit/config.py by path, from the real file, loaded after the version check.""" +def kit_module(name): + """lib/coding-agent-kit/NAME.py by path, from the real file, loaded after the version check.""" lib = Path(os.path.realpath(__file__)).parent.parent - spec = importlib.util.spec_from_file_location("claude_as_config", lib / "coding-agent-kit/config.py") + spec = importlib.util.spec_from_file_location("claude_as_" + name, lib / f"coding-agent-kit/{name}.py") module = importlib.util.module_from_spec(spec) try: spec.loader.exec_module(module) except (OSError, ImportError, SyntaxError): fail(f"incomplete install at {lib.parent}") - config = module.load() + return module + + +def kit_config(): + config = kit_module("config").load() for warning in config.warnings: print(styled("claude-as: " + warning, "33", 2), file=sys.stderr) return config +def ask_label(tty, metadata): + """An optional label, asked after the confirmation and before any token exists.""" + for _ in range(3): + tty.write("Label for this account (optional, e.g. your email): ") + label = tty.read().strip() + problem = metadata.label_problem(label) + if not problem: + return label or None + tty.write(f"Label refused: {problem}.\n", "33") + tty.write("No label saved; set one later with claude-as --set-label.\n", "33") + return None + + +def save_label(config, metadata, account, label): + """Best effort after the token is saved: a label problem never loses the token.""" + try: + metadata.write(metadata.directory(config, create=True) / f"claude-{account}.json", {"label": label}) + except (OSError, ValueError): + print(styled("claude-as: the label was not saved; set it with claude-as --set-label.", "33", 2), file=sys.stderr) + + +def set_label(config, metadata, args): + """claude-as --set-label NAME [LABEL]: metadata only; an empty label clears it.""" + name = args[0] + if not NAME.fullmatch(name) or name.startswith("-"): + fail("invalid account name (ASCII letters, digits, _ or -; no leading -).") + if name not in accounts(config.secrets_dir, config): + fail(f"no account {name}; run claude-as {name} to create it.") + if len(args) == 2: + label = args[1].strip() + else: + tty = Terminal() + try: + tty.write(f"Label for account {name} (empty clears it): ") + label = tty.read().strip() + finally: + tty.close() + problem = metadata.label_problem(label) + if problem: + fail(f"label refused: {problem}.") + path = metadata.directory(config, create=True) / f"claude-{name}.json" + if label: + metadata.write(path, {"label": label}) + elif os.path.lexists(path): + path.unlink() + print(styled(f"claude-as: label of {name} {'set' if label else 'cleared'}.", "32", 2), file=sys.stderr) + + +def usage_environment(env, config, metadata, account, token): + """The status line helper records this account's last rate limits through these, and + only while the token it runs under hashes to the same identifier.""" + for name in ("CODING_AGENT_KIT_USAGE_FILE", "CODING_AGENT_KIT_TOKEN_ID"): + env.pop(name, None) + try: + folder = metadata.directory(config, create=True) + except (OSError, ValueError): + return + env["CODING_AGENT_KIT_USAGE_FILE"] = str(folder / f"claude-{account}.usage.json") + env["CODING_AGENT_KIT_TOKEN_ID"] = hashlib.sha256(token.encode("ascii")).hexdigest()[:16] + + def main(): config = kit_config() secret = config.secrets_dir - if sys.argv[1:] in (["--list-accounts"], ["--show-config"]): - # Metadata only: no preflight, no directories and no native CLI. - text = "\n".join(accounts(secret, config)) if sys.argv[1] == "--list-accounts" else config.show("claude-as") + args = sys.argv[1:] + if args in (["--list-accounts"], ["--show-config"], ["--account-labels"]) or ( + len(args) == 2 and args[0] == "--account-info") or (args[:1] == ["--set-label"] and len(args) in (2, 3)): + # Metadata only: no preflight, no native CLI, and never a token read. + metadata = kit_module("accounts") + if args[0] == "--set-label": + set_label(config, metadata, args[1:]) + return + if args[0] == "--account-info": + name = args[1] + if not NAME.fullmatch(name) or name.startswith("-"): + fail("invalid account name (ASCII letters, digits, _ or -; no leading -).") + if name not in accounts(secret, config): + fail(f"no account {name}.") + text = metadata.info("claude-as", name, config, time.time()) + elif args[0] == "--account-labels": + text = "\n".join(metadata.labels("claude-as", accounts(secret, config), config)) + else: + text = "\n".join(accounts(secret, config)) if args[0] == "--list-accounts" else config.show("claude-as") if text: print(text) return @@ -398,16 +481,22 @@ def main(): try: tty.write("Existing accounts: " + (", ".join(accounts(secret, config)) or "(none)") + "\n") tty.confirm(f"Account {account} does not exist. Login and save a new account?") + metadata = kit_module("accounts") + label = ask_label(tty, metadata) token = enroll(executable, tty) secret.mkdir(mode=0o700, exist_ok=True) directory(secret, config) save(target, token) tty.write(f"Credentials saved for account {account}.\n", "32") + if label: + save_label(config, metadata, account, label) finally: tty.close() else: token = read_secret(target) + metadata = kit_module("accounts") env = os.environ.copy() + usage_environment(env, config, metadata, account, token) env["CLAUDE_CODE_OAUTH_TOKEN"] = token os.execve(executable, [executable, *args], env) diff --git a/lib/codex-as/check.py b/lib/codex-as/check.py index 2942847..a1c04a8 100644 --- a/lib/codex-as/check.py +++ b/lib/codex-as/check.py @@ -100,6 +100,12 @@ class Plan: rootless: bool # root help or version, update and completion need no account unknown: tuple # options this scan does not know (typos fail in clap after any takeover) profile: str | None # -p/--profile: layers codex_home/NAME.config.toml + forked: bool = False # fork or exec fork: recover_id is the source, not a thread this run writes + + @property + def continued(self): + """The threads this run appends to, an interactive or exec resume; a fork writes a new one.""" + return tuple(thread for thread in (self.resume_id, None if self.forked else self.recover_id) if thread) def session_id(text): @@ -166,7 +172,7 @@ def arguments(args, config): # options are forwarded as switches, so their values are scanned as words. mode, root, cwd = "interactive", True, Path.cwd() command, target, last, query, show_all = None, None, False, False, False - no_daemon, others, words, unknown, bare, profile = False, False, [], [], False, None + no_daemon, others, words, unknown, bare, profile, forked = False, False, [], [], False, None, False i = 0 while i < len(args): arg = args[i] @@ -227,6 +233,7 @@ def arguments(args, config): break # the rest is the sandboxed command line elif command == "exec": command = "exec-resume" if arg in {"resume", "fork"} else "exec-prompt" + forked = arg == "fork" elif command in {"resume", "fork", "exec-resume"} and target is None: target = arg else: @@ -256,7 +263,8 @@ def arguments(args, config): return Plan(mode, cwd, command, resume_id, target_id if command in {"fork", "exec-resume"} else None, scope, last and picking, others, no_daemon, bare or command in {"update", "completion"}, tuple(unknown), - profile if profile is not None and PROFILE.fullmatch(profile) else None) + profile if profile is not None and PROFILE.fullmatch(profile) else None, + forked=forked or command == "fork") diff --git a/lib/codex-as/launch.py b/lib/codex-as/launch.py index b4021fb..9ade028 100644 --- a/lib/codex-as/launch.py +++ b/lib/codex-as/launch.py @@ -10,6 +10,7 @@ import subprocess import sys import tempfile +import time # python3 -I puts no script directory on sys.path, so the modules load by path, @@ -28,7 +29,9 @@ def load_module(name, path): guard = load_module("check", "codex-as/check.py") # ends the run first on Python before 3.11 storage = load_module("layout", "codex-as/layout.py") handoff = load_module("handoff", "codex-as/handoff.py") + usage = load_module("usage", "codex-as/usage.py") settings = load_module("config", "coding-agent-kit/config.py") + metadata = load_module("accounts", "coding-agent-kit/accounts.py") except (OSError, ImportError, SyntaxError): message = f"codex-as: incomplete install at {LIB.parent}" print(guard.styled(message, "31", 2) if "guard" in globals() else message, file=sys.stderr) @@ -124,6 +127,32 @@ def exit_status(result): return 128 - result +def remember(config, layout, raw=None, plan=None, started=None): + """Best effort, for --account-info: this login's email and plan, and after a run the + last rate limits its rollouts show. Never fails a launch and writes no token.""" + try: + if raw is None: + raw = guard.credential(layout.target) + email, kind = storage.profile(raw) + seen = None if plan is None else usage.latest(layout.root, plan.continued, storage.owner(raw), started, time.time()) + # The old record is read only after the scan, just before the write, so a run of + # this account that finished meanwhile keeps its newer snapshot. + path = metadata.directory(config, create=True) / f"codex-{layout.name}.json" + try: + old = metadata.read(path) or {} + except ValueError: + old = {} + last = old.get("usage") if isinstance(old.get("usage"), dict) else None + known = last.get("seen_at") if last else None + # Concurrent runs of one account: the newest snapshot wins, by its own time. + if seen and (not isinstance(known, int) or seen["seen_at"] > known): + last = seen + record = {"email": email, "plan": kind, "usage": last} + metadata.write(path, {key: value for key, value in record.items() if value is not None}) + except (OSError, ValueError, TypeError, KeyError, AttributeError, UnicodeError, RecursionError): + pass + + def post_run(layout, baseline): """Repair or report the account link after native Codex exits; never changes the exit code.""" replaced = "Codex replaced the account auth.json link with a regular file; " @@ -157,9 +186,19 @@ def main(args): config = settings.load() for warning in config.warnings: guard.warn(warning) - if args in (["--list-accounts"], ["--show-config"]): - # Metadata only: no preflight, no directories and no native CLI. - text = "\n".join(guard.accounts(config.secrets_dir)) if args[0] == "--list-accounts" else config.show("codex-as") + if args in (["--list-accounts"], ["--show-config"], ["--account-labels"]) or ( + len(args) == 2 and args[0] == "--account-info"): + # Metadata only: no preflight, no directories, no native CLI and never a credential read. + if args[0] == "--account-info": + if not re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_-]*", args[1]): + guard.fail("invalid account name (ASCII letters, digits, _ or -; no leading -).") + if args[1] not in guard.accounts(config.secrets_dir): + guard.fail(f"no account {args[1]}.") + text = metadata.info("codex-as", args[1], config, time.time()) + elif args[0] == "--account-labels": + text = "\n".join(metadata.labels("codex-as", guard.accounts(config.secrets_dir), config)) + else: + text = "\n".join(guard.accounts(config.secrets_dir)) if args[0] == "--list-accounts" else config.show("codex-as") if text: print(text) return 0 @@ -212,6 +251,7 @@ def main(args): guard.fail("account has active clients and authentication needs repair; close all account clients first.") # Revalidate after EX->SH, whose conversion may release the lock briefly. baseline = layout.validate() + remember(config, layout, baseline) if plan.profile is not None: layout.link_profile(plan.profile) if plan.unknown and plan.command == "resume": @@ -226,6 +266,7 @@ def main(args): except BaseException: layout.close() raise + started = time.time() with Supervision(): try: result = run_native(command, env, layout.fd) @@ -233,6 +274,8 @@ def main(args): layout.close() # Use a fresh open-file description: surviving descendants still hold SH. post_run(layout, baseline) + if os.path.lexists(layout.target): + remember(config, layout, plan=plan, started=started) return result diff --git a/lib/codex-as/layout.py b/lib/codex-as/layout.py index 61a745a..48d0b75 100644 --- a/lib/codex-as/layout.py +++ b/lib/codex-as/layout.py @@ -82,16 +82,48 @@ def shared_sqlite(guard, root, cwd, profile=None): return result +def claims(data): + """The id_token payload of a ChatGPT login (decoded, never verified).""" + token = data["tokens"]["id_token"].split(".")[1] + return json.loads(base64.urlsafe_b64decode(token + "=" * (-len(token) % 4))) + + def identity(raw): data = json.loads(raw) if data.get("tokens") is None: return ("apikey", data.get("OPENAI_API_KEY")) - token = data["tokens"]["id_token"].split(".")[1] - payload = json.loads(base64.urlsafe_b64decode(token + "=" * (-len(token) % 4))) + payload = claims(data) auth = payload.get("https://api.openai.com/auth", {}) return ("chatgpt", payload.get("sub"), auth.get("chatgpt_user_id")) +def text(value): + return value if isinstance(value, str) and value else None + + +def profile(raw): + """(email, plan) of a login for --account-info; an API-key login has no email.""" + data = json.loads(raw) + if data.get("tokens") is None: + return None, "API key" + payload = claims(data) + auth = payload.get("https://api.openai.com/auth") or {} + named = payload.get("https://api.openai.com/profile") or {} + email = text(payload.get("email")) or (text(named.get("email")) if isinstance(named, dict) else None) + return email, text(auth.get("chatgpt_plan_type")) if isinstance(auth, dict) else None + + +def owner(raw): + """(ChatGPT user, workspace) as native Codex records a thread's creator, or None.""" + data = json.loads(raw) + if data.get("tokens") is None: + return None + auth = claims(data).get("https://api.openai.com/auth") or {} + user = text(auth.get("chatgpt_user_id")) or text(auth.get("user_id")) if isinstance(auth, dict) else None + account = text(data["tokens"].get("account_id")) + return (user, account) if user and account else None + + def same_identity(left, right): a, b = identity(left), identity(right) if a[0] != b[0]: diff --git a/lib/codex-as/usage.py b/lib/codex-as/usage.py new file mode 100644 index 0000000..c907bef --- /dev/null +++ b/lib/codex-as/usage.py @@ -0,0 +1,167 @@ +"""The last Codex rate-limit snapshot one codex-as run saw, read from its own rollouts. + +Best effort and offline: after native Codex exits, codex-as reads the tail of +the shared session rollouts written during the run and keeps the newest +token_count event with rate limits that belongs to this account. A rollout +belongs to it when its thread is the one this run resumed or recovered, or +when the thread's creator (as native Codex records it in session_meta) is this +account's ChatGPT user and workspace. The rollout format is Codex's own and +internal; anything unexpected yields nothing. Only regular files are opened, +never a FIFO, device or link, so nothing blocks after Codex exits. The threads +this run continues come first, then the newest rollouts; at most MAX_FILES of +this account's are read, after at most MAX_HEADS session_meta lines of others. +Known gaps: a session switched inside Codex (/resume) is missed, and another +account resuming, during the run, a thread this account created can be counted +here. +""" + +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import re +import stat +import time + + +TAIL = 1 << 20 +FIRST_LINE = 1 << 20 +MAX_FILES = 8 # rollouts of this account read in one run +MAX_HEADS = 64 # first lines of other rollouts read to find them +LAST = 253402300800 # 10000-01-01 UTC: no calendar time from here on +SLACK = 5 # seconds around the run, for clocks that tick between the launcher and Codex +NAME = re.compile(r"rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})" + r"(?:_[0-9a-f-]{36})?\.jsonl") + + +def days(start, end): + """The local and UTC date directories a rollout started in [start, end] can be in.""" + result, moment = set(), start - 86400 + while moment <= end + 86400: + for parts in (time.localtime(moment), time.gmtime(moment)): + result.add(f"{parts.tm_year:04d}/{parts.tm_mon:02d}/{parts.tm_mday:02d}") + moment += 3600 + return sorted(result) + + +def candidates(root, ids, started, ended): + """Regular rollouts modified during the run: the threads of IDS first, then the newest.""" + sessions = Path(root) / "sessions" + found = set() + for thread in ids: + found.update(sessions.glob(f"*/*/*/rollout-*-{thread}*.jsonl")) + for day in days(started, ended): + found.update((sessions / day).glob("rollout-*.jsonl")) + recent = [] + for path in found: + match = NAME.fullmatch(path.name) + try: + info = path.lstat() + except OSError: + continue + if match and stat.S_ISREG(info.st_mode) and info.st_mtime >= started - SLACK: + recent.append((match[1] not in ids, -info.st_mtime, str(path), path)) + return [path for *_, path in sorted(recent)] + + +def opened(path): + """PATH as a binary stream while it is a regular file; never waits on a FIFO or device.""" + stream = os.fdopen(os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC), "rb") + if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): + stream.close() + raise OSError(f"{path.name}: not a regular file") + return stream + + +def creator(stream): + """(creator user, creator workspace) from the rollout's session_meta line, or None.""" + stream.seek(0) + head = stream.read(FIRST_LINE).split(b"\n", 1)[0] + try: + line = json.loads(head) + except ValueError: + return None + payload = line.get("payload") if isinstance(line, dict) and line.get("type") == "session_meta" else None + if not isinstance(payload, dict): + return None + user, account = payload.get("creator_user_id"), payload.get("creator_account_id") + return (user, account) if isinstance(user, str) and isinstance(account, str) else None + + +def moment(text): + try: + stamp = datetime.fromisoformat(text) + except (TypeError, ValueError): + return None + return (stamp if stamp.tzinfo else stamp.replace(tzinfo=timezone.utc)).timestamp() + + +def window(value): + if value is None: + return None + if not isinstance(value, dict): + raise ValueError + used, minutes, resets = value.get("used_percent"), value.get("window_minutes"), value.get("resets_at") + if isinstance(used, bool) or not isinstance(used, (int, float)) or not 0 <= used <= 100: + raise ValueError + for item in (minutes, resets): + if item is not None and (isinstance(item, bool) or not isinstance(item, int) or item <= 0): + raise ValueError + if resets is not None and resets >= LAST: + raise ValueError + return {"used_percent": float(used), "window_minutes": minutes, "resets_at": resets} + + +def snapshots(stream, started, ended): + """(time, snapshot) of each usable token_count event in the rollout's tail.""" + start = max(0, os.fstat(stream.fileno()).st_size - TAIL) + stream.seek(start) + lines = stream.read(TAIL).split(b"\n") + if start: + lines = lines[1:] # the first line was cut + for raw in lines: + if b"token_count" not in raw: + continue + try: + line = json.loads(raw) + payload = line["payload"] + limits = payload["rate_limits"] if line["type"] == "event_msg" and payload["type"] == "token_count" else None + if not isinstance(limits, dict) or limits.get("limit_id") not in (None, "codex"): + continue + when = moment(line.get("timestamp")) + if when is None or not started - SLACK <= when <= ended + SLACK: + continue + windows = {key: window(limits.get(key)) for key in ("primary", "secondary")} + except (ValueError, KeyError, TypeError, RecursionError): + continue + if any(windows.values()): + yield when, windows + + +def latest(root, ids, account, started, ended): + """The newest snapshot of this run: {"primary", "secondary", "seen_at"}, or None. + + IDS are the thread UUIDs this run resumed or recovered; ACCOUNT is the + (ChatGPT user, workspace) pair of this login, or None for an API key.""" + ids = {thread for thread in ids if thread} + best, owned, heads = None, 0, 0 + for path in candidates(root, ids, started, ended): + continued = NAME.fullmatch(path.name)[1] in ids + # The threads of IDS come first: past them, only a creator match counts. + if owned == MAX_FILES or not continued and (account is None or heads == MAX_HEADS): + break + try: + with opened(path) as stream: + if not continued: + heads += 1 + if creator(stream) != account: + continue + owned += 1 + for when, windows in snapshots(stream, started, ended): + if best is None or when > best[0]: + best = (when, windows) + except OSError: + continue + if best is None: + return None + return {**best[1], "seen_at": int(best[0])} diff --git a/lib/coding-agent-kit/accounts.py b/lib/coding-agent-kit/accounts.py new file mode 100644 index 0000000..6d4a190 --- /dev/null +++ b/lib/coding-agent-kit/accounts.py @@ -0,0 +1,270 @@ +"""coding-agent-kit account metadata: last-seen identity, labels and usage, never a token. + +Public API (load this file by path; it is never on sys.path): + + directory(config, create=False) -> Path + config.state_dir / "accounts". With create, makes it 0700 (and its + parents under the launcher's umask) and checks that it is an owned, + real 0700 directory whose parent other users cannot change. Either + way it raises ValueError when it is config.secrets_dir, also through + a link: its files carry the Codex credentials' names. + read(path) -> dict | None + One metadata file, or None when it is absent. Never follows a link; + only an owned regular 0600 file with one link, at most 64 KiB, holding + a JSON object with unique keys. Raises ValueError otherwise. + write(path, data) -> bool + Replaces the file atomically (an O_EXCL|O_NOFOLLOW 0600 temporary file + in the same directory, then rename) unless it already holds exactly + this content; returns whether it wrote. + clean(value, limit) -> str | None + A string with C0, DEL and C1 characters removed and cut to limit, or + None for anything else or an empty result. + label_problem(text) -> str | None + Why TEXT cannot be a label: control characters, more than 100 + characters, or a Claude token prefix (a pasted token is never stored). + info(tool, name, config, now) -> str + The --account-info text of NAME for "codex-as" or "claude-as". + labels(tool, names, config) -> list[str] + "NAMELABEL" (the email for Codex, the label for Claude), or a bare + NAME, for each name: the --account-labels lines. Unsafe metadata, or a + directory that is the secrets directory, gives bare names. + +Files, each with one writer, under directory(config): + codex-NAME.json codex-as: email, plan and the last usage snapshot + claude-NAME.json claude-as: the label + claude-NAME.usage.json helpers/statusline.cjs: the last rate-limit windows +Times in them come from a token, a Codex rollout or the status line, never +from the launcher's clock; one from year 10000 on is ignored. +""" + +import json +import math +import os +from pathlib import Path +import re +import secrets +import stat +import time + + +LIMIT = 64 * 1024 +LABEL_LIMIT = 100 +CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f]") +WINDOWS = {"codex-as": ("primary", "secondary"), "claude-as": ("five_hour", "seven_day")} +NAMES = {"five_hour": "5h", "seven_day": "weekly"} +LAST = 253402300800 # 10000-01-01 UTC: no calendar time from here on + + +def directory(config, create=False): + path = config.state_dir / "accounts" + if create: + path.mkdir(mode=0o700, parents=True, exist_ok=True) + try: + aliased = os.path.samefile(path, config.secrets_dir) + except OSError: + aliased = False + if aliased: + raise ValueError(f"{path}: must not be the secrets directory.") + if not create: + return path + uid = os.getuid() + parent, info = path.parent.lstat(), path.lstat() + if not stat.S_ISDIR(parent.st_mode) or parent.st_uid not in (uid, 0) or parent.st_mode & 0o022: + raise ValueError(f"{path.parent}: must be a real directory that other users cannot change.") + if not stat.S_ISDIR(info.st_mode) or info.st_uid != uid or stat.S_IMODE(info.st_mode) != 0o700: + raise ValueError(f"{path}: must be an owned, real directory with permissions 0700.") + return path + + +def unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate field") + result[key] = value + return result + + +def finite(_): + raise ValueError("non-finite number") + + +def read(path): + try: + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC) + except FileNotFoundError: + return None + except OSError as error: + raise ValueError(f"{path.name}: unreadable metadata ({error.strerror}).") from None + with os.fdopen(fd, "rb") as stream: + info = os.fstat(stream.fileno()) + if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1 + or stat.S_IMODE(info.st_mode) != 0o600 or info.st_size > LIMIT): + raise ValueError(f"{path.name}: unsafe metadata (not an owned 0600 file up to 64 KiB).") + raw = stream.read(LIMIT + 1) + try: + data = json.loads(raw.decode("utf-8"), object_pairs_hook=unique, parse_constant=finite) + except (ValueError, RecursionError): + raise ValueError(f"{path.name}: malformed metadata.") from None + if len(raw) > LIMIT or not isinstance(data, dict): + raise ValueError(f"{path.name}: malformed metadata.") + return data + + +def write(path, data): + raw = (json.dumps(data, sort_keys=True, ensure_ascii=True) + "\n").encode("ascii") + try: + with os.fdopen(os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC), "rb") as stream: + if stream.read(LIMIT + 1) == raw: + return False + except OSError: + pass + temporary = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp") + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600) + try: + with os.fdopen(fd, "wb") as stream: + stream.write(raw) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + except BaseException: + try: + temporary.unlink() + except OSError: + pass + raise + return True + + +def clean(value, limit): + if not isinstance(value, str): + return None + return CONTROL.sub("", value).strip()[:limit] or None + + +def label_problem(text): + if CONTROL.search(text): + return "it contains control characters" + if "sk-ant-" in text: + return "it looks like a token" + if len(text) > LABEL_LIMIT: + return f"it is longer than {LABEL_LIMIT} characters" + return None + + +def window(value): + """A usage window as (used percent, window minutes or None, reset epoch or None), or None.""" + if not isinstance(value, dict): + return None + used = value.get("used_percent", value.get("used_percentage")) + minutes, resets = value.get("window_minutes"), value.get("resets_at") + if isinstance(used, bool) or not isinstance(used, (int, float)) or not math.isfinite(used) or not 0 <= used <= 100: + return None + if minutes is not None and (isinstance(minutes, bool) or not isinstance(minutes, int) or minutes <= 0): + minutes = None + if resets is not None and (isinstance(resets, bool) or not isinstance(resets, int) or not 0 < resets < LAST): + resets = None + return used, minutes, resets + + +def window_name(key, minutes): + if key in NAMES: + return NAMES[key] + if minutes == 300: + return "5h" + if minutes == 10080: + return "weekly" + if minutes: + return f"{minutes // 1440}d" if minutes % 1440 == 0 else f"{minutes // 60}h" if minutes % 60 == 0 else f"{minutes}m" + return key + + +def span(seconds): + minutes = max(0, int(seconds) // 60) + if minutes < 60: + return f"{minutes}m" + if minutes < 1440: + return f"{minutes // 60}h {minutes % 60}m" + return f"{minutes // 1440}d {minutes % 1440 // 60}h" + + +def clock(epoch, now): + """EPOCH as local HH:MM, with the weekday unless it is today; None where the platform has no such time.""" + try: + local = time.localtime(epoch) + same_day = time.localtime(now)[:3] == local[:3] + return time.strftime("%H:%M" if same_day else "%a %H:%M", local) + except (OverflowError, OSError, ValueError): + return None + + +def age(seconds): + seconds = max(0, int(seconds)) + if seconds < 60: + return "just now" + if seconds < 3600: + return f"{seconds // 60} min ago" + if seconds < 48 * 3600: + return f"{seconds // 3600} h ago" + return f"{seconds // 86400} d ago" + + +def usage_lines(tool, usage, now): + """The window lines and the seen line of a usage record, or None without any window.""" + if not isinstance(usage, dict): + return None + lines = [] + for key in WINDOWS[tool]: + parsed = window(usage.get(key)) + if parsed is None: + continue + used, minutes, resets = parsed + name = window_name(key, minutes) + at = None if resets is None else clock(resets, now) + if at is None: + lines.append(f"{name:<8}{round(used)}% used") + elif resets <= now: + lines.append(f"{name:<8}reset since {at}") + else: + lines.append(f"{name:<8}{round(used)}% used, resets {at} (in {span(resets - now)})") + seen = usage.get("seen_at") + if not lines: + return None + if isinstance(seen, int) and not isinstance(seen, bool) and 0 < seen < LAST: + lines.append(f"{'seen':<8}{age(now - seen)}") + return lines + + +def info(tool, name, config, now): + folder = directory(config) + if tool == "codex-as": + data = read(folder / f"codex-{name}.json") or {} + lines = [f"{name} (Codex)"] + for key in ("email", "plan"): + lines.append(f"{key:<8}{clean(data.get(key), 254) or 'not recorded yet'}") + usage = data.get("usage") + else: + data = read(folder / f"claude-{name}.json") or {} + label = clean(data.get("label"), LABEL_LIMIT) + lines = [f"{name} (Claude)", f"{'label':<8}{label or f'not set (claude-as --set-label {name} LABEL)'}"] + usage = read(folder / f"claude-{name}.usage.json") + lines.extend(usage_lines(tool, usage, now) or [f"{'usage':<8}not recorded yet"]) + return "\n".join(lines) + + +def labels(tool, names, config): + try: + folder = directory(config) + except ValueError: + return list(names) + result = [] + for name in names: + try: + if tool == "codex-as": + label = clean((read(folder / f"codex-{name}.json") or {}).get("email"), 254) + else: + label = clean((read(folder / f"claude-{name}.json") or {}).get("label"), LABEL_LIMIT) + except ValueError: + label = None + result.append(f"{name}\t{label}" if label else name) + return result diff --git a/lib/coding-agent-kit/config.py b/lib/coding-agent-kit/config.py index 97260df..9fe1c45 100644 --- a/lib/coding-agent-kit/config.py +++ b/lib/coding-agent-kit/config.py @@ -14,6 +14,9 @@ claude str: the claude executable, the same rule codex_home Path, absolute; the account root is codex_home / "accounts" secrets_dir Path, absolute; neither equal to codex_home nor nested with it + state_dir Path, absolute: $XDG_STATE_HOME/coding-agent-kit when XDG_STATE_HOME + is absolute, else $HOME/.local/state/coding-agent-kit; the + launchers keep the account metadata cache under it. Not a key. warnings tuple of ": unknown key ignored", one per unknown key expand(path) -> Path: Path(path).expanduser() with this run's HOME which(key) -> str | None: shutil.which() of the "codex" or "claude" value @@ -68,6 +71,7 @@ class Config: claude: str codex_home: Path secrets_dir: Path + state_dir: Path warnings: tuple = () def expand(self, path): @@ -277,5 +281,10 @@ def load(): raise ConfigError(f"{path}: secrets_dir: must not be inside codex_home.") if secrets in root.parents: raise ConfigError(f"{path}: codex_home: must not be inside secrets_dir.") + xdg = Path(os.environ.get("XDG_STATE_HOME", "")) + state = (xdg if xdg.is_absolute() else home / ".local/state") / "coding-agent-kit" + # Metadata files carry the Codex credentials' names (codex-NAME.json). + if single(state / "accounts") == secrets: + raise ConfigError(f"{path}: secrets_dir: must differ from the account metadata directory {state / 'accounts'}.") source = f"from {VARIABLE}" if explicit else "found" if raw is not None else "not found; defaults" - return Config(path, source, home, warnings=tuple(warnings), **values) + return Config(path, source, home, state_dir=state, warnings=tuple(warnings), **values) diff --git a/tests/COVERAGE.md b/tests/COVERAGE.md index 1432735..e3dbee7 100644 --- a/tests/COVERAGE.md +++ b/tests/COVERAGE.md @@ -18,14 +18,15 @@ source-to-test behavior matrix instead. | Production target | Test | Covered behavior | | --- | --- | --- | -| `lib/coding-agent-kit/config.py` | `test_config.py` (`Loader`), `config.sh` | location precedence (`CODING_AGENT_KIT_CONFIG`, an absolute `XDG_CONFIG_HOME`, `~/.config`; empty and relative values ignored), a missing file and a path component that is a regular file give the defaults (an explicit file under one is not found), an explicit file must be absolute and exist, resolving and dangling symlinks (the file and a directory on the way), a FIFO refused without blocking, an unreadable file, an unsearchable directory and a symlink loop fail closed with `cannot be opened`, umask 002/022/077, group write only for the user private group (the login group from the password database: accepted with another real gid, as after `newgrp` or `sg`, and refused when the login group differs, for the file and the directories; listed members and, through a patched `getpwall`, accounts whose primary group it is; the accounts listed once per load for three group-writable places), group write with an access ACL refused (patched `listxattr`: POSIX and NFSv4 names, no `listxattr`, ENOTSUP, a read error; and, where `setfacl` works, a named group entry, a directory default ACL and an ACL on a directory), other-writable files and foreign owners refused, the directories on the way up to HOME (not above, also when HOME is a symlink into a shared group directory, through the link or physically spelled, and the HOME link must be yours) or to `/` for a file outside HOME, as written and resolved: group-writable non-private, other-writable and foreign-owned refused, sticky and an Ubuntu 0775 user private group accepted, a stowed link into a checkout, symlinks owned by another user refused and by root accepted, the 64 KiB limit and UTF-8, syntax errors that name the position and never the contents, nesting past the parser's recursion and an integer past the digit limit as `invalid TOML`, unknown keys warn and continue, value errors (type, empty, C0, DEL and C1 control characters such as CSI and NEL, escaped or raw, relative, `..`, `/` in a command name), executables as names or absolute or `~/` paths, `codex_home` and `secrets_dir` never equal or nested, also when spelled with a leading `//` or under a HOME spelled with one, a leading slash run collapsed in stored values, an absolute HOME required, `expand` with the loaded HOME, the `--show-config` lines; golden defaults: an explicit-defaults file loads equal to no file | -| `bin/codex-as`, `bin/claude-as`: location, help and metadata queries | `test_config.py` (`Launchers`), `config.sh` | help names the kit's docs, or the GitHub URL for a lone copy or a symlink loop, and needs no python3, while a launch without python3 fails once and writes nothing (`codex-as` with its Python 3.11+ message, `claude-as` with the shell's exec error, exit 127, as in 0.1.0); resolution cases: direct, PATH lookup, relative, relative upwards, two-hop Stow-style links, a symlinked directory alias, a second hop through a symlinked directory with a lexical decoy, and under `bash`, `sh`, `/bin/sh` and `dash` a path, a name in the current directory, two-hop links and that second hop; 40 links accepted, 41 links and a cycle refused with one message; not on PATH and unsearchable directories fail with one message; incomplete installs name the root without a traceback (shell side, Python side and the 0.1 `check.py accounts`); `--list-accounts` and `--show-config` start nothing and ignore launch policy; a bad configuration gives empty listings and completions; linked installs, their completions and the 0.1 helper entry points follow the configuration; configured paths, executables and parent checks for both launchers; a missing parent and one below a regular file are named; a `~/.config` that is a file launches with the defaults; deep nesting, a huge integer and a control character in the file, and an unknown user in `claude-as --settings`, print one line and no traceback from both launchers and the 0.1 `check.py accounts`; unknown keys warn once and the launch continues; golden defaults: no file, an explicit `~/` defaults file and an absolute-spelled one give identical argv, environment, HOME tree and outputs; no kit code outside the loader reads HOME | -| `bin/codex-as`, `lib/codex-as/*.py` | `test_codex_as.py`, `test_codex_sharing_native.py`, `codex-as.sh` | fixed account views and shared state, switching and refresh writeback, root `auth.json` never read or changed, argument forwarding with I/O, cwd, exit status and umask preserved, refused commands, settings, environment and session names, profiles, plain-codex commands, version acceptance, daemon takeover (idle, possibly running, picker scope, `--last`, journals, torn and interrupted takeovers, identity and version checks), leases and concurrent clients, signals, credential repair and divergence, drift and permission refusals that name the path, interrupted setup; with a real Codex 0.161.0 or newer on PATH, offline app-server checks of history, discovery, writer locks, shared config writes and token refresh through the account link | -| `bin/claude-as`, `lib/claude-as/launch.py`, `completions/*` | `test_account_enrollment.py`, `account-enrollment.sh` | enrolment of both tools through `/dev/tty` with the original command continued, decline, failure and no terminal, Claude token capture and manual fallback, conflicts and refused options, Claude account names starting with `-` refused with codex-as's message and nothing started or written, completion lists safe names without opening secrets or starting a CLI; colour: `CODING_AGENT_KIT_COLOR` auto, always, never and an invalid value, `NO_COLOR` and `TERM=dumb`, `DOTFILES_COLOR` ignored, each message styled only for its own destination, `--list-accounts` output plain | +| `lib/coding-agent-kit/config.py` | `test_config.py` (`Loader`), `config.sh` | location precedence (`CODING_AGENT_KIT_CONFIG`, an absolute `XDG_CONFIG_HOME`, `~/.config`; empty and relative values ignored), a missing file and a path component that is a regular file give the defaults (an explicit file under one is not found), an explicit file must be absolute and exist, resolving and dangling symlinks (the file and a directory on the way), a FIFO refused without blocking, an unreadable file, an unsearchable directory and a symlink loop fail closed with `cannot be opened`, umask 002/022/077, group write only for the user private group (the login group from the password database: accepted with another real gid, as after `newgrp` or `sg`, and refused when the login group differs, for the file and the directories; listed members and, through a patched `getpwall`, accounts whose primary group it is; the accounts listed once per load for three group-writable places), group write with an access ACL refused (patched `listxattr`: POSIX and NFSv4 names, no `listxattr`, ENOTSUP, a read error; and, where `setfacl` works, a named group entry, a directory default ACL and an ACL on a directory), other-writable files and foreign owners refused, the directories on the way up to HOME (not above, also when HOME is a symlink into a shared group directory, through the link or physically spelled, and the HOME link must be yours) or to `/` for a file outside HOME, as written and resolved: group-writable non-private, other-writable and foreign-owned refused, sticky and an Ubuntu 0775 user private group accepted, a stowed link into a checkout, symlinks owned by another user refused and by root accepted, the 64 KiB limit and UTF-8, syntax errors that name the position and never the contents, nesting past the parser's recursion and an integer past the digit limit as `invalid TOML`, unknown keys warn and continue, value errors (type, empty, C0, DEL and C1 control characters such as CSI and NEL, escaped or raw, relative, `..`, `/` in a command name), executables as names or absolute or `~/` paths, `codex_home` and `secrets_dir` never equal or nested, also when spelled with a leading `//` or under a HOME spelled with one, `secrets_dir` never the account metadata directory (a trailing slash, a `//` spelling, an absolute `XDG_STATE_HOME`) and accepted above or below it, a leading slash run collapsed in stored values, an absolute HOME required, `expand` with the loaded HOME, the `--show-config` lines; golden defaults: an explicit-defaults file loads equal to no file; `state_dir` from an absolute `XDG_STATE_HOME`, else `~/.local/state`, relative and empty values ignored, a `state_dir` key only an unknown key | +| `bin/codex-as`, `bin/claude-as`: location, help and metadata queries | `test_config.py` (`Launchers`), `config.sh` | help names the kit's docs, or the GitHub URL for a lone copy or a symlink loop, and needs no python3, while a launch without python3 fails once and writes nothing (`codex-as` with its Python 3.11+ message, `claude-as` with the shell's exec error, exit 127, as in 0.1.0); resolution cases: direct, PATH lookup, relative, relative upwards, two-hop Stow-style links, a symlinked directory alias, a second hop through a symlinked directory with a lexical decoy, and under `bash`, `sh`, `/bin/sh` and `dash` a path, a name in the current directory, two-hop links and that second hop; 40 links accepted, 41 links and a cycle refused with one message; not on PATH and unsearchable directories fail with one message; incomplete installs name the root without a traceback (shell side, Python side and the 0.1 `check.py accounts`); `--list-accounts` and `--show-config` start nothing and ignore launch policy; a bad configuration gives empty listings and completions; linked installs, their completions and the 0.1 helper entry points follow the configuration; configured paths, executables and parent checks for both launchers; a missing parent and one below a regular file are named; a `~/.config` that is a file launches with the defaults; deep nesting, a huge integer and a control character in the file, and an unknown user in `claude-as --settings`, print one line and no traceback from both launchers and the 0.1 `check.py accounts`; unknown keys warn once and the launch continues; golden defaults: no file, an explicit `~/` defaults file and an absolute-spelled one give identical argv, environment, HOME tree and outputs; no kit code outside the loader reads HOME or `XDG_STATE_HOME`; `--account-labels` and `--account-info NAME` create nothing (no state directory), start nothing and succeed with unusable 0600 credentials, so never open one, a missing account exits 1 with one line, and other arities are invalid account names (codex-as exit 2 in shell, claude-as exit 1); the zsh completions describe each account by its email or label, Bash by name only; the kit tests' dotfiles host is the layout of dotfiles' own `coding-agent-kit` package | +| `bin/codex-as`, `lib/codex-as/*.py` | `test_codex_as.py`, `test_codex_sharing_native.py`, `codex-as.sh` | fixed account views and shared state, switching and refresh writeback, root `auth.json` never read or changed, argument forwarding with I/O, cwd, exit status and umask preserved, refused commands, settings, environment and session names, profiles, plain-codex commands, version acceptance, daemon takeover (idle, possibly running, picker scope, `--last`, journals, torn and interrupted takeovers, identity and version checks), leases and concurrent clients, signals, credential repair and divergence, drift and permission refusals that name the path, interrupted setup; `Plan.continued` (the resumed thread, never a fork's source); with a real Codex 0.161.0 or newer on PATH, offline app-server checks of history, discovery, writer locks, shared config writes and token refresh through the account link | +| `lib/coding-agent-kit/accounts.py`, `lib/codex-as/usage.py`, the recording in `lib/codex-as/launch.py` | `test_account_info.py`, `account-info.sh` | the state directory created 0700 only on request, refused when 0755, linked or below a directory others can change, under any umask; strict reads (0644, duplicate keys, NaN, a list, over 64 KiB, malformed, a link, a hard link) failing without quoting the file; atomic 0600 writes only on change that replace a link instead of writing through it and leave no temporary file; label refusals; `--account-info` lines for identity, usage with local reset times and the age, reset windows, missing data, only allow-listed fields with C0, DEL and C1 removed; labels as emails, labels or bare names, an unsafe file giving a bare name; the directory never the secrets directory, the same path or through a linked parent, for lookups and writes (labels then bare names); reset and seen times from the year 10000 on ignored instead of failing; the rollout scan: a new session of this account, another account's concurrent session, the same user in another workspace and an API key ignored, a resumed thread counted only by its id and only for lines of the run, compressed, null, other-family, malformed and out-of-run snapshots skipped, files untouched during the run not read, only the last MiB read; a FIFO and a link named like rollouts never opened, without blocking; other accounts' newer rollouts never using up the eight-file limit, the 64-line head limit, and a continued thread read first; reset times from the year 10000 on skipped; a newer record written by another run during the scan kept; through codex-as with a fake codex writing rollouts: email and plan recorded before the native child starts, the profile-claim email, an API-key login, the run's snapshot recorded with the exit code kept, the newest snapshot kept, `exec fork` never counting its source thread while `exec resume` counts it, a `secrets_dir` in the state directory refused before any write and, through a linked ancestor, never read or written by a launch or a query, no token in the file, an unusable state directory never failing or delaying the launch | +| `bin/claude-as`, `lib/claude-as/launch.py`, `completions/*` | `test_account_enrollment.py`, `account-enrollment.sh` | enrolment of both tools through `/dev/tty` with the original command continued, decline, failure and no terminal, Claude token capture and manual fallback, conflicts and refused options, Claude account names starting with `-` refused with codex-as's message and nothing started or written, completion lists safe names without opening secrets or starting a CLI; colour: `CODING_AGENT_KIT_COLOR` auto, always, never and an invalid value, `NO_COLOR` and `TERM=dumb`, `DOTFILES_COLOR` ignored, each message styled only for its own destination, `--list-accounts` output plain; the label prompt after the confirmation and before the token, saved 0600 only after the token, refused (control characters, more than 100 characters, a token) and asked again, none saved without a token; `--set-label NAME LABEL`, from `/dev/tty`, cleared by an empty label, with an unusable token (never read), refused for a missing account, a token and an invalid name, starting no CLI; the child gets the usage file and the token's 16-digit SHA-256 identifier, inherited values are dropped, and neither is set when the state directory is not 0700 | | `install.sh` | `standalone-e2e.sh` | runs under `/bin/sh`, bash and dash (when present), directly, through a link, through a relative link from another depth, by relative name and through a directory alias of the checkout, from a checkout path with spaces and pattern characters (`kit [with] spaces*`); 40 links followed and 41 refused; the printed links, `fpath`, helper and PATH lines; idempotent reinstall; `--prefix` with a space, `XDG_DATA_HOME` and `BASH_COMPLETION_USER_DIR`; refusal, with nothing changed and `--uninstall` keeping each, of a file, a directory, a link into another checkout, a Stow-style two-hop link, a dangling link to a deleted checkout, a link into a prefix sibling, an absolute text leaving the checkout through `..`, a hand-made relative link into it and a hand-made link through a directory alias of it; refusal of every destination reached through a relative directory link: hand-made below HOME (named with pattern characters, and spelled with a trailing slash beside a `--prefix` outside it) and below a `--prefix` outside HOME (an own link already there included) and (with GNU Stow) a folded `~/.local/bin` holding a package link and a folded `~/.local/bin` and `~/.local/share` of a package without one, whose Git repository stays clean; an absolute `~/.local` link and a HOME below a relative link install and uninstall, also with a `--prefix` whose parent lies above that link and with that link, an ancestor of HOME, as the `--prefix`; a HOME that is itself a relative link (named with pattern characters) installs and uninstalls with the default prefix and with `--prefix` HOME; HOME=/ with `BASH_COMPLETION_USER_DIR` in the test directory: a `--prefix` below a relative link installs and uninstalls and a `--prefix` that is one is refused; not covered: the fallback for a `--prefix` directly under `/`, which needs root to make a link there; links of an older layout replaced and removed; a destination whose nearest existing parent is a file, a dangling link, read-only or unsearchable, and this checkout's own links in a read-only directory, refused before any link; `--uninstall` refusing, with nothing removed, own links in a read-only directory and an unsearchable directory on the way (read-only and unsearchable cases skipped as root); an incomplete checkout and `sh ({ rate_limits: { + five_hour: { used_percentage: five, resets_at: 1767276000 }, + seven_day: { used_percentage: seven, resets_at: 1767600000 }, +} }); + +function usageFixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-usage-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const folder = path.join(root, 'accounts'); + fs.mkdirSync(folder, { mode: 0o700 }); + fs.chmodSync(folder, 0o700); + const file = path.join(folder, 'claude-work.usage.json'); + const env = { + CODING_AGENT_KIT_USAGE_FILE: file, + CODING_AGENT_KIT_TOKEN_ID: crypto.createHash('sha256').update(fabricatedToken).digest('hex').slice(0, 16), + CLAUDE_CODE_OAUTH_TOKEN: fabricatedToken, + }; + return { root, folder, file, env }; +} + +test('status line records the last rate limits for the claude-as account it runs under', posixOnly, (t) => { + const { folder, file, env } = usageFixture(t); + assert.equal(recordUsage(usageInput(23.5, 41), env, 1767268800000), true); + assert.deepEqual(JSON.parse(fs.readFileSync(file, 'utf8')), { + five_hour: { used_percentage: 23.5, resets_at: 1767276000 }, + seven_day: { used_percentage: 41, resets_at: 1767600000 }, + seen_at: 1767268800, + }); + assert.equal(fs.statSync(file).mode & 0o777, 0o600); + assert.ok(!fs.readFileSync(file, 'utf8').includes('fabricated'), 'the token reached the usage file'); + // Unchanged values are written again only after five minutes; changed ones at once. + assert.equal(recordUsage(usageInput(23.5, 41), env, 1767268800000 + 299000), false); + assert.equal(recordUsage(usageInput(23.5, 41), env, 1767268800000 + 301000), true); + assert.equal(recordUsage(usageInput(24, 41), env, 1767268800000 + 302000), true); + assert.equal(JSON.parse(fs.readFileSync(file, 'utf8')).five_hour.used_percentage, 24); + assert.deepEqual(fs.readdirSync(folder), ['claude-work.usage.json'], 'a temporary file was left'); + // Any account name claude-as accepts, however long. + const long = path.join(folder, `claude-${'n'.repeat(120)}.usage.json`); + assert.equal(recordUsage(usageInput(5, 6), { ...env, CODING_AGENT_KIT_USAGE_FILE: long }, 1767268800000), true); +}); + +test('status line records nothing without a matching token, a safe path or valid numbers', posixOnly, (t) => { + const { root, folder, file, env } = usageFixture(t); + const refused = [ + {}, + { ...env, CLAUDE_CODE_OAUTH_TOKEN: undefined }, + { ...env, CLAUDE_CODE_OAUTH_TOKEN: fabricatedToken + 'x' }, + { ...env, CODING_AGENT_KIT_TOKEN_ID: undefined }, + { ...env, CODING_AGENT_KIT_USAGE_FILE: 'accounts/claude-work.usage.json' }, + { ...env, CODING_AGENT_KIT_USAGE_FILE: path.join(folder, 'codex-work.json') }, + { ...env, CODING_AGENT_KIT_USAGE_FILE: path.join(folder, 'claude-../x.usage.json') }, + ]; + for (const candidate of refused) { + assert.equal(recordUsage(usageInput(10, 20), candidate, 1767268800000), false, JSON.stringify(Object.keys(candidate))); + } + for (const input of [{}, { rate_limits: null }, usageInput(Number.NaN, 101), + { rate_limits: { five_hour: { used_percentage: 5, resets_at: 17.5 } } }, + { rate_limits: { five_hour: { used_percentage: 5, resets_at: 253402300800 } } }, + { rate_limits: { five_hour: { used_percentage: '5', resets_at: 1767276000 } } }]) { + assert.equal(recordUsage(input, env, 1767268800000), false, JSON.stringify(input)); + } + assert.deepEqual(fs.readdirSync(folder), []); + fs.chmodSync(folder, 0o755); + assert.equal(recordUsage(usageInput(10, 20), env, 1767268800000), false, 'wrote into a directory others can read'); + fs.chmodSync(folder, 0o700); + const elsewhere = path.join(root, 'elsewhere'); + fs.mkdirSync(elsewhere, { mode: 0o700 }); + fs.rmdirSync(folder); + fs.symlinkSync(elsewhere, folder); + assert.equal(recordUsage(usageInput(10, 20), env, 1767268800000), false, 'followed a linked directory'); + assert.deepEqual(fs.readdirSync(elsewhere), []); + assert.ok(!fs.existsSync(file) || fs.lstatSync(folder).isSymbolicLink()); +}); + +test('status line output is unchanged when it records usage', posixOnly, (t) => { + const { file, env } = usageFixture(t); + const input = { ...usageInput(7, 8), model: { display_name: 'Opus' } }; + const result = spawnSync(process.execPath, [path.join(scripts, 'statusline.cjs')], { + input: JSON.stringify(input), encoding: 'utf8', timeout: 5000, env: { ...process.env, ...env }, + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, `${formatStatus(input)}\n`); + assert.equal(result.stderr, ''); + assert.equal(JSON.parse(fs.readFileSync(file, 'utf8')).seven_day.used_percentage, 8); +}); + +test('status line never records usage on Windows', (t) => { + const { file, env } = usageFixture(t); + const platform = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + assert.equal(recordUsage(usageInput(10, 20), env, 1767268800000), false); + } finally { + Object.defineProperty(process, 'platform', platform); + } + assert.ok(!fs.existsSync(file)); +}); diff --git a/tests/codex-as.sh b/tests/codex-as.sh index b02c191..0f1940f 100755 --- a/tests/codex-as.sh +++ b/tests/codex-as.sh @@ -4,7 +4,7 @@ set -euo pipefail # Hermetic: a caller's kit configuration, colour choice and XDG directories # never reach the code under test; each case sets what it needs. -unset CODING_AGENT_KIT_CONFIG CODING_AGENT_KIT_COLOR XDG_CONFIG_HOME NO_COLOR +unset CODING_AGENT_KIT_CONFIG CODING_AGENT_KIT_COLOR CODING_AGENT_KIT_USAGE_FILE CODING_AGENT_KIT_TOKEN_ID XDG_CONFIG_HOME XDG_STATE_HOME NO_COLOR TEST_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" python3 -I -B "$TEST_DIR/test_codex_as.py" diff --git a/tests/config.sh b/tests/config.sh index 6c1847a..c73ced4 100755 --- a/tests/config.sh +++ b/tests/config.sh @@ -4,7 +4,7 @@ set -euo pipefail # Hermetic: a caller's kit configuration, colour choice and XDG directories # never reach the code under test; each case sets what it needs. -unset CODING_AGENT_KIT_CONFIG CODING_AGENT_KIT_COLOR XDG_CONFIG_HOME NO_COLOR +unset CODING_AGENT_KIT_CONFIG CODING_AGENT_KIT_COLOR CODING_AGENT_KIT_USAGE_FILE CODING_AGENT_KIT_TOKEN_ID XDG_CONFIG_HOME XDG_STATE_HOME NO_COLOR TEST_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" # Arguments go to unittest, for example -k Loader. diff --git a/tests/contract.sh b/tests/contract.sh index 9cb5c9c..8ff3729 100755 --- a/tests/contract.sh +++ b/tests/contract.sh @@ -7,7 +7,7 @@ set -euo pipefail # Hermetic: a caller's kit configuration, colour choice and XDG directories # never reach the code under test; each case sets what it needs. -unset CODING_AGENT_KIT_CONFIG CODING_AGENT_KIT_COLOR XDG_CONFIG_HOME NO_COLOR +unset CODING_AGENT_KIT_CONFIG CODING_AGENT_KIT_COLOR CODING_AGENT_KIT_USAGE_FILE CODING_AGENT_KIT_TOKEN_ID XDG_CONFIG_HOME XDG_STATE_HOME NO_COLOR TEST_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd -P -- "$TEST_DIR/.." && pwd)" @@ -24,8 +24,10 @@ LAYOUT='100755 bin/codex-as 100644 lib/codex-as/check.py 100644 lib/codex-as/layout.py 100644 lib/codex-as/handoff.py +100644 lib/codex-as/usage.py 100644 lib/claude-as/launch.py 100644 lib/coding-agent-kit/config.py +100644 lib/coding-agent-kit/accounts.py 100644 completions/bash/codex-as 100644 completions/bash/claude-as 100644 completions/zsh/_codex-as diff --git a/tests/run.ps1 b/tests/run.ps1 index 5beebf5..fd286c2 100644 --- a/tests/run.ps1 +++ b/tests/run.ps1 @@ -9,7 +9,7 @@ $PSNativeCommandUseErrorActionPreference = $false # Hermetic: a caller's kit configuration, colour choice and XDG directories # never reach the suites; each case sets what it needs. The same names, in # the same order, as the unset statement of tests/*.sh. -foreach ($knob in @('CODING_AGENT_KIT_CONFIG', 'CODING_AGENT_KIT_COLOR', 'XDG_CONFIG_HOME', 'NO_COLOR')) { +foreach ($knob in @('CODING_AGENT_KIT_CONFIG', 'CODING_AGENT_KIT_COLOR', 'CODING_AGENT_KIT_USAGE_FILE', 'CODING_AGENT_KIT_TOKEN_ID', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'NO_COLOR')) { Remove-Item -LiteralPath "Env:$knob" -ErrorAction Ignore } diff --git a/tests/run.sh b/tests/run.sh index 8d8bb10..858b147 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -4,7 +4,7 @@ set -euo pipefail # Hermetic: a caller's kit configuration, colour choice and XDG directories # never reach the code under test; each case sets what it needs. -unset CODING_AGENT_KIT_CONFIG CODING_AGENT_KIT_COLOR XDG_CONFIG_HOME NO_COLOR +unset CODING_AGENT_KIT_CONFIG CODING_AGENT_KIT_COLOR CODING_AGENT_KIT_USAGE_FILE CODING_AGENT_KIT_TOKEN_ID XDG_CONFIG_HOME XDG_STATE_HOME NO_COLOR # GitHub's runners start jobs with SIGPIPE ignored, so a writer into a pipe # whose reader stopped early prints "write error: Broken pipe" there instead @@ -62,6 +62,7 @@ tests=( test-entrypoints.sh contract.sh config.sh + account-info.sh codex-as.sh account-enrollment.sh standalone-e2e.sh diff --git a/tests/standalone-e2e.sh b/tests/standalone-e2e.sh index 233a2a2..725d099 100755 --- a/tests/standalone-e2e.sh +++ b/tests/standalone-e2e.sh @@ -7,7 +7,7 @@ set -euo pipefail # Hermetic: a caller's kit configuration, colour choice and XDG directories # never reach the code under test; each case sets what it needs. -unset CODING_AGENT_KIT_CONFIG CODING_AGENT_KIT_COLOR XDG_CONFIG_HOME NO_COLOR +unset CODING_AGENT_KIT_CONFIG CODING_AGENT_KIT_COLOR CODING_AGENT_KIT_USAGE_FILE CODING_AGENT_KIT_TOKEN_ID XDG_CONFIG_HOME XDG_STATE_HOME NO_COLOR TEST_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd -P -- "$TEST_DIR/.." && pwd)" @@ -282,6 +282,25 @@ run list-codex codex-as --list-accounts expect list-codex 0 A run list-claude claude-as --list-accounts expect list-claude 0 work +# Account metadata through the installed links: offline, from the state +# directory only, which a query never creates. +BEFORE="$(snapshot "$H")" +run labels-codex codex-as --account-labels +expect labels-codex 0 A +run labels-claude claude-as --account-labels +expect labels-claude 0 work +run info-codex codex-as --account-info A +expect info-codex 0 "A (Codex) +email not recorded yet +plan not recorded yet +usage not recorded yet" +run info-claude claude-as --account-info work +expect info-claude 0 "work (Claude) +label not set (claude-as --set-label work LABEL) +usage not recorded yet" +run info-missing codex-as --account-info B +expect info-missing 1 '' 'codex-as: no account B.' +[[ "$(snapshot "$H")" == "$BEFORE" && ! -s "$RECORD" ]] || die 'an account query wrote to HOME or started a CLI' completions A work : >"$RECORD" run launch-codex codex-as A exec hello diff --git a/tests/test-entrypoints.sh b/tests/test-entrypoints.sh index d7ec9f6..022d60f 100755 --- a/tests/test-entrypoints.sh +++ b/tests/test-entrypoints.sh @@ -4,7 +4,7 @@ set -euo pipefail # Hermetic: a caller's kit configuration, colour choice and XDG directories # never reach the code under test; each case sets what it needs. -unset CODING_AGENT_KIT_CONFIG CODING_AGENT_KIT_COLOR XDG_CONFIG_HOME NO_COLOR +unset CODING_AGENT_KIT_CONFIG CODING_AGENT_KIT_COLOR CODING_AGENT_KIT_USAGE_FILE CODING_AGENT_KIT_TOKEN_ID XDG_CONFIG_HOME XDG_STATE_HOME NO_COLOR TEST_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" TEST_TMP="$(mktemp -d "${TMPDIR:-/tmp}/kit-test-entrypoints.XXXXXX")" @@ -76,7 +76,7 @@ case $KNOBS in exit 1 ;; esac -for knob in XDG_CONFIG_HOME NO_COLOR; do +for knob in XDG_CONFIG_HOME XDG_STATE_HOME NO_COLOR; do if [[ " $KNOBS " != *" $knob "* ]]; then printf 'ERROR: tests/run.sh does not unset %s, which the launchers read.\n' "$knob" >&2 exit 1 diff --git a/tests/test_account_enrollment.py b/tests/test_account_enrollment.py index d01096a..375aae7 100644 --- a/tests/test_account_enrollment.py +++ b/tests/test_account_enrollment.py @@ -1,5 +1,6 @@ """Real launchers with fabricated login subprocesses and isolated controlling TTYs.""" +import hashlib import json import importlib.util import os @@ -17,6 +18,7 @@ from test_codex_as import CodexAs, FAKE, ROOT, SHELLS CLAUDE_TOKEN = 'sk-ant-oat01-' + 'fabricated-sensitive-claude-' * 4 +LABEL_PROMPT = 'Label for this account' class Enrollment(unittest.TestCase): @@ -84,6 +86,7 @@ def setUp(self): record = {'args': sys.argv[1:], 'stdin': sys.stdin.read(), 'cwd': os.getcwd(), 'selected': os.environ.get('CLAUDE_CODE_OAUTH_TOKEN') == os.environ['CLAUDE_FAKE_TOKEN'], 'config_dir': os.environ.get('CLAUDE_CONFIG_DIR'), 'browser': os.environ.get('BROWSER'), + 'usage_file': os.environ.get('CODING_AGENT_KIT_USAGE_FILE'), 'token_id': os.environ.get('CODING_AGENT_KIT_TOKEN_ID'), 'tty_flags': [bool(flags & termios.ECHO), bool(flags & termios.ICANON)]} pathlib.Path(os.environ['FAKE_RECORD']).write_text(json.dumps(record)) print('stdout sentinel'); print('stderr sentinel', file=sys.stderr) @@ -207,7 +210,7 @@ def test_prompt_colors_respect_preferences_and_preserve_client_io(self): self.assertEqual('\x1b[' in err, forced) self.assertFalse((self.secret / ('codex-color-account.json' if tool == 'codex-as' else 'claude-color-account')).exists()) - code, out, err, tty = self.terminal(['color-account'], [('[y/N]', 'y')], + code, out, err, tty = self.terminal(['color-account'], [('[y/N]', 'y'), (LABEL_PROMPT, '')], baseline | {'FAKE_EXIT': '17'}, tool=tool) self.assertEqual(code, 17, err) self.assertEqual(out, 'stdout sentinel\n') @@ -291,7 +294,7 @@ def test_missing_without_tty_and_help_do_not_login(self): def test_claude_enroll_capture_and_continue_without_token_output(self): code, out, err, tty = self.terminal(['work', '-p', 'setup-token "quoted"'], - [('[y/N]', 'y')], {'FAKE_EXIT': '19', 'BROWSER': 'original-browser'}, + [('[y/N]', 'y'), (LABEL_PROMPT, '')], {'FAKE_EXIT': '19', 'BROWSER': 'original-browser'}, tool='claude-as') self.assertEqual(code, 19, err) self.assertEqual(out, 'stdout sentinel\n') @@ -311,7 +314,7 @@ def test_claude_enroll_capture_and_continue_without_token_output(self): def test_claude_capture_failure_and_manual_fallback_decline(self): for variant in ('truncated', 'different', 'failure'): - code, out, _, tty = self.terminal(['work'], [('[y/N]', 'y'), ('[y/N]', 'n')], + code, out, _, tty = self.terminal(['work'], [('[y/N]', 'y'), (LABEL_PROMPT, ''), ('[y/N]', 'n')], {'CLAUDE_VARIANT': variant}, tool='claude-as') self.assertNotEqual(code, 0) self.assertEqual(out, '') @@ -359,7 +362,7 @@ def test_claude_refuses_names_that_start_with_a_dash(self): self.assertIn('Account work-2 does not exist.', tty) def test_claude_wrapped_capture_policy_and_manual_fallback(self): - code, _, err, tty = self.terminal(['work'], [('[y/N]', 'y')], + code, _, err, tty = self.terminal(['work'], [('[y/N]', 'y'), (LABEL_PROMPT, '')], {'CLAUDE_VARIANT': 'wrapped'}, tool='claude-as') self.assertEqual(code, 0, err) self.assertNotIn(CLAUDE_TOKEN, tty) @@ -371,7 +374,8 @@ def test_claude_wrapped_capture_policy_and_manual_fallback(self): self.assertNotEqual(code, 0) self.assertIn('managed policy', err) cache.write_text('{}') - code, _, err, _ = self.terminal(['work'], [('[y/N]', 'y'), ('[y/N]', 'y'), ('input hidden', CLAUDE_TOKEN)], + code, _, err, _ = self.terminal(['work'], [('[y/N]', 'y'), (LABEL_PROMPT, ''), ('[y/N]', 'y'), + ('input hidden', CLAUDE_TOKEN)], {'CLAUDE_FAKE_VERSION': '99.0.0'}, tool='claude-as') self.assertEqual(code, 0, err) self.assertTrue((self.secret / 'claude-work').read_text().strip() == CLAUDE_TOKEN) @@ -504,11 +508,92 @@ def test_config_directory_empty_and_literal_tilde_match_native_loading(self): settings.unlink() def test_native_authorization_tty_settings_do_not_leak_into_final_client(self): - code, _, err, _ = self.terminal(['work'], [('[y/N]', 'y')], + code, _, err, _ = self.terminal(['work'], [('[y/N]', 'y'), (LABEL_PROMPT, '')], {'CLAUDE_DAMAGE_TTY': '1'}, tool='claude-as') self.assertEqual(code, 0, err) self.assertEqual(json.loads(self.record.read_text())['tty_flags'], [True, True]) + @property + def state(self): + return self.home / '.local/state/coding-agent-kit/accounts' + + def test_claude_label_is_asked_after_the_confirmation_and_saved_after_the_token(self): + code, _, err, tty = self.terminal(['work'], [('[y/N]', 'y'), (LABEL_PROMPT, ' alice@example.com ')], + tool='claude-as') + self.assertEqual(code, 0, err) + self.assertLess(tty.index('[y/N]'), tty.index(LABEL_PROMPT)) + self.assertLess(tty.index(LABEL_PROMPT), tty.index('Credentials saved')) + label = self.state / 'claude-work.json' + self.assertEqual(json.loads(label.read_text()), {'label': 'alice@example.com'}) + self.assertEqual(label.stat().st_mode & 0o777, 0o600) + self.assertEqual(self.state.stat().st_mode & 0o777, 0o700) + + def test_claude_refused_labels_are_asked_again_and_a_pasted_token_is_never_stored(self): + code, _, err, tty = self.terminal(['work'], [('[y/N]', 'y'), (LABEL_PROMPT, CLAUDE_TOKEN), + ('refused', 'x' * 101), ('refused', 'ok label')], + tool='claude-as') + self.assertEqual(code, 0, err) + self.assertIn('Label refused: it looks like a token.', tty) + self.assertIn('Label refused: it is longer than 100 characters.', tty) + self.assertEqual(json.loads((self.state / 'claude-work.json').read_text()), {'label': 'ok label'}) + for path in self.state.iterdir(): + self.assertNotIn(CLAUDE_TOKEN, path.read_text()) + + def test_claude_without_a_label_saves_none_and_a_declined_login_saves_nothing(self): + code, _, err, _ = self.terminal(['work'], [('[y/N]', 'y'), (LABEL_PROMPT, '')], tool='claude-as') + self.assertEqual(code, 0, err) + self.assertFalse((self.state / 'claude-work.json').exists()) + code, _, _, _ = self.terminal(['other'], [('[y/N]', 'y'), (LABEL_PROMPT, 'never saved'), ('[y/N]', 'n')], + {'CLAUDE_VARIANT': 'failure'}, tool='claude-as') + self.assertNotEqual(code, 0) + self.assertFalse((self.secret / 'claude-other').exists()) + self.assertFalse((self.state / 'claude-other.json').exists(), 'a label was saved without a token') + + def test_set_label_changes_and_clears_a_label_without_reading_the_token(self): + target = self.secret / 'claude-work' + target.write_text('not a token\n') # safe but unusable: reading it would fail + target.chmod(0o600) + code, out, err, tty = self.terminal(['--set-label', 'work', 'Work account'], [], tool='claude-as') + self.assertEqual((code, out, tty), (0, '', ''), err) + self.assertIn('label of work set', err) + self.assertEqual(json.loads((self.state / 'claude-work.json').read_text()), {'label': 'Work account'}) + self.assertFalse(self.record.exists(), 'set-label started Claude') + code, _, err, tty = self.terminal(['--set-label', 'work'], [('Label for account work', 'From the terminal')], + tool='claude-as') + self.assertEqual(code, 0, err) + self.assertEqual(json.loads((self.state / 'claude-work.json').read_text()), {'label': 'From the terminal'}) + code, _, err, _ = self.terminal(['--set-label', 'work', ''], [], tool='claude-as') + self.assertEqual(code, 0, err) + self.assertFalse((self.state / 'claude-work.json').exists()) + for args, message in ((['--set-label', 'missing', 'x'], 'no account missing'), + (['--set-label', 'work', 'sk-ant-oat01-x'], 'label refused: it looks like a token'), + (['--set-label', '-x', 'y'], 'invalid account name')): + with self.subTest(args=args): + code, out, err, _ = self.terminal(args, [], tool='claude-as') + self.assertEqual((code, out), (1, '')) + self.assertIn(message, err) + self.assertFalse(self.record.exists()) + + def test_claude_passes_the_usage_file_and_token_id_to_its_child(self): + target = self.secret / 'claude-work' + target.write_text(CLAUDE_TOKEN + '\n') + target.chmod(0o600) + outer = {'CODING_AGENT_KIT_USAGE_FILE': str(self.base / 'outer.usage.json'), 'CODING_AGENT_KIT_TOKEN_ID': 'outer'} + code, _, err, _ = self.terminal(['work'], [], outer, tool='claude-as') + self.assertEqual(code, 0, err) + record = json.loads(self.record.read_text()) + self.assertEqual(record['usage_file'], str(self.state / 'claude-work.usage.json')) + self.assertEqual(record['token_id'], hashlib.sha256(CLAUDE_TOKEN.encode()).hexdigest()[:16]) + self.assertEqual(self.state.stat().st_mode & 0o777, 0o700) + self.assertFalse((self.state / 'claude-work.usage.json').exists(), 'the launcher wrote usage itself') + # A state directory others can read is never handed on, and the outer values are not inherited. + self.state.chmod(0o755) + code, _, err, _ = self.terminal(['work'], [], outer, tool='claude-as') + self.assertEqual(code, 0, err) + record = json.loads(self.record.read_text()) + self.assertEqual((record['usage_file'], record['token_id']), (None, None)) + + del CodexAs # unittest should collect only this module's enrollment cases. diff --git a/tests/test_account_info.py b/tests/test_account_info.py new file mode 100644 index 0000000..a3de7b5 --- /dev/null +++ b/tests/test_account_info.py @@ -0,0 +1,583 @@ +"""Account metadata: the cache module, Codex identity and usage, and the queries. + +Module cases run in process against a temporary state directory. Launcher +cases run the real scripts from kit copies in a temporary HOME with fake codex +and claude that only record, and fabricated rollouts; nothing reaches a real +login or the network. +""" + +import base64 +import importlib.util +import json +import os +from pathlib import Path +import shutil +import stat +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from types import SimpleNamespace +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[1] + + +def load(name, path): + spec = importlib.util.spec_from_file_location(name, ROOT / path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +accounts = load("kit_accounts_under_test", "lib/coding-agent-kit/accounts.py") +# 2026-01-01 12:00:00 UTC, a Thursday. +NOW = 1767268800 + + +class Store(unittest.TestCase): + def setUp(self): + self.addCleanup(os.umask, os.umask(0o077)) + self.tmp = tempfile.TemporaryDirectory(prefix="kit-accounts-test-") + self.addCleanup(self.tmp.cleanup) + self.base = Path(self.tmp.name).resolve() + self.config = SimpleNamespace(state_dir=self.base / "state/coding-agent-kit", secrets_dir=self.base / "secrets") + self.dir = self.config.state_dir / "accounts" + patcher = patch.dict(os.environ, {"TZ": "UTC"}) + patcher.start() + self.addCleanup(patcher.stop) + time.tzset() + self.addCleanup(time.tzset) + + def put(self, name, data, mode=0o600): + self.dir.mkdir(mode=0o700, parents=True, exist_ok=True) + path = self.dir / name + path.write_text(data if isinstance(data, str) else json.dumps(data)) + path.chmod(mode) + return path + + def test_directory_is_created_private_and_only_on_request(self): + os.umask(0o022) # the mode is the module's, whatever the caller's umask + self.assertEqual(accounts.directory(self.config), self.dir) + self.assertFalse(self.config.state_dir.exists(), "a plain lookup created the directory") + self.assertEqual(accounts.directory(self.config, create=True), self.dir) + self.assertEqual(stat.S_IMODE(self.dir.lstat().st_mode), 0o700) + self.dir.chmod(0o755) + with self.assertRaisesRegex(ValueError, "0700"): + accounts.directory(self.config, create=True) + self.dir.chmod(0o700) + self.config.state_dir.chmod(0o777) + with self.assertRaisesRegex(ValueError, "other users cannot change"): + accounts.directory(self.config, create=True) + self.config.state_dir.chmod(0o700) + os.rename(self.dir, self.base / "real") + os.symlink(self.base / "real", self.dir) + with self.assertRaisesRegex(ValueError, "0700"): + accounts.directory(self.config, create=True) + + def test_the_directory_is_never_the_secrets_directory(self): + # Metadata files carry the credentials' names (codex-NAME.json): reading or writing one + # there would open or replace a login. + secrets = self.base / "real/coding-agent-kit/accounts" + secrets.mkdir(parents=True, mode=0o700) + credential = secrets / "codex-A.json" + credential.write_text('{"tokens": {"access_token": "fabricated-sensitive-access"}}') + credential.chmod(0o600) + (self.base / "link").symlink_to(self.base / "real") + for state in (self.base / "real/coding-agent-kit", # the same path + self.base / "link/coding-agent-kit"): # through a linked ancestor + config = SimpleNamespace(state_dir=state, secrets_dir=secrets) + for create in (False, True): + with self.subTest(state=state, create=create), self.assertRaisesRegex(ValueError, "secrets directory"): + accounts.directory(config, create=create) + with self.assertRaisesRegex(ValueError, "secrets directory"): + accounts.info("codex-as", "A", config, NOW) + self.assertEqual(accounts.labels("codex-as", ["A"], config), ["A"]) + self.assertEqual(credential.read_text(), '{"tokens": {"access_token": "fabricated-sensitive-access"}}') + + def test_reads_are_strict(self): + self.assertIsNone(accounts.read(self.dir / "codex-A.json")) + good = self.put("codex-A.json", {"email": "a@example.com"}) + self.assertEqual(accounts.read(good), {"email": "a@example.com"}) + for name, data, mode in (("loose.json", "{}", 0o644), ("dup.json", '{"a": 1, "a": 2}', 0o600), + ("nan.json", '{"a": NaN}', 0o600), ("list.json", "[]", 0o600), + ("big.json", '{"a": "' + "x" * accounts.LIMIT + '"}', 0o600), + ("bad.json", "{", 0o600)): + with self.subTest(name=name): + with self.assertRaises(ValueError) as caught: + accounts.read(self.put(name, data, mode)) + self.assertNotIn("x" * 20, str(caught.exception), "an error quoted the file") + os.symlink(good, self.dir / "link.json") + with self.assertRaises(ValueError): + accounts.read(self.dir / "link.json") + os.link(good, self.dir / "hard.json") + with self.assertRaises(ValueError): + accounts.read(good) + + def test_writes_are_atomic_private_and_only_on_change(self): + path = accounts.directory(self.config, create=True) / "codex-A.json" + self.assertTrue(accounts.write(path, {"email": "a@example.com"})) + self.assertEqual(stat.S_IMODE(path.lstat().st_mode), 0o600) + inode = path.lstat().st_ino + self.assertFalse(accounts.write(path, {"email": "a@example.com"}), "rewrote identical content") + self.assertEqual(path.lstat().st_ino, inode) + self.assertTrue(accounts.write(path, {"email": "b@example.com"})) + self.assertEqual(accounts.read(path), {"email": "b@example.com"}) + self.assertEqual(sorted(p.name for p in self.dir.iterdir()), ["codex-A.json"], "a temporary file was left") + # A link in its place is replaced, never written through. + victim = self.base / "victim" + victim.write_text("kept") + path.unlink() + os.symlink(victim, path) + self.assertTrue(accounts.write(path, {"email": "c@example.com"})) + self.assertEqual(victim.read_text(), "kept") + self.assertFalse(path.is_symlink()) + + def test_labels_refuse_control_characters_length_and_tokens(self): + self.assertIsNone(accounts.label_problem("alice@example.com (work)")) + self.assertIsNone(accounts.label_problem("x" * 100)) + self.assertIn("longer", accounts.label_problem("x" * 101)) + self.assertIn("control", accounts.label_problem("a\x1b[31mb")) + self.assertIn("control", accounts.label_problem("a\x85b")) + self.assertIn("token", accounts.label_problem("pasted sk-ant-oat01-abc")) + + def test_codex_info_shows_identity_usage_and_age(self): + names = {"primary": {"used_percent": 23.4, "window_minutes": 300, "resets_at": NOW + 2 * 3600 + 600}, + "secondary": {"used_percent": 41.0, "window_minutes": 10080, "resets_at": NOW + 3 * 86400 + 4 * 3600 + 120}, + "seen_at": NOW - 12 * 60} + self.put("codex-work.json", {"email": "alice@example.com", "plan": "plus", "usage": names}) + self.assertEqual(accounts.info("codex-as", "work", self.config, NOW).splitlines(), [ + "work (Codex)", + "email alice@example.com", + "plan plus", + "5h 23% used, resets 14:10 (in 2h 10m)", + "weekly 41% used, resets Sun 16:02 (in 3d 4h)", + "seen 12 min ago", + ]) + + def test_info_marks_reset_windows_and_missing_data(self): + self.assertEqual(accounts.info("codex-as", "A", self.config, NOW).splitlines(), [ + "A (Codex)", "email not recorded yet", "plan not recorded yet", "usage not recorded yet"]) + self.assertEqual(accounts.info("claude-as", "B", self.config, NOW).splitlines(), [ + "B (Claude)", "label not set (claude-as --set-label B LABEL)", "usage not recorded yet"]) + self.put("claude-B.json", {"label": "bob@example.com"}) + self.put("claude-B.usage.json", {"five_hour": {"used_percentage": 80, "resets_at": NOW - 60}, + "seven_day": {"used_percentage": 12.6, "resets_at": NOW + 1800}, + "seen_at": NOW - 3 * 86400}) + self.assertEqual(accounts.info("claude-as", "B", self.config, NOW).splitlines(), [ + "B (Claude)", "label bob@example.com", "5h reset since 11:59", + "weekly 13% used, resets 12:30 (in 30m)", "seen 3 d ago"]) + + def test_info_prints_only_allow_listed_and_clean_fields(self): + self.put("codex-A.json", {"email": "a@ex\x1b]0;owned\x07ample.com", "plan": "\x9bplus", + "tokens": {"access_token": "fabricated-sensitive-access"}, + "usage": {"primary": {"used_percent": 150, "window_minutes": 300}, + "secondary": {"used_percent": "50", "window_minutes": 10080}, + "seen_at": NOW}}) + text = accounts.info("codex-as", "A", self.config, NOW) + self.assertNotIn("fabricated", text) + self.assertNotIn("\x1b", text) + self.assertNotIn("\x9b", text) + self.assertEqual(text.splitlines(), ["A (Codex)", "email a@ex]0;ownedample.com", "plan plus", + "usage not recorded yet"]) + + def test_times_beyond_the_calendar_are_ignored(self): + for resets in (10**17, 10**19, 253402300800): + with self.subTest(resets=resets): + self.put("codex-A.json", {"usage": {"primary": {"used_percent": 5, "window_minutes": 300, + "resets_at": resets}, "seen_at": 10**19}}) + self.assertEqual(accounts.info("codex-as", "A", self.config, NOW).splitlines()[3:], ["5h 5% used"]) + + def test_unsafe_metadata_fails_the_query_without_quoting_it(self): + self.put("codex-A.json", {"email": "a@example.com"}, mode=0o644) + with self.assertRaises(ValueError) as caught: + accounts.info("codex-as", "A", self.config, NOW) + self.assertNotIn("a@example.com", str(caught.exception)) + + def test_labels_are_emails_or_labels_or_bare_names(self): + self.put("codex-A.json", {"email": "a@example.com\tx"}) + self.put("codex-C.json", {"email": "c@example.com"}, mode=0o644) + self.put("claude-B.json", {"label": "bob"}) + self.assertEqual(accounts.labels("codex-as", ["A", "B", "C"], self.config), + ["A\ta@example.comx", "B", "C"]) + self.assertEqual(accounts.labels("claude-as", ["A", "B"], self.config), ["A", "B\tbob"]) + + + +usage = load("kit_codex_usage_under_test", "lib/codex-as/usage.py") +USER, WORKSPACE = "user-fixture-A", "workspace-fixture-A" +THREAD, OTHER, RESUMED = ("019a0000-0000-7000-8000-00000000000a", "019a0000-0000-7000-8000-00000000000b", + "019a0000-0000-7000-8000-00000000000c") + + +def iso(epoch): + return time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime(epoch)) + + +def limits(primary, secondary=None, limit_id=None): + windows = [None if used is None else {"used_percent": used, "window_minutes": minutes, "resets_at": NOW + 3600} + for used, minutes in ((primary, 300), (secondary, 10080))] + return {"limit_id": limit_id, "limit_name": None, "primary": windows[0], "secondary": windows[1], "credits": None, + "plan_type": None} + + +def rollout(root, thread, lines, day="2026/01/01", creator=(USER, WORKSPACE), stamp=NOW, suffix=""): + """A rollout as native Codex writes it: session_meta, then event lines (WHEN, RATE_LIMITS).""" + path = Path(root) / "sessions" / day / f"rollout-{time.strftime('%Y-%m-%dT%H-%M-%S', time.gmtime(stamp))}-{thread}.jsonl{suffix}" + path.parent.mkdir(parents=True, exist_ok=True) + meta = {"id": thread, "timestamp": iso(stamp), "cwd": "/", "originator": "codex_cli_rs", "cli_version": "0.163.0"} + if creator: + meta.update(creator_user_id=creator[0], creator_account_id=creator[1]) + records = [{"timestamp": iso(stamp), "type": "session_meta", "payload": meta}] + for when, rate in lines: + records.append({"timestamp": iso(when), "type": "event_msg", + "payload": {"type": "token_count", "info": None, "rate_limits": rate}}) + with open(path, "a") as stream: + stream.write("".join(json.dumps(record) + "\n" for record in records)) + return path + + +class CodexUsage(unittest.TestCase): + """The rollout scan in process, on fabricated rollouts of a shared sessions directory.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix="kit-usage-test-") + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name).resolve() + patcher = patch.dict(os.environ, {"TZ": "UTC"}) + patcher.start() + self.addCleanup(patcher.stop) + time.tzset() + self.addCleanup(time.tzset) + self.start, self.end = NOW - 600, NOW + 600 + + def latest(self, ids=(), account=(USER, WORKSPACE)): + return usage.latest(self.root, ids, account, self.start, self.end) + + def touch_all(self): + for path in (self.root / "sessions").rglob("*"): + if path.is_file(): + os.utime(path, (NOW, NOW)) + + def test_a_new_session_of_this_account_gives_its_last_snapshot(self): + rollout(self.root, THREAD, [(NOW - 60, limits(10.0, 30.0)), (NOW, limits(23.5, 41.0)), (NOW + 1, None)]) + self.touch_all() + self.assertEqual(self.latest(), { + "primary": {"used_percent": 23.5, "window_minutes": 300, "resets_at": NOW + 3600}, + "secondary": {"used_percent": 41.0, "window_minutes": 10080, "resets_at": NOW + 3600}, + "seen_at": NOW}) + + def test_another_accounts_concurrent_session_is_ignored(self): + rollout(self.root, OTHER, [(NOW, limits(90.0))], creator=("user-fixture-B", "workspace-fixture-B")) + rollout(self.root, THREAD, [(NOW - 30, limits(5.0))]) + self.touch_all() + self.assertEqual(self.latest()["primary"]["used_percent"], 5.0) + # The same user in another workspace is another quota. + self.assertIsNone(self.latest(account=(USER, "workspace-other"))) + # An API-key login has no creator to match. + self.assertIsNone(self.latest(account=None)) + + def test_a_resumed_thread_counts_by_its_id_whoever_created_it(self): + old = rollout(self.root, RESUMED, [(NOW - 5 * 86400, limits(70.0))], day="2025/12/27", + creator=("user-fixture-B", "workspace-fixture-B"), stamp=NOW - 5 * 86400) + self.touch_all() + self.assertIsNone(self.latest(ids=(RESUMED,)), "a snapshot from before the run counted") + with open(old, "a") as stream: + stream.write(json.dumps({"timestamp": iso(NOW), "type": "event_msg", + "payload": {"type": "token_count", "info": None, "rate_limits": limits(12.0)}}) + "\n") + self.touch_all() + self.assertIsNone(self.latest(), "a resumed thread of another creator counted without its id") + self.assertEqual(self.latest(ids=(RESUMED,))["primary"]["used_percent"], 12.0) + + def test_compressed_null_foreign_and_stale_snapshots_are_skipped(self): + rollout(self.root, OTHER, [(NOW, limits(99.0))], suffix=".zst") + rollout(self.root, THREAD, [(NOW - 3600, limits(50.0)), # before the run + (NOW - 20, limits(8.0)), + (NOW - 10, None), + (NOW - 5, limits(77.0, limit_id="codex_bengalfox")), + (NOW - 2, {"limit_id": "codex", "primary": {"used_percent": "high"}})]) + self.touch_all() + self.assertEqual(self.latest(), {"primary": {"used_percent": 8.0, "window_minutes": 300, "resets_at": NOW + 3600}, + "secondary": None, "seen_at": NOW - 20}) + + def test_files_untouched_during_the_run_are_not_read(self): + path = rollout(self.root, THREAD, [(NOW, limits(30.0))]) + os.utime(path, (NOW - 3600, NOW - 3600)) + self.assertIsNone(self.latest()) + + def test_only_regular_rollouts_are_opened(self): + own = rollout(self.root, THREAD, [(NOW - 10, limits(20.0))]) + # A FIFO would block the read after Codex exits; a link could point anywhere. + fifo = own.with_name(f"rollout-2026-01-01T12-00-00-{OTHER}.jsonl") + os.mkfifo(fifo) + self.addCleanup(self.release, fifo) + elsewhere = rollout(self.base_elsewhere(), RESUMED, [(NOW, limits(90.0))]) + own.with_name(elsewhere.name).symlink_to(elsewhere) + self.touch_all() + found = [] + scan = threading.Thread(target=lambda: found.append(self.latest(ids=(OTHER, RESUMED))), daemon=True) + scan.start() + scan.join(10) + self.assertFalse(scan.is_alive(), "the scan blocked on a FIFO") + self.assertEqual(found[0]["primary"]["used_percent"], 20.0) + + def base_elsewhere(self): + path = self.root / "elsewhere" + path.mkdir(exist_ok=True) + return path + + @staticmethod + def release(fifo): + try: + os.close(os.open(fifo, os.O_WRONLY | os.O_NONBLOCK)) + except OSError: + pass + + def test_other_accounts_rollouts_never_crowd_this_ones_out(self): + rollout(self.root, THREAD, [(NOW - 30, limits(15.0))]) + rollout(self.root, RESUMED, [(NOW - 20, limits(25.0))], creator=("user-fixture-B", "workspace-fixture-B")) + self.touch_all() + def others(count): + for number in range(count): + path = rollout(self.root, f"019a0000-0000-7000-8000-0000000010{number:02x}", [(NOW, limits(99.0))], + creator=("user-fixture-B", "workspace-fixture-B")) + os.utime(path, (NOW + 60, NOW + 60)) + + # The file limit counts only this account's rollouts. + others(usage.MAX_FILES + 2) + self.assertEqual(self.latest()["primary"]["used_percent"], 15.0) + self.assertEqual(self.latest(ids=(RESUMED,))["primary"]["used_percent"], 25.0) + # Past MAX_HEADS newer foreign rollouts the search for its own stops; a continued thread comes first. + others(usage.MAX_HEADS) + self.assertIsNone(self.latest()) + self.assertEqual(self.latest(ids=(RESUMED,))["primary"]["used_percent"], 25.0) + + def test_reset_times_beyond_the_calendar_are_skipped(self): + rate = limits(30.0) + rate["primary"]["resets_at"] = 10**19 + rollout(self.root, THREAD, [(NOW - 20, limits(10.0)), (NOW - 10, rate)]) + self.touch_all() + self.assertEqual(self.latest()["primary"]["used_percent"], 10.0) + + def test_only_the_tail_is_read(self): + path = rollout(self.root, THREAD, [(NOW - 100, limits(40.0))]) + with open(path, "a") as stream: + filler = json.dumps({"timestamp": iso(NOW - 50), "type": "response_item", "payload": {"text": "x" * 1000}}) + "\n" + stream.write(filler * (usage.TAIL // len(filler) + 10)) + self.touch_all() + # The snapshot lies before the last MiB; the session_meta line is still read from the head. + self.assertIsNone(self.latest()) + with open(path, "a") as stream: + stream.write(json.dumps({"timestamp": iso(NOW), "type": "event_msg", + "payload": {"type": "token_count", "rate_limits": limits(41.0)}}) + "\n") + self.touch_all() + self.assertEqual(self.latest()["primary"]["used_percent"], 41.0) + + +CODEX_FAKE = r""" +import json, os, pathlib, sys, time +if sys.argv[1:] == ["--version"]: + print("codex-cli 0.163.0") + sys.exit(0) +home = pathlib.Path(os.environ["CODEX_HOME"]) +if os.environ.get("FAKE_PROBE"): + # What --account-info could show while this session runs. + state = pathlib.Path(os.environ["HOME"]) / ".local/state/coding-agent-kit/accounts/codex-A.json" + pathlib.Path(os.environ["FAKE_PROBE"]).write_text(state.read_text() if state.exists() else "absent") +for item in json.loads(os.environ.get("FAKE_ROLLOUTS", "[]")): + now = time.time() + item.get("offset", 0) + stamp = time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime(now)) + day = home / "sessions" / time.strftime("%Y/%m/%d", time.localtime(now)) + day.mkdir(parents=True, exist_ok=True) + path = day / ("rollout-" + time.strftime("%Y-%m-%dT%H-%M-%S", time.localtime(now)) + "-" + item["thread"] + ".jsonl") + meta = {"id": item["thread"], "timestamp": stamp} + if item.get("creator"): + meta.update(creator_user_id=item["creator"][0], creator_account_id=item["creator"][1]) + with open(path, "a") as stream: + stream.write(json.dumps({"timestamp": stamp, "type": "session_meta", "payload": meta}) + "\n") + for rate in item["limits"]: + stream.write(json.dumps({"timestamp": stamp, "type": "event_msg", + "payload": {"type": "token_count", "info": None, "rate_limits": rate}}) + "\n") +print("fake codex ran") +sys.exit(int(os.environ.get("FAKE_EXIT", "0"))) +""" + + +def id_token(claims): + return "fake." + base64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip("=") + ".fake" + + +class CodexLaunch(unittest.TestCase): + """codex-as from a kit copy, with a fake codex that writes rollouts like native Codex.""" + + def setUp(self): + self.addCleanup(os.umask, os.umask(0o022)) + self.tmp = tempfile.TemporaryDirectory(prefix="kit-account-launch-") + self.addCleanup(self.tmp.cleanup) + self.base = Path(self.tmp.name).resolve() + self.kit = self.base / "kit" + for part in ("bin", "lib", "docs"): + shutil.copytree(ROOT / part, self.kit / part) + self.home = self.base / "home with spaces" + self.home.mkdir(mode=0o700) + (self.home / ".codex").mkdir(mode=0o700) + (self.home / ".codex/config.toml").write_text('model = "fixture-model"\n') + (self.home / ".secrets").mkdir(mode=0o700) + self.credential("A", {"sub": "fixture-A", "email": "alice@example.com", + "https://api.openai.com/auth": {"chatgpt_plan_type": "plus", "chatgpt_user_id": USER, + "chatgpt_account_id": WORKSPACE}}) + fakes = self.base / "fake bin" + fakes.mkdir() + (fakes / "codex").write_text(f"#!{sys.executable}\n" + CODEX_FAKE) + (fakes / "codex").chmod(0o700) + self.env = {key: value for key, value in os.environ.items() + if not key.startswith(("ANTHROPIC_", "CLAUDE_", "CODEX_", "OPENAI_", "CODING_AGENT_KIT_", "XDG_"))} + self.env.update(HOME=str(self.home), TZ="UTC", PATH=os.pathsep.join((str(fakes), self.env.get("PATH", "/usr/bin:/bin")))) + self.state = self.home / ".local/state/coding-agent-kit/accounts/codex-A.json" + + def credential(self, name, claims, api_key=False): + path = self.home / f".secrets/codex-{name}.json" + if api_key: + data = {"auth_mode": "apikey", "OPENAI_API_KEY": "sk-fabricated-sensitive-key", "tokens": None} + else: + data = {"auth_mode": "chatgpt", "OPENAI_API_KEY": None, "last_refresh": "2026-10-01T00:00:00Z", + "tokens": {"id_token": id_token(claims), "access_token": "fabricated-sensitive-access-" + name, + "refresh_token": "fabricated-sensitive-refresh-" + name, + "account_id": claims.get("https://api.openai.com/auth", {}).get("chatgpt_account_id", name)}} + path.write_text(json.dumps(data)) + path.chmod(0o600) + + def run_codex(self, *args, env=None): + result = subprocess.run([str(self.kit / "bin/codex-as"), *args], cwd=self.base, env=self.env | (env or {}), + stdin=subprocess.DEVNULL, text=True, capture_output=True, timeout=30, start_new_session=True) + self.assertNotIn("fabricated-sensitive", result.stdout + result.stderr) + return result + + def stored(self): + raw = self.state.read_text() + self.assertNotIn("fabricated-sensitive", raw, "a token reached the metadata file") + self.assertEqual(stat.S_IMODE(self.state.lstat().st_mode), 0o600) + return json.loads(raw) + + def test_a_launch_records_the_email_and_plan(self): + probe = self.base / "probe" + result = self.run_codex("A", "exec", "hello", env={"FAKE_PROBE": str(probe)}) + self.assertEqual((result.returncode, result.stdout), (0, "fake codex ran\n"), result.stderr) + self.assertEqual(self.stored(), {"email": "alice@example.com", "plan": "plus"}) + # Recorded before native Codex starts, so a long session shows it too. + self.assertEqual(json.loads(probe.read_text()), {"email": "alice@example.com", "plan": "plus"}) + self.assertEqual(stat.S_IMODE(self.state.parent.lstat().st_mode), 0o700) + + def test_the_profile_claim_and_an_api_key_login(self): + self.credential("A", {"https://api.openai.com/profile": {"email": "fallback@example.com"}}) + self.run_codex("A", "exec", "hello") + self.assertEqual(self.stored(), {"email": "fallback@example.com"}) + self.credential("A", {}, api_key=True) + self.run_codex("A", "exec", "hello") + self.assertEqual(self.stored(), {"plan": "API key"}) + + def test_the_run_records_its_last_rate_limits_and_keeps_the_newest(self): + rollouts = [{"thread": THREAD, "creator": [USER, WORKSPACE], "limits": [limits(23.0, 41.0)]}, + {"thread": OTHER, "creator": ["user-fixture-B", WORKSPACE], "limits": [limits(99.0)]}] + result = self.run_codex("A", "exec", "hello", env={"FAKE_ROLLOUTS": json.dumps(rollouts), "FAKE_EXIT": "3"}) + self.assertEqual(result.returncode, 3, "the exit code of native Codex changed") + record = self.stored() + self.assertEqual(record["usage"]["primary"]["used_percent"], 23.0) + self.assertEqual(record["usage"]["secondary"]["window_minutes"], 10080) + seen = record["usage"]["seen_at"] + self.assertLessEqual(abs(seen - time.time()), 60) + # A run that wrote no snapshot keeps the last one. + self.run_codex("A", "exec", "hello") + self.assertEqual(self.stored()["usage"]["seen_at"], seen) + # An older snapshot never replaces a newer one. + future = dict(record["usage"], seen_at=seen + 86400) + self.state.write_text(json.dumps(dict(record, usage=future))) + self.run_codex("A", "exec", "hello", env={"FAKE_ROLLOUTS": json.dumps(rollouts)}) + self.assertEqual(self.stored()["usage"], future) + + def test_a_fork_never_counts_its_source_thread(self): + # Another account's session goes on in the source thread while this one forks it. + rollouts = [{"thread": THREAD, "creator": [USER, WORKSPACE], "limits": [limits(10.0)]}, + {"thread": RESUMED, "creator": ["user-fixture-B", "workspace-fixture-B"], "limits": [limits(95.0)], + "offset": 2}] + for args, used in ((("exec", "fork", RESUMED, "go"), 10.0), (("exec", "resume", RESUMED, "go"), 95.0)): + with self.subTest(args=args): + shutil.rmtree(self.home / ".codex/sessions", ignore_errors=True) + self.state.unlink(missing_ok=True) + result = self.run_codex("A", *args, env={"FAKE_ROLLOUTS": json.dumps(rollouts)}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.stored()["usage"]["primary"]["used_percent"], used) + + def test_a_secrets_dir_in_the_state_directory_is_refused_before_any_write(self): + secrets = self.home / "x/coding-agent-kit/accounts" + secrets.mkdir(parents=True, mode=0o700) + os.rename(self.home / ".secrets/codex-A.json", secrets / "codex-A.json") + before = (secrets / "codex-A.json").read_bytes() + (self.home / ".config/coding-agent-kit").mkdir(parents=True, mode=0o700) + (self.home / ".config/coding-agent-kit/config.toml").write_text('secrets_dir = "~/x/coding-agent-kit/accounts"\n') + result = self.run_codex("A", "exec", "hello", env={"XDG_STATE_HOME": str(self.home / "x")}) + self.assertEqual(result.returncode, 1) + self.assertIn("must differ from the account metadata directory", result.stderr) + self.assertEqual((secrets / "codex-A.json").read_bytes(), before) + # The same directory through a linked ancestor: the launch goes on and writes nothing there. + (self.home / "st").symlink_to("x") + for args, code in ((("A", "exec", "hello"), 0), (("--account-info", "A"), 1)): + with self.subTest(args=args): + result = self.run_codex(*args, env={"XDG_STATE_HOME": str(self.home / "st")}) + self.assertEqual(result.returncode, code, result.stderr) + self.assertEqual(sorted(os.listdir(secrets)), ["codex-A.json"]) + self.assertEqual((secrets / "codex-A.json").read_bytes(), before) + self.assertIn("must not be the secrets directory", result.stderr) + + def test_an_unusable_state_directory_never_fails_the_launch(self): + state = self.home / ".local/state" + state.mkdir(parents=True) + (state / "coding-agent-kit").write_text("in the way") + result = self.run_codex("A", "exec", "hello") + self.assertEqual((result.returncode, result.stdout, result.stderr), (0, "fake codex ran\n", "")) + self.assertEqual(result.returncode, 0) + (state / "coding-agent-kit").unlink() + (state / "coding-agent-kit/accounts").mkdir(parents=True, mode=0o755) + (state / "coding-agent-kit/accounts").chmod(0o755) + result = self.run_codex("A", "exec", "hello") + self.assertEqual((result.returncode, result.stderr), (0, "")) + self.assertFalse(self.state.exists(), "metadata was written into a directory others can read") + + +class Remember(unittest.TestCase): + """codex-as's metadata write in process, racing another run of the same account.""" + + def test_a_newer_record_written_during_the_scan_is_kept(self): + self.addCleanup(os.umask, os.umask(0o077)) + launch = load("kit_codex_launch_under_test", "lib/codex-as/launch.py") + tmp = tempfile.TemporaryDirectory(prefix="kit-remember-test-") + self.addCleanup(tmp.cleanup) + base = Path(tmp.name).resolve() + config = SimpleNamespace(state_dir=base / "state", secrets_dir=base / "secrets") + layout = SimpleNamespace(name="A", root=base, target=base / "unused") + plan = SimpleNamespace(continued=(), resume_id=None, recover_id=None) + claims = {"email": "a@example.com", "https://api.openai.com/auth": {"chatgpt_plan_type": "plus", + "chatgpt_user_id": USER}} + raw = json.dumps({"tokens": {"id_token": id_token(claims), "account_id": WORKSPACE}}).encode() + + def snapshot(seen): + return {"primary": {"used_percent": 1.0, "window_minutes": 300, "resets_at": None}, "secondary": None, + "seen_at": seen} + + def scan(root, ids, account, started, ended): + # The other run finishes while this one still reads its rollouts. + path = launch.metadata.directory(config, create=True) / "codex-A.json" + launch.metadata.write(path, {"email": "a@example.com", "plan": "plus", "usage": snapshot(NOW + 100)}) + return snapshot(NOW) + + with patch.object(launch.usage, "latest", scan): + launch.remember(config, layout, raw, plan, NOW - 60) + stored = json.loads((config.state_dir / "accounts/codex-A.json").read_text()) + self.assertEqual(stored["usage"]["seen_at"], NOW + 100) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_codex_as.py b/tests/test_codex_as.py index b3d0125..7dc84da 100644 --- a/tests/test_codex_as.py +++ b/tests/test_codex_as.py @@ -45,7 +45,8 @@ # OPENAI_BASE_URL only routes the user's own API key for tool subprocesses. ENV_UNUSED = ("OPENAI_ORG_ID", "OPENAI_PROJECT_ID", "CODEX_CHATGPT_BASE_URL", "CODEX_CHATGPT_ACCOUNT_ID", "OPENAI_BASE_URL") # The kit's own knobs: a caller's value never reaches a fixture, which sets what it needs. -KIT_KNOBS = ("CODING_AGENT_KIT_CONFIG", "CODING_AGENT_KIT_COLOR", "XDG_CONFIG_HOME", "NO_COLOR") +KIT_KNOBS = ("CODING_AGENT_KIT_CONFIG", "CODING_AGENT_KIT_COLOR", "CODING_AGENT_KIT_USAGE_FILE", "CODING_AGENT_KIT_TOKEN_ID", + "XDG_CONFIG_HOME", "XDG_STATE_HOME", "NO_COLOR") FAKE = r''' import json, os, pathlib, sys, time, signal if sys.argv[1:] == ["--version"]: @@ -694,6 +695,13 @@ def test_picker_scope_arguments(self): for args in (['fork', uuid_text], ['exec', 'resume', uuid_text], ['exec', 'fork', uuid_text]): with self.subTest(args=args): self.assertEqual(check.arguments(args, config).recover_id, uuid_text) + # The threads a run appends to, whose rollouts are this account's whoever created them: + # a fork writes a new thread and never its source. + for args, threads in ((['resume', uuid_text], (uuid_text,)), (['exec', 'resume', uuid_text, 'go'], (uuid_text,)), + (['fork', uuid_text], ()), (['exec', 'fork', uuid_text, 'go'], ()), + (['exec', 'go'], ()), (['resume', '--last'], ())): + with self.subTest(args=args): + self.assertEqual(check.arguments(args, config).continued, threads) def test_picker_leaves_possibly_running_sessions_and_names_them(self): daemon, ids, attempts, run = self.handoff_fixture() diff --git a/tests/test_config.py b/tests/test_config.py index a57538b..e2be89d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -195,6 +195,18 @@ def test_location_precedence(self): ("from-explicit", explicit, "from CODING_AGENT_KIT_CONFIG")) self.assertEqual(self.load(CODING_AGENT_KIT_CONFIG="").codex, "from-home") + def test_state_dir_follows_an_absolute_xdg_state_home(self): + # The account metadata cache; not a key, so a configuration file cannot move it. + default = self.home / ".local/state/coding-agent-kit" + self.assertEqual(self.load().state_dir, default) + state = self.base / "xdg state" + self.assertEqual(self.load(XDG_STATE_HOME=str(state)).state_dir, state / "coding-agent-kit") + for value in ("relative/state", ""): + self.assertEqual(self.load(XDG_STATE_HOME=value).state_dir, default) + write(self.file, 'state_dir = "/elsewhere"\n') + loaded = self.load() + self.assertEqual((loaded.state_dir, loaded.warnings), (default, (f"{self.file}: unknown key state_dir ignored",))) + def test_explicit_file_must_be_absolute_and_exist(self): self.assertEqual(self.fails(CODING_AGENT_KIT_CONFIG="relative.toml"), "relative.toml: CODING_AGENT_KIT_CONFIG must be an absolute path.") @@ -583,6 +595,23 @@ def test_codex_home_and_secrets_dir_never_nest(self): write(self.file, 'codex_home = "~/a"\nsecrets_dir = "~/ab"\n') # a prefix, not a parent self.assertEqual(self.load().secrets_dir, self.home / "ab") + def test_secrets_dir_is_never_the_metadata_directory(self): + # The metadata files are named like the Codex credentials (codex-NAME.json), so one + # directory for both would let a launch replace a login with its metadata. + default, moved = self.home / ".local/state/coding-agent-kit/accounts", self.home / "x/coding-agent-kit/accounts" + for secrets_dir, env, accounts in (("~/.local/state/coding-agent-kit/accounts", {}, default), + ("~/.local/state/coding-agent-kit/accounts/", {}, default), + (f"/{self.home}/.local/state//coding-agent-kit/accounts", {}, default), + ("~/x/coding-agent-kit/accounts", {"XDG_STATE_HOME": str(self.home / "x")}, moved)): + with self.subTest(secrets_dir=secrets_dir, env=env): + write(self.file, f'secrets_dir = "{secrets_dir}"\n') + self.assertEqual(self.fails(**env), + f"{self.file}: secrets_dir: must differ from the account metadata directory {accounts}.") + # Above or below it, each file keeps its own name. + for secrets_dir in ("~/.local/state/coding-agent-kit", "~/.local/state/coding-agent-kit/accounts/secrets"): + write(self.file, f'secrets_dir = "{secrets_dir}"\n') + self.assertEqual(self.load().secrets_dir, config.place(self.home, secrets_dir)) + def test_a_leading_double_slash_is_the_root(self): # pathlib keeps exactly two leading slashes as their own root; Linux and macOS read them as /. for codex_home, secrets_dir, reason in ( @@ -875,6 +904,9 @@ def test_incomplete_installs_name_the_root_without_a_traceback(self): def test_metadata_queries_start_nothing_and_ignore_launch_policy(self): shutil.rmtree(self.home / ".codex") + # Safe but unusable credentials: a query that opened one would fail. + write(self.home / ".secrets/codex-A.json", "not JSON") + write(self.home / ".secrets/claude-work", "not a token\n") before = snapshot(self.home) # Conflicting variables stop a launch in preflight; the queries never run it. env = {"OPENAI_API_KEY": "fabricated-access-conflict", "CLAUDE_CODE_OAUTH_TOKEN": "fabricated-access-conflict", @@ -891,14 +923,33 @@ def test_metadata_queries_start_nothing_and_ignore_launch_policy(self): if tool == "codex-as": expected.append(f"codex_home = {self.home}/.codex") self.assertEqual(result.stdout.splitlines(), [*expected, f"secrets_dir = {self.home}/.secrets"]) + # Account metadata: read only from the state directory, which none of these creates. + name = names.strip() + label = "codex-A.json" if tool == "codex-as" else "claude-work.json" + result = self.run_tool([self.kit / "bin" / tool, "--account-labels"], env) + self.assertEqual((result.returncode, result.stdout, result.stderr), (0, names, "")) + result = self.run_tool([self.kit / "bin" / tool, "--account-info", name], env) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.splitlines()[0], f"{name} ({'Codex' if tool == 'codex-as' else 'Claude'})") + self.assertIn("not recorded yet", result.stdout) + result = self.run_tool([self.kit / "bin" / tool, "--account-info", "missing"], env) + self.assertEqual((result.returncode, result.stdout, result.stderr), (1, "", f"{tool}: no account missing.\n")) + self.assertFalse(os.path.lexists(self.home / ".local/state"), label) self.assertEqual(snapshot(self.home), before, "a metadata query wrote to HOME") self.assertEqual(self.records(), [], "a metadata query started a native CLI") - # Only alone: with more arguments the option is an invalid codex-as account name. - for args in (["--list-accounts", "x"], ["--show-config", "--show-config"]): + # Only alone, or --account-info with one name: with other arguments the + # option is an invalid codex-as account name. + for args in (["--list-accounts", "x"], ["--show-config", "--show-config"], ["--account-labels", "A"], + ["--account-info"], ["--account-info", "A", "x"], ["--account-info", "-A"]): result = self.run_tool([self.kit / "bin/codex-as", *args]) - self.assertEqual(result.returncode, 2) + self.assertEqual(result.returncode, 2, args) + self.assertIn("invalid account name", result.stderr) + for args in (["--account-info"], ["--account-info", "work", "x"], ["--account-info", "-x"]): + result = self.run_tool([self.kit / "bin/claude-as", *args]) + self.assertEqual(result.returncode, 1, args) self.assertIn("invalid account name", result.stderr) self.assertEqual(self.run_tool([self.kit / "bin/codex-as", "--list-account"]).returncode, 2) + self.assertEqual(self.records(), []) def completion(self, shell, tool, home, env=None): env = (env or {}) | {"HOME": str(home), "PATH": os.pathsep.join((str(home / ".local/bin"), self.env["PATH"]))} @@ -967,6 +1018,23 @@ def test_linked_install_completions_and_old_helpers_follow_the_configuration(sel self.assertEqual((result.returncode, result.stdout, result.stderr), (0, name + "\n", ""), shell) self.assertEqual(self.records(), []) + @unittest.skipUnless(shutil.which("zsh"), "zsh not installed") + def test_zsh_completion_describes_accounts_by_their_email_or_label(self): + linked = self.base / "linked home" + self.linked_home(linked) + state = linked / ".local/state/coding-agent-kit/accounts" + state.mkdir(parents=True, mode=0o700) + write(state / "codex-A.json", json.dumps({"email": "alice@example.com"})) + write(state / "claude-work.json", json.dumps({"label": "Work: billing"})) + for tool, expected in (("codex-as", "A:alice@example.com\n"), ("claude-as", "work:Work: billing\n")): + with self.subTest(tool=tool): + result = self.completion("zsh", tool, linked) + self.assertEqual((result.returncode, result.stdout, result.stderr), (0, expected, "")) + # Bash completes bare names. + result = self.completion("bash", tool, linked) + self.assertEqual(result.stdout, expected.split(":")[0] + "\n") + self.assertEqual(self.records(), []) + def alternate_secrets(self, home): secrets = home / "vault" secrets.mkdir(mode=0o700) @@ -1149,8 +1217,8 @@ def test_explicit_defaults_reproduce_the_defaults_exactly(self): self.assertEqual(self.golden(variant), expected) def test_kit_code_reads_home_only_through_the_loader(self): - reads = re.compile(r'os\.environ\[["\']HOME["\']\]|environ\.get\(["\']HOME["\']|getenv\(["\']HOME["\']' - r'|Path\.home\(\)|expanduser') + reads = re.compile(r'os\.environ\[["\'](?:XDG_STATE_)?HOME["\']\]|environ\.get\(["\'](?:XDG_STATE_)?HOME["\']' + r'|getenv\(["\'](?:XDG_STATE_)?HOME["\']|Path\.home\(\)|expanduser') python = [path for path in ROOT.glob("lib/**/*.py") if path != ROOT / "lib/coding-agent-kit/config.py"] self.assertGreaterEqual(len(python), 5) for path in python: @@ -1158,7 +1226,7 @@ def test_kit_code_reads_home_only_through_the_loader(self): self.assertEqual(reads.findall(path.read_text()), []) for path in (*ROOT.glob("bin/*"), *ROOT.glob("completions/*/*")): with self.subTest(path=path.relative_to(ROOT)): - self.assertEqual(re.findall(r"\$\{?HOME\b", path.read_text()), []) + self.assertEqual(re.findall(r"\$\{?(?:XDG_STATE_)?HOME\b", path.read_text()), []) if __name__ == "__main__": From 081f54ae953ca6524865eb7819968c445b3919b6 Mon Sep 17 00:00:00 2001 From: FridrichMethod Date: Wed, 21 Oct 2026 04:13:57 -0700 Subject: [PATCH 3/3] docs: describe the account metadata cache for v0.3.0 docs/account-launchers.md gains an account metadata section: where the cache lives, its three files and their writers, the --account-info output, where each field comes from and its known gaps (the Codex rollout format is internal; a /resume inside Codex is missed; Claude usage needs a Pro or Max plan and a rendered status line), and a privacy note. configuration.md says state_dir is not a key, codex-as.md and README list the new queries, AGENTS.md extends the public contract, and CHANGELOG gains 0.3.0. --- AGENTS.md | 22 ++++++++----- CHANGELOG.md | 65 +++++++++++++++++++++++++++++++++++++++ README.md | 25 +++++++++++---- docs/account-launchers.md | 36 ++++++++++++++++++++-- docs/codex-as.md | 2 +- docs/configuration.md | 29 ++++++++++++++--- 6 files changed, 158 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 90b7485..21d9f52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,9 +12,10 @@ consumer, a submodule whose files it links. ``` bin/codex-as, bin/claude-as launchers (Bash, POSIX sh) -lib/codex-as/*.py codex-as modules: launch, check, layout, handoff +lib/codex-as/*.py codex-as modules: launch, check, layout, handoff, usage lib/claude-as/launch.py claude-as module lib/coding-agent-kit/config.py the configuration loader both launchers share +lib/coding-agent-kit/accounts.py the account metadata cache both launchers share completions/{bash,zsh}/ account-name completion for both launchers helpers/*.cjs Claude Code helpers (Node) install.sh links bin/ and completions/ into a prefix @@ -34,7 +35,7 @@ tests/ suites, fixtures, runners, COVERAGE.md loads its modules inside the error handling, so a missing file prints `incomplete install at ROOT`, never a traceback. - `lib/coding-agent-kit/config.py` is the only launcher or completion code - that reads `HOME` or the configuration file (a test greps for other reads). It loads once per run into + that reads `HOME`, `XDG_STATE_HOME` or the configuration file (a test greps for other reads). It loads once per run into one frozen object that every consumer takes. Unknown keys warn and never fail, so a newer configuration file works with an older kit. See [docs/configuration.md](docs/configuration.md). @@ -48,7 +49,7 @@ tests/ suites, fixtures, runners, COVERAGE.md a pre-commit hook: - The public paths and their modes (`git ls-files -s`): the launchers, their - `lib/` modules, `config.py`, the completions, the helpers, `install.sh` and + `lib/` modules, `config.py` and `accounts.py`, the completions, the helpers, `install.sh` and the three docs. Consumers link these paths one by one (dotfiles links each file from its own tree), copied installs depend on the `bin/` plus `lib/` shape, and `--help` points at `docs/account-launchers.md`. @@ -65,9 +66,14 @@ a pre-commit hook: The command-line surface is public too, guarded by the suites rather than `contract.sh`: the help text (dotfiles tests grep its `Concurrent accounts` -line and its `Details and manual repair:` path), `--list-accounts` and -`--show-config` with their output, the configuration file location, keys and -defaults, `install.sh` and its options, and `CODING_AGENT_KIT_COLOR`. Changing any of it, or anything `contract.sh` pins, +line and its `Details and manual repair:` path), `--list-accounts`, +`--account-labels`, `--account-info NAME` (dotfiles' completion preview shows +it), `claude-as --set-label` and `--show-config` with their output, the +configuration file location, keys and defaults, the account metadata files in +`${XDG_STATE_HOME:-~/.local/state}/coding-agent-kit/accounts/` with their +fields, `install.sh` and its options, `CODING_AGENT_KIT_COLOR`, and the +`CODING_AGENT_KIT_USAGE_FILE` and `CODING_AGENT_KIT_TOKEN_ID` that `claude-as` +passes its child. Changing any of it, or anything `contract.sh` pins, is a breaking change: it needs a minor release and a matching CHANGELOG entry. ## Required conventions @@ -108,11 +114,11 @@ After modifying any file, run `pre-commit run --all-files` to ensure changes pas - Bash: `shfmt -i 4 -ci -ln bash` - POSIX: `shfmt -i 4 -ci -ln posix` - Zsh: `shfmt -i 4 -ci -ln zsh` (needs shfmt 3.13+, the first release with a zsh dialect) -- Launcher messages follow one colour policy in shell and Python: `CODING_AGENT_KIT_COLOR=auto|always|never` (unset or invalid means `auto`, styled only when the destination is a terminal), and a nonempty `NO_COLOR` or `TERM=dumb` disables color. Native CLI output, completion lists and the output of `--list-accounts` and `--show-config` stay untouched, and redirected logs stay plain by default. +- Launcher messages follow one colour policy in shell and Python: `CODING_AGENT_KIT_COLOR=auto|always|never` (unset or invalid means `auto`, styled only when the destination is a terminal), and a nonempty `NO_COLOR` or `TERM=dumb` disables color. Native CLI output, completion lists and the output of `--list-accounts`, `--account-labels`, `--account-info` and `--show-config` stay untouched, and redirected logs stay plain by default. ## Testing -- Every test entry point (`tests/*.sh`) starts with the one statement `unset CODING_AGENT_KIT_CONFIG CODING_AGENT_KIT_COLOR XDG_CONFIG_HOME NO_COLOR`, and `tests/run.ps1` removes the same names in the same order. `tests/test-entrypoints.sh` checks each entry point against `tests/run.sh`, and that the statement names every `CODING_AGENT_KIT_*` variable (pattern `_?CODING_AGENT_KIT_[A-Z_]+`) in a tracked file outside `tests/`, `docs/`, `.github/` and Markdown, so a knob a new file reads goes on that list too. Python tests that pass the caller's environment on drop the same names in `setUp`. +- Every test entry point (`tests/*.sh`) starts with the one statement `unset CODING_AGENT_KIT_CONFIG CODING_AGENT_KIT_COLOR CODING_AGENT_KIT_USAGE_FILE CODING_AGENT_KIT_TOKEN_ID XDG_CONFIG_HOME XDG_STATE_HOME NO_COLOR`, and `tests/run.ps1` removes the same names in the same order. `tests/test-entrypoints.sh` checks each entry point against `tests/run.sh`, and that the statement names every `CODING_AGENT_KIT_*` variable (pattern `_?CODING_AGENT_KIT_[A-Z_]+`) in a tracked file outside `tests/`, `docs/`, `.github/` and Markdown, so a knob a new file reads goes on that list too. Python tests that pass the caller's environment on drop the same names in `setUp`. - Behavior coverage is tracked as a source-to-test matrix in `tests/COVERAGE.md`; update it with the tests. ## Windows line endings diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d9b542..f93bf71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,70 @@ # Changelog +## 0.3.0 + +Account details for previews and people: each launcher keeps a small, +offline metadata cache per account, and new queries print it without opening +a credential. A minor release: it adds environment variables, state files, +an enrolment prompt, help lines and queries. + +Added: + +- `codex-as --account-info NAME` and `claude-as --account-info NAME` print an + account's last-seen details: the Codex email and plan, the Claude label, and + the 5-hour and weekly usage with their reset times and the age of the + snapshot, or `not recorded yet`. `--account-labels` prints `NAMELABEL` + lines (the email for Codex, the label for Claude). Both are metadata only: + no directory is created, no preflight or native CLI runs, no credential is + opened, and only allow-listed fields are printed, with control characters + removed. +- The cache lives in `${XDG_STATE_HOME:-~/.local/state}/coding-agent-kit/accounts/` + (0700): `codex-NAME.json` (written by `codex-as`), `claude-NAME.json` + (`claude-as`) and `claude-NAME.usage.json` (`helpers/statusline.cjs`), each + 0600, replaced atomically and only when it changes. No token is stored. + `Config.state_dir` in `lib/coding-agent-kit/config.py` names it; it is not a + configuration key. +- `codex-as` records the email and plan from the account's `id_token` at each + launch and, after each run, the last rate-limit snapshot of the run's own + rollouts (the thread it resumes, never a fork's source, or one this account created), + best effort and without changing the exit code. New module + `lib/codex-as/usage.py`. +- `claude-as` asks for an optional label when it creates an account, after + the confirmation and before any token exists, and saves it after the token; + control characters, more than 100 characters and anything containing + `sk-ant-` are refused. `claude-as --set-label NAME [LABEL]` sets, asks for + or (empty) clears the label of an existing account. +- `claude-as` passes its child `CODING_AGENT_KIT_USAGE_FILE` and + `CODING_AGENT_KIT_TOKEN_ID` (the first 16 hex digits of the token's + SHA-256), dropping inherited values. `helpers/statusline.cjs` exports + `recordUsage` and records the session's `rate_limits` there only while + `CLAUDE_CODE_OAUTH_TOKEN` hashes to that identifier, into an owned 0700 + directory, never on Windows; its printed status line is unchanged. +- New shared module `lib/coding-agent-kit/accounts.py`, the cache. +- A configuration whose `secrets_dir` is the cache directory is refused, + since the cache's `codex-NAME.json` would replace the credential of that + name; a cache directory that is `secrets_dir` through a link is never read + or written. + +Changed: + +- The zsh completions run `--account-labels` and show each account's email + or label as its description; Bash still completes bare names. +- Both `--help` texts gain two lines for the new queries. +- `bin/codex-as` accepts `--account-labels` alone and `--account-info NAME`; + other arities stay invalid account names. +- Every test entry point also unsets `CODING_AGENT_KIT_USAGE_FILE`, + `CODING_AGENT_KIT_TOKEN_ID` and `XDG_STATE_HOME`. +- The tests that model a dotfiles host follow its layout from October 2026: + the kit checked out at the root of dotfiles' own `coding-agent-kit` package. + +Known limits: Codex usage comes from Codex's internal rollout format, misses +a session switched inside Codex, skips compressed rollouts, can miss this +account's new session past 64 other accounts' rollouts of the run, and can +count a thread this account created that another account resumed during the +run. +Claude usage exists only for Pro and Max subscribers, after the first API +response, in sessions that render a status line. + ## 0.2.0 coding-agent-kit becomes a standalone install. The launchers find their own diff --git a/README.md b/README.md index 29898ca..ec37e7a 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,8 @@ codex-as work resume --last codex-as work exec --json "Check the tests" claude-as personal -p "Check the tests" codex-as --list-accounts # account names, what completion offers +codex-as --account-info work # last-seen email, plan and usage (offline) +claude-as --set-label personal "me@example.com" claude-as --show-config # configuration in effect codex-as --help ``` @@ -158,8 +160,18 @@ codex-as --help - `claude-as` starts Claude Code with the `claude setup-token` OAuth token from `/claude-NAME` and otherwise inherits the existing configuration. -- Apart from `--help`, `--list-accounts` and `--show-config`, each accepted - only alone, the launchers have no options of their own. +- Apart from `--help`, `--list-accounts`, `--account-labels` and + `--show-config`, each accepted only alone, `--account-info NAME` and + `claude-as --set-label NAME [LABEL]`, the launchers have no options of their + own. +- Each launcher keeps a small metadata cache per account in + `${XDG_STATE_HOME:-~/.local/state}/coding-agent-kit/accounts/`: the Codex + email and plan from the account's token and the last rate limits a run saw + in its own rollouts, and the Claude label asked at enrolment and the last + rate limits the status line helper saw. `--account-info NAME` prints it + offline, without opening a credential, and the zsh completions show the + email or label as each account's description. Emails show in previews and + transcripts; no token is ever stored there. [docs/account-launchers.md](docs/account-launchers.md) covers the account layout, adding accounts, completion, safety boundaries and manual repair; @@ -197,8 +209,8 @@ enrolment, and after moving `codex_home` export `CODEX_HOME` for plain The launchers style only their own messages: headings cyan, questions and warnings yellow, saves green, errors red. Native Codex and Claude output, -completion lists and `--list-accounts`/`--show-config` output are never -styled. +completion lists and the output of `--list-accounts`, `--account-labels`, +`--account-info` and `--show-config` are never styled. - `CODING_AGENT_KIT_COLOR=auto` (also when unset or invalid) styles a message only when its destination is a terminal, so redirected logs stay plain; @@ -224,8 +236,9 @@ pre-commit run --all-files - `run.ps1` runs the Node helper suite; `-CI` requires Windows. - Every suite runs in a temporary HOME with fake CLIs and fictitious credentials, reaches no network beyond local fixtures, and first unsets - `CODING_AGENT_KIT_CONFIG`, `CODING_AGENT_KIT_COLOR`, `XDG_CONFIG_HOME` and - `NO_COLOR`. + `CODING_AGENT_KIT_CONFIG`, `CODING_AGENT_KIT_COLOR`, + `CODING_AGENT_KIT_USAGE_FILE`, `CODING_AGENT_KIT_TOKEN_ID`, + `XDG_CONFIG_HOME`, `XDG_STATE_HOME` and `NO_COLOR`. - `tests/standalone-e2e.sh` installs with `install.sh` under `/bin/sh`, bash and (when present) dash, checks its refusals and drives both launchers and their completions through the links; diff --git a/docs/account-launchers.md b/docs/account-launchers.md index 451ca35..d4b1487 100644 --- a/docs/account-launchers.md +++ b/docs/account-launchers.md @@ -22,7 +22,7 @@ Valid local credentials launch immediately. A missing account lists existing acc Claude setup-token tokens typically last one year and do not renew automatically. They support model requests, with no guarantee for Remote Control or claude.ai connectors; local MCP remains usable. [Official documentation](https://code.claude.com/docs/en/authentication) -Confirmation and login I/O use a separate `/dev/tty`, preserving the final stdin/stdout/stderr, cwd, arguments, and exit code. A missing account is refused without a controlling terminal; there is no `--yes`. The wrapper's own `--help`, `--list-accounts` and `--show-config`, and `codex-as NAME` with a root help or version flag, `help`, `update` or `completion`, run without the account and never start a login. +Confirmation and login I/O use a separate `/dev/tty`, preserving the final stdin/stdout/stderr, cwd, arguments, and exit code. A missing account is refused without a controlling terminal; there is no `--yes`. The wrapper's own `--help`, `--list-accounts`, `--account-labels`, `--account-info NAME` and `--show-config` (and `claude-as --set-label`), and `codex-as NAME` with a root help or version flag, `help`, `update` or `completion`, run without the account and never start a login. ## Codex account layout @@ -56,9 +56,40 @@ Managed policy is respected: automatic enrollment stops for nonempty managed cac New account files are created exclusively; incorrect permissions, ownership, symlinks, or hard links are refused. Account names must be nonempty, contain only ASCII letters, digits, underscores, and hyphens, and not start with a hyphen, as in codex-as. Local format validation is not server authentication; expired credentials are not automatically deleted or replaced through login. +## Account metadata + +Both launchers keep a small cache of what they last saw about each account, so a completion preview or a person can look it up offline: `codex-as --account-info NAME` and `claude-as --account-info NAME`. It lives in `${XDG_STATE_HOME:-~/.local/state}/coding-agent-kit/accounts/` (an absolute `XDG_STATE_HOME` only; the directory is created 0700 on first use and must stay an owned, real 0700 directory), never in `secrets_dir`: a configuration whose `secrets_dir` is that directory is refused, and one that reaches it through a link is never read or written. Each file is a 0600 JSON object, replaced atomically and only when its content changes, with one writer: + +| File | Content | Written by | +| --- | --- | --- | +| `codex-NAME.json` | email, plan, and the last usage snapshot | `codex-as`, before and after each run | +| `claude-NAME.json` | the label | `claude-as` enrolment or `claude-as --set-label` | +| `claude-NAME.usage.json` | the last 5-hour and weekly windows | `helpers/statusline.cjs` | + +No file holds a token, and no time in them comes from the launcher's clock: a Codex snapshot carries the time of the rollout line it came from, a Claude one the time the status line saw it. + +```text +$ codex-as --account-info work +work (Codex) +email alice@example.com +plan plus +5h 23% used, resets 14:30 (in 2h 10m) +weekly 41% used, resets Mon 09:00 (in 3d 4h) +seen 12 min ago +``` + +Times are local. A window whose reset time has passed reads `reset since HH:MM`; missing data reads `not recorded yet`, and an account without a label reads `not set (claude-as --set-label NAME LABEL)`. `--account-labels` prints one line per account, `NAMELABEL` (the email for Codex, the label for Claude) or the bare name when there is none; the zsh completions use it as descriptions. Both queries, like `--list-accounts`, create no directory, run no preflight or native CLI and never open a credential: they read only the state files, accept only an owned regular 0600 file with one link, at most 64 KiB, with unique keys, and print only the fields above with control characters removed. An unsafe file fails `--account-info` with one line that never quotes it; `--account-labels` shows that account's bare name. + +- **Codex identity**: the email (the `email` claim, else the profile claim's `email`) and the plan (`chatgpt_plan_type`) from the account's `id_token`, decoded locally at each launch and again after the run. An API-key login records the plan `API key` and no email. The plan changes only when Codex refreshes the token. +- **Codex usage**: after native Codex exits, codex-as reads the session rollouts modified during the run, only regular files (never a FIFO, device or link), and keeps the newest `token_count` event with rate limits of the default `codex` family timestamped within the run. A rollout counts when its thread is the one the run resumes (`resume UUID`, `exec resume UUID`; a fork writes a new thread, so its source never counts), or when its `session_meta` names this account's ChatGPT user and workspace as the creator. The resumed thread comes first, then the newest rollouts: codex-as reads the tail (at most 1 MiB) of up to eight of this account's, after the first lines of at most 64 others. Of concurrent runs of one account the newest snapshot normally wins: each run reads the stored record only just before it replaces it. Times from the year 10000 on are ignored. The windows are named by their length (300 minutes is `5h`, 10080 is `weekly`). This is best effort, silent on failure and never changes the exit code. Known gaps: the rollout format is Codex's internal one, so a change leaves usage blank; a session switched inside Codex (`/resume`) is missed; compressed rollouts (`.jsonl.zst`) are skipped; past 64 other accounts' rollouts modified during the run, this account's own new session can be missed; and another account that resumes, during the run, a thread this account created can be counted here. +- **Claude label**: a new account asks `Label for this account (optional, e.g. your email):` after the confirmation and before any token exists, and saves the label only after the token. Control characters, more than 100 characters and anything containing `sk-ant-` are refused, so a pasted token is never stored. `claude-as --set-label NAME LABEL` sets the label of an existing account, `claude-as --set-label NAME` asks for it on `/dev/tty`, and an empty label clears it. +- **Claude usage**: Claude Code gives the status line its rate limits only for Pro and Max subscribers and only after the first API response; headless `claude-as NAME -p` runs render no status line and record nothing. claude-as passes its child `CODING_AGENT_KIT_USAGE_FILE` (the path of `claude-NAME.usage.json`) and `CODING_AGENT_KIT_TOKEN_ID` (the first 16 hex digits of the token's SHA-256), and drops inherited values first. `helpers/statusline.cjs` records only when both are set, `CLAUDE_CODE_OAUTH_TOKEN` is present and hashes to that identifier (a child that lost the token, or runs another login, records nothing), the path is absolute with a `claude-NAME.usage.json` name in an owned 0700 directory, and the numbers are valid; it writes when the windows change or the record is older than five minutes, never on Windows, and its printed status line is unchanged. + +Privacy: emails and labels appear in completion previews, screen shares and recordings, and the output of `--account-info` lands in any agent transcript that runs it. No token is ever printed or stored there. + ## Completion, installation, and concurrency -`install.sh` links the zsh completions into `PREFIX/share/zsh/site-functions`, which must be on `fpath` before `compinit` runs (with oh-my-zsh, before it is sourced; the installer prints the line), and the Bash completions into the bash-completion user directory, `${BASH_COMPLETION_USER_DIR:-${XDG_DATA_HOME:-~/.local/share}/bash-completion}/completions`, where bash-completion autoloads them. For the first argument both run `codex-as --list-accounts` or `claude-as --list-accounts`, so the launchers must be on `PATH`; the listing enumerates only safe filenames and metadata, without reading credentials, running the native CLI, or using the network, and an invalid configuration offers nothing. Corrupt contents are detected at startup. Later arguments first complete the supported scope, then delegate to existing native completion when available. +`install.sh` links the zsh completions into `PREFIX/share/zsh/site-functions`, which must be on `fpath` before `compinit` runs (with oh-my-zsh, before it is sourced; the installer prints the line), and the Bash completions into the bash-completion user directory, `${BASH_COMPLETION_USER_DIR:-${XDG_DATA_HOME:-~/.local/share}/bash-completion}/completions`, where bash-completion autoloads them. For the first argument Bash runs `codex-as --list-accounts` or `claude-as --list-accounts` and zsh `--account-labels`, which adds each account's email or label as its description, so the launchers must be on `PATH`; the listing enumerates only safe filenames and metadata, without reading credentials, running the native CLI, or using the network, and an invalid configuration offers nothing. Corrupt contents are detected at startup. Later arguments first complete the supported scope, then delegate to existing native completion when available. fzf-tab, when present, uses the same candidates; normal Tab works without fzf. New terminals load the new completions; nothing appends source or PATH lines to rc files. Installation only creates links and triggers no real initialization, login, or model requests. @@ -72,6 +103,7 @@ The launcher's own step/help headings are cyan, confirmations/warnings yellow, s tests/codex-as.sh tests/account-enrollment.sh tests/config.sh +tests/account-info.sh tests/standalone-e2e.sh pre-commit run --all-files ``` diff --git a/docs/codex-as.md b/docs/codex-as.md index 2a4bdf8..8482a32 100644 --- a/docs/codex-as.md +++ b/docs/codex-as.md @@ -10,7 +10,7 @@ codex-as ayting exec --json "Check the tests" Existing accounts launch directly. Missing accounts require confirmation, followed by native device login in an empty temporary `CODEX_HOME`; after saving complete JSON credentials, the original command continues. No sourcing or old /tmp initialization examples are needed. -Apart from `--help`, the launcher has two options of its own, each accepted only as the single argument and neither starting Codex: `codex-as --list-accounts` prints the account names (what completion offers), and `codex-as --show-config` prints the configuration file in use, the `codex` executable, `` and ``. +Apart from `--help`, the launcher has its own queries, none starting Codex or opening a credential: `codex-as --list-accounts` prints the account names (what Bash completion offers), `codex-as --account-labels` prints them with their last-seen emails (the zsh descriptions), `codex-as --account-info NAME` prints the last-seen email, plan and usage of one account, and `codex-as --show-config` prints the configuration file in use, the `codex` executable, `` and ``. Each but `--account-info NAME` is accepted only as the single argument. ## Daemon takeover diff --git a/docs/configuration.md b/docs/configuration.md index 5177aba..355e479 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,6 +34,12 @@ must be set to an absolute path in every case. Neither the launchers nor `install.sh` ever create the file. +The account metadata cache (see the account launchers documentation) is not +configured here: it lives in `$XDG_STATE_HOME/coding-agent-kit/accounts/` when +`XDG_STATE_HOME` is an absolute path, else in +`$HOME/.local/state/coding-agent-kit/accounts/`. A `state_dir` key is an +unknown key and only warns. + ## Format and keys The keys are top-level strings, all optional: @@ -88,6 +94,10 @@ Rules for every key: - They must differ, and neither may lie inside the other. This check is lexical too, so it does not see two spellings of one directory through a symlink, or through case on a case-insensitive file system. +- `secrets_dir` must also differ from the account metadata directory below + (`coding-agent-kit/accounts` in the state directory), whose files carry the + Codex credentials' names; above or below it is fine. The launchers also + never read or write that directory when it is `secrets_dir` through a link. - The parent of each must be a directory you own that neither group nor others can write: `codex-as` checks both parents, `claude-as` the parent of `secrets_dir`. With the defaults both parents are `$HOME`, checked as @@ -211,6 +221,7 @@ TOOL: FILE: KEY: must not contain a .. component. TOOL: FILE: secrets_dir: must differ from codex_home. TOOL: FILE: secrets_dir: must not be inside codex_home. TOOL: FILE: codex_home: must not be inside secrets_dir. +TOOL: FILE: secrets_dir: must differ from the account metadata directory DIR. ``` A syntax error at the very end reads `invalid TOML at end of document.`, and @@ -244,11 +255,13 @@ regular file, stops it with: TOOL: PARENT does not exist; create it, owned by you and not group or other writable. ``` -## --show-config and --list-accounts +## --show-config, --list-accounts and the account queries -Both are the launcher's own queries, accepted only as the single argument. -They read metadata only: no environment or argument checks, no directory is -created, no native CLI starts and nothing touches the network. +These are the launcher's own queries: `--show-config`, `--list-accounts` and +`--account-labels` accepted only as the single argument, `--account-info NAME` +only with one account name. They read metadata only: no environment or +argument checks, no directory is created, no native CLI starts, no credential +is opened and nothing touches the network. `TOOL --show-config` prints the effective values and exits 0: @@ -280,6 +293,14 @@ account argument. An invalid configuration or an unsafe `secrets_dir` prints nothing on stdout and an error on stderr, and exits 1, so completion offers no names. +`TOOL --account-labels` prints the same accounts as `NAMELABEL` lines (the +email for Codex, the label for Claude), or a bare `NAME` when there is none; +the zsh completions run it instead of `--list-accounts` and show the label as +the description. `TOOL --account-info NAME` prints the last-seen details of one +account, or exits 1 with `TOOL: no account NAME.` when the listing has no such +account. The account launchers documentation describes both outputs and where +the data comes from. + ## Moving codex_home and secrets_dir Choose both before the first `codex-as NAME`. Each account view under