agent-dag 3.22.0 → 3.22.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +6 -477
  2. package/package.json +14 -48
  3. package/shim.js +107 -0
  4. package/LICENSE +0 -661
  5. package/LICENSING.md +0 -82
  6. package/THIRD_PARTY_NOTICES.md +0 -395
  7. package/bin/agent-dag.js +0 -626
  8. package/bin/deck.js +0 -1805
  9. package/dist/web/assets/index-3FWd7g_W.css +0 -1
  10. package/dist/web/assets/index-BOwtoP02.js +0 -266
  11. package/dist/web/index.html +0 -49
  12. package/hook/hook.js +0 -542
  13. package/release-notes.json +0 -392
  14. package/src/server/activity.mjs +0 -52
  15. package/src/server/agent-activity.mjs +0 -522
  16. package/src/server/args.mjs +0 -183
  17. package/src/server/auto-update.mjs +0 -79
  18. package/src/server/block-notify.mjs +0 -173
  19. package/src/server/boot-deadline.mjs +0 -127
  20. package/src/server/brand.mjs +0 -16
  21. package/src/server/browser-history.mjs +0 -497
  22. package/src/server/browser-presence.mjs +0 -211
  23. package/src/server/browser-profiles.mjs +0 -279
  24. package/src/server/browser-react.mjs +0 -284
  25. package/src/server/browser-watch-store.mjs +0 -350
  26. package/src/server/browser-watch.mjs +0 -905
  27. package/src/server/ccusage.mjs +0 -1168
  28. package/src/server/claude-accounts.mjs +0 -951
  29. package/src/server/claude-dir.mjs +0 -213
  30. package/src/server/codex-auth.mjs +0 -388
  31. package/src/server/codex-dir.mjs +0 -171
  32. package/src/server/codex-quota.mjs +0 -449
  33. package/src/server/codex-usage.mjs +0 -512
  34. package/src/server/cswap-admin.mjs +0 -1562
  35. package/src/server/cswap-auto.mjs +0 -658
  36. package/src/server/cswap-install.mjs +0 -641
  37. package/src/server/deck-home.mjs +0 -243
  38. package/src/server/deck-prefs.mjs +0 -301
  39. package/src/server/deck-probe.mjs +0 -111
  40. package/src/server/detach.mjs +0 -244
  41. package/src/server/exec.mjs +0 -996
  42. package/src/server/global-install.mjs +0 -67
  43. package/src/server/hwmonitor.mjs +0 -56
  44. package/src/server/index.mjs +0 -6043
  45. package/src/server/installer.mjs +0 -912
  46. package/src/server/invoked-as.mjs +0 -144
  47. package/src/server/lan-about.mjs +0 -119
  48. package/src/server/lan-engine.mjs +0 -952
  49. package/src/server/lan-reach.mjs +0 -256
  50. package/src/server/lan-socket.mjs +0 -682
  51. package/src/server/lan-sync.mjs +0 -941
  52. package/src/server/lhm-parse.mjs +0 -91
  53. package/src/server/log-tail.mjs +0 -139
  54. package/src/server/log-writer.mjs +0 -322
  55. package/src/server/login-service.mjs +0 -473
  56. package/src/server/macmon.mjs +0 -310
  57. package/src/server/npx.mjs +0 -264
  58. package/src/server/open-url.mjs +0 -242
  59. package/src/server/presence.mjs +0 -40
  60. package/src/server/quota.mjs +0 -792
  61. package/src/server/relay-guard.mjs +0 -507
  62. package/src/server/reset-label.mjs +0 -78
  63. package/src/server/retire-sound-hook.mjs +0 -349
  64. package/src/server/running-deck.mjs +0 -234
  65. package/src/server/self-update.mjs +0 -1380
  66. package/src/server/stop-deck.mjs +0 -171
  67. package/src/server/supervisor.mjs +0 -392
  68. package/src/server/system-metrics.mjs +0 -1825
  69. package/src/server/term.mjs +0 -686
  70. package/src/server/uv-bootstrap.mjs +0 -337
@@ -1,213 +0,0 @@
1
- // Where Claude Code lives on this machine: its configuration directory, its
2
- // binary, and the question of whether it is here at all.
3
- //
4
- // CLAUDE_CONFIG_DIR relocates that directory wholesale; it is a replacement for
5
- // ~/.claude, not an overlay, so on a machine where it is set there is nothing in
6
- // ~/.claude for Claude Code to read. Writing hook entries there is silent
7
- // failure of the worst kind: the install reports success, no hook ever fires,
8
- // and the deck stays empty with no error anywhere to explain it.
9
- //
10
- // Every module on the Claude side of the install resolves the directory through
11
- // here, so the deck can never register its hooks in one file while Claude Code
12
- // reads another. hook/hook.js repeats the rule inline rather than importing it:
13
- // it is copied out of the package and run standalone by the host CLI, so it has
14
- // no way back to this module, but it has to resolve the same directory.
15
- //
16
- // The binary list and the presence test moved in beside it for the same reason.
17
- // The list used to sit in quota.mjs under the name quotaClaudeCandidates, where
18
- // it read as a detail of one poller; it is nothing of the kind. It is the answer
19
- // to "where does the `claude` command live", and the deck now asks that question
20
- // at boot, before any quota exists, to decide whether this is a Claude machine
21
- // at all. One list, one caller-visible name, no second spelling to drift.
22
- import { existsSync } from "node:fs";
23
- import { homedir } from "node:os";
24
- import { join, resolve, posix as posixPath, win32 as winPath } from "node:path";
25
-
26
- /**
27
- * Absolute path of the Claude Code config dir: $CLAUDE_CONFIG_DIR or ~/.claude.
28
- *
29
- * The environment and the home directory are parameters purely so callers that
30
- * are already working from an injected environment can stay consistent with it;
31
- * every caller in the deck passes nothing and gets the real machine's answer.
32
- */
33
- export function claudeConfigDir(env = process.env, home = homedir()) {
34
- const override = env.CLAUDE_CONFIG_DIR?.trim();
35
- return override ? resolve(override) : join(home, ".claude");
36
- }
37
-
38
- /**
39
- * Every place the `claude` CLI is known to live, in the order to try them.
40
- *
41
- * Pure, and the platform, environment and home directory are parameters, so the
42
- * Windows list can be checked from a Mac — which is the only way this list stays
43
- * right, since it exists entirely for machines the author is not sitting at.
44
- */
45
- export function claudeCliCandidates(platform = process.platform, env = process.env, home = homedir()) {
46
- // The path flavour follows the PLATFORM ARGUMENT, not the host: node's `join`
47
- // would emit forward slashes when the Windows list is built on a Mac.
48
- const { join } = platform === "win32" ? winPath : posixPath;
49
- if (platform !== "win32") {
50
- return [
51
- "claude",
52
- join(home, ".local", "bin", "claude"),
53
- "/usr/local/bin/claude",
54
- "/opt/homebrew/bin/claude",
55
- ];
56
- }
57
- return [
58
- // The native installer, which ships a bare claude.exe and NO .cmd shim. It
59
- // was the one install this branch could not reach: the npm path below does
60
- // not exist on such a machine, and the bare-name fallback used to be spelled
61
- // `claude.cmd`, which cmd.exe cannot resolve to an .exe — PATHEXT supplies a
62
- // missing extension, it never substitutes one that is already there.
63
- join(home, ".local", "bin", "claude.exe"),
64
- // `npm i -g @anthropic-ai/claude-code`. npm's global prefix is %APPDATA%\npm
65
- // — Roaming rather than Local, deliberately, since it follows the user
66
- // between machines — and APPDATA is read from the environment because a
67
- // roaming profile puts it on a network share, not under the home directory.
68
- join(env.APPDATA || join(home, "AppData", "Roaming"), "npm", "claude.cmd"),
69
- // Last resort: the bare name, which cmd.exe resolves through PATH + PATHEXT
70
- // and so finds claude.exe and claude.cmd alike.
71
- "claude",
72
- ];
73
- }
74
-
75
- /**
76
- * The names Claude Code itself writes inside its config dir, and the deck never
77
- * does. Presence of any one of them is proof Claude Code has actually run here.
78
- *
79
- * The exclusions are the entire point. `settings.json` and `agent-dag/` are OURS
80
- * — installHooks writes the first and keepDiscovery creates the second — so a
81
- * test that accepted them would answer "Claude is installed" on any machine this
82
- * deck had ever been started on, including the Codex-only one this whole check
83
- * exists to recognise. That is not a hypothetical: every Codex-only machine that
84
- * ran an earlier ccdeck already has both sitting in a ~/.claude the deck created
85
- * for itself.
86
- *
87
- * `.credentials.json` is in the list but cannot carry it alone: macOS keeps the
88
- * OAuth token in the Keychain and writes no such file, so on the platform where
89
- * a credentials test is most tempting it is always negative (see #360). It earns
90
- * its place as one more way to say yes on Linux and Windows, never as the test.
91
- */
92
- const CLAUDE_USE_MARKERS = [
93
- "projects", // one directory per cwd, written from the first session on
94
- "history.jsonl", // the prompt history, appended to on the first prompt
95
- "statsig", // written at first launch, before any session completes
96
- "todos",
97
- "shell-snapshots",
98
- "ide",
99
- "plugins",
100
- ".credentials.json", // Linux + Windows OAuth store; absent on macOS by design
101
- ".claude.json", // Claude Code's own state file, when the config dir moved
102
- ];
103
-
104
- /**
105
- * Whether the `claude` binary is on this machine — on PATH, or in one of the
106
- * places its installers are known to put it.
107
- *
108
- * `run()` in exec.mjs can afford to hand a bare name to spawn and let the OS
109
- * resolve it. This cannot: it has to answer without launching anything. Boot is
110
- * the wrong place to spend a `claude --version` child process (quota.mjs
111
- * measured those at ~3.0s each), and a spawn probe would also have to decide
112
- * what a non-zero exit means, which is a question with no good answer. So the
113
- * PATH walk is done by hand, with PATHEXT applied on Windows exactly as cmd.exe
114
- * would — a `claude.cmd` shim from npm and a bare `claude.exe` from the native
115
- * installer both have to count, and neither is spelled `claude`.
116
- *
117
- * Not exported (#383). It is the first half of `hasClaudeInstalled` below, and
118
- * that function takes the same four injected dependencies — platform, env, home
119
- * and `exists` — so every branch of this walk is already driven from a test with
120
- * no host state reaching it, including the Windows PATHEXT branch exercised from
121
- * a Mac. Exporting it would offer a second, narrower answer to "is Claude Code
122
- * here", and the point of #402 was that there is one.
123
- */
124
- function claudeCliOnDisk({
125
- platform = process.platform,
126
- env = process.env,
127
- home = homedir(),
128
- exists = existsSync,
129
- } = {}) {
130
- const path = platform === "win32" ? winPath : posixPath;
131
- const bare = [];
132
- for (const candidate of claudeCliCandidates(platform, env, home)) {
133
- // A full path is worth a single stat; a bare name means "ask PATH", which
134
- // is the walk below. Same split quotaClaudeBin makes, for the same reason.
135
- if (candidate.includes(path.sep)) { if (exists(candidate)) return true; }
136
- else bare.push(candidate);
137
- }
138
- if (bare.length === 0) return false;
139
-
140
- // process.env is case-insensitive on Windows, but an injected plain object in
141
- // a test is not, and %Path% is how the variable is actually spelled there.
142
- const rawPath = env.PATH ?? env.Path ?? env.path ?? "";
143
- // The empty extension stays in the list: a Git-Bash or WSL-style shim on
144
- // Windows can be an extensionless file, and on POSIX it is the only entry.
145
- const exts = platform === "win32"
146
- ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").map(e => e.trim()).filter(Boolean)]
147
- : [""];
148
-
149
- for (const entry of String(rawPath).split(path.delimiter)) {
150
- // Windows PATH entries are routinely quoted, and an empty entry means the
151
- // current directory — which is not a place to go looking for a CLI.
152
- const dir = entry.trim().replace(/^"+|"+$/g, "");
153
- if (dir === "") continue;
154
- for (const name of bare) {
155
- for (const ext of exts) if (exists(path.join(dir, name + ext))) return true;
156
- }
157
- }
158
- return false;
159
- }
160
-
161
- /**
162
- * Whether this machine has Claude Code at all — the mirror of hasCodexInstalled().
163
- *
164
- * README offers "Claude Code CLI or OpenAI Codex CLI (or both)", and until now
165
- * nothing anywhere asked which. A Codex-only machine got a Python
166
- * account-switcher installed for a CLI it does not have, an accounts panel open
167
- * on first run, and a banner telling it to sign into that CLI (#402).
168
- *
169
- * WHY TWO KINDS OF EVIDENCE, OR'd. Neither half is sufficient on its own:
170
- *
171
- * - The binary alone misses the user whose `claude` lives somewhere no list
172
- * knows (nvm, mise, volta, a corporate wrapper) and whose deck was launched
173
- * from a desktop shortcut with a PATH that never sourced their shell rc.
174
- * That user has run Claude Code for months; the config dir proves it.
175
- * - The config dir alone misses the machine where Claude Code is installed and
176
- * has never been launched, which is exactly the moment somebody installs
177
- * both CLIs and starts the deck first. It also cannot be the test on its own
178
- * for the opposite reason: THE DECK CREATES THAT DIRECTORY ITSELF. Hence
179
- * CLAUDE_USE_MARKERS above, which is a list of things only Claude Code puts
180
- * there.
181
- *
182
- * WHY NOT CREDENTIALS. A credentials file is not a presence test on macOS at
183
- * all — the token is in the Keychain and no file exists (#360) — so a machine
184
- * with Claude Code, signed in and in daily use, would read as Codex-only.
185
- *
186
- * WHY NOT "a Claude session was seen". It is the most direct evidence there is,
187
- * and it arrives far too late: the decision this answers is made at boot, before
188
- * the server is listening, and a machine whose first session has not started yet
189
- * would install nothing and show no panel until a restart.
190
- *
191
- * The bias is deliberate and one-way. A false yes leaves a Claude-only surface
192
- * on a Codex machine — today's bug, visible, and the user can pass --no-claude.
193
- * A false no takes the hooks away from somebody who has Claude Code, which is a
194
- * deck that stays empty forever. So every check here is generous, the banner
195
- * says out loud which way it went, and --claude overrides it.
196
- */
197
- export function hasClaudeInstalled({
198
- platform = process.platform,
199
- env = process.env,
200
- home = homedir(),
201
- configDir = claudeConfigDir(env, home),
202
- exists = existsSync,
203
- } = {}) {
204
- if (claudeCliOnDisk({ platform, env, home, exists })) return true;
205
- const path = platform === "win32" ? winPath : posixPath;
206
- for (const marker of CLAUDE_USE_MARKERS) {
207
- if (exists(path.join(configDir, marker))) return true;
208
- }
209
- // ~/.claude.json is Claude Code's own state file on every install that never
210
- // moved the config dir, and it sits BESIDE the home directory rather than
211
- // inside ~/.claude — so the loop above cannot reach it there.
212
- return exists(path.join(home, ".claude.json"));
213
- }
@@ -1,388 +0,0 @@
1
- // Codex (ChatGPT) OAuth credentials: read, refresh, persist.
2
- //
3
- // Ports the flow the Codex CLI itself uses (openai/codex, crate `codex-login`)
4
- // so agents-deck keeps a live token instead of going dark the moment the one
5
- // written by `codex login` rotates server-side.
6
- //
7
- // The important subtlety: OpenAI ROTATES the refresh token, single-use. A
8
- // refresh whose result does not reach disk burns the credential outright —
9
- // the next attempt fails with `refresh_token_reused` and the user has to run
10
- // `codex login` again. Everything defensive in this file follows from that:
11
- // refreshes are serialized, the token is re-read from disk inside the lock
12
- // immediately before it is spent, a response that does not clearly carry a
13
- // new access token is never treated as success, and nothing here throws —
14
- // a rejected promise from a background poll would take the server down.
15
- import { readFile, chmod, unlink } from "node:fs/promises";
16
- import { join } from "node:path";
17
- import { CODEX_HOME } from "./codex-dir.mjs";
18
- import { createTemp, renameWithRetry, resolveWriteTarget } from "./installer.mjs";
19
- import { PRODUCT } from "./brand.mjs";
20
-
21
- // This file used to resolve CODEX_HOME itself, as `process.env.CODEX_HOME ??
22
- // join(homedir(), ".codex")`. `??` falls back on null and undefined only, so an
23
- // empty CODEX_HOME — what `export CODEX_HOME=$SOME_UNSET_VAR` leaves in a
24
- // profile — survived it, and join("", "auth.json") is the CWD-relative
25
- // "auth.json". Of all five readers this was the expensive one to get wrong: the
26
- // rotated refresh token below is single-use, so a write that lands in whatever
27
- // directory the deck was started from does not lose a read, it burns the
28
- // credential and costs the user a `codex login`. codex-dir.mjs owns the rule now
29
- // and treats an empty value as "not set" (#375).
30
- const AUTH_PATH = join(CODEX_HOME, "auth.json");
31
-
32
- // Same client id + endpoint the Codex CLI uses (codex-rs/login/src/auth/manager.rs).
33
- const CLIENT_ID = process.env.CODEX_APP_SERVER_LOGIN_CLIENT_ID ?? "app_EMoamEEZ73f0CkXaXp7hrann";
34
- const DEFAULT_REFRESH_URL = "https://auth.openai.com/oauth/token";
35
-
36
- // Registrable domains the deck is willing to hand an OpenAI credential to.
37
- // Deliberately a suffix list rather than a set of exact hosts: OpenAI moves
38
- // endpoints between subdomains, and FedRAMP tenants live on their own, so
39
- // pinning the four hosts in use today would break a working login on a change
40
- // that is none of our business. The suffix is the part that is.
41
- const CREDENTIAL_HOSTS = ["openai.com", "chatgpt.com"];
42
-
43
- /**
44
- * May a live OpenAI credential be sent to this URL?
45
- *
46
- * Both destinations in the Codex half of the deck are configurable by something
47
- * other than the deck — `chatgpt_base_url` in ~/.codex/config.toml, which the
48
- * access token is sent to, and $CODEX_REFRESH_TOKEN_URL_OVERRIDE, which the
49
- * SINGLE-USE refresh token is POSTed to — and neither was checked before the
50
- * credential went out. The Codex CLI honours the same two knobs; the difference
51
- * is that it is the program those credentials belong to, and the deck is a
52
- * bystander that reads them.
53
- *
54
- * Two rules. `https:`, so a base URL of `http://…` cannot put a bearer token on
55
- * the wire in cleartext. And a host at or under one of the domains above, so a
56
- * config file the deck does not own cannot name the recipient.
57
- *
58
- * `URL` does the parsing rather than a regex, which is what makes
59
- * `https://chatgpt.com@evil.example/` (userinfo, not a host) and
60
- * `https://chatgpt.com.evil.example/` (a different registrable domain) come out
61
- * as the hosts they really are.
62
- */
63
- export function isCredentialHost(raw) {
64
- let u;
65
- try { u = new URL(String(raw ?? "")); } catch { return false; }
66
- if (u.protocol !== "https:") return false;
67
- const host = u.hostname.toLowerCase();
68
- return CREDENTIAL_HOSTS.some(d => host === d || host.endsWith(`.${d}`));
69
- }
70
-
71
- // The override is honoured only when it names somewhere the refresh token may
72
- // go. Falling back rather than failing outright keeps a machine with a stale or
73
- // mistyped override working, and the log line is there so the fallback is not
74
- // the silent kind.
75
- function refreshUrl() {
76
- const override = process.env.CODEX_REFRESH_TOKEN_URL_OVERRIDE?.trim();
77
- if (!override) return DEFAULT_REFRESH_URL;
78
- if (isCredentialHost(override)) return override;
79
- console.error(
80
- `${PRODUCT} codex-auth: ignoring CODEX_REFRESH_TOKEN_URL_OVERRIDE — ` +
81
- `a refresh token is only sent to https OpenAI hosts, not ${override}`,
82
- );
83
- return DEFAULT_REFRESH_URL;
84
- }
85
-
86
- // Refresh once the access token is within this much of expiring. Deliberately
87
- // tighter than the CLI's 5 minutes: matching it would wake both processes into
88
- // the same window to race for the same single-use token, and the loser gets a
89
- // `refresh_token_reused` that reads to the user as "your login is broken".
90
- const EXPIRY_SKEW_MS = 90 * 1000;
91
- // Fallback only, for tokens whose `exp` we cannot read.
92
- const MAX_TOKEN_AGE_MS = 8 * 24 * 60 * 60 * 1000;
93
-
94
- // Refresh failures that will never succeed on retry — the credential is gone
95
- // and only `codex login` brings it back.
96
- const PERMANENT_CODES = new Set([
97
- "refresh_token_expired",
98
- "refresh_token_reused",
99
- "refresh_token_invalidated",
100
- "invalid_grant",
101
- ]);
102
-
103
- /**
104
- * Decode a JWT payload. Returns null for anything that isn't a 3-part JWT.
105
- *
106
- * Exported for its test and not for a caller (#383). Its two readers are
107
- * `expiryMs` below — which decides whether the deck spends the single-use
108
- * refresh token, the one mistake in this file that costs the user a `codex
109
- * login` — and `identityFrom`, which reads the plan, the account id and the
110
- * email out of the id_token. Both take the answer from a file the deck did not
111
- * write and cannot validate, so every way this can be handed something that is
112
- * not a JWT is a real input, and neither reader can be driven far enough to
113
- * exercise them: `expiryMs` collapses the whole result to one number and
114
- * `identityFrom` needs a full auth file plus a refresh round-trip to reach.
115
- * See codex-jwt-decode.test.ts.
116
- */
117
- export function decodeJwt(token) {
118
- if (typeof token !== "string") return null;
119
- const parts = token.split(".");
120
- if (parts.length !== 3 || !parts[1]) return null;
121
- try {
122
- const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
123
- return (payload && typeof payload === "object") ? payload : null;
124
- } catch {
125
- return null;
126
- }
127
- }
128
-
129
- /** Access-token expiry in ms, or null when the token carries no readable `exp`. */
130
- function expiryMs(accessToken) {
131
- const exp = decodeJwt(accessToken)?.exp;
132
- return typeof exp === "number" ? exp * 1000 : null;
133
- }
134
-
135
- async function readAuthFile() {
136
- try {
137
- const parsed = JSON.parse(await readFile(AUTH_PATH, "utf8"));
138
- return (parsed && typeof parsed === "object") ? parsed : null;
139
- } catch {
140
- return null;
141
- }
142
- }
143
-
144
- /**
145
- * Write auth.json back atomically: write a sibling tmp file, fsync it, then
146
- * rename over the target. A reader can never observe a half-written file, and
147
- * the fsync means a machine crash cannot leave an empty one behind after we
148
- * have already spent the old refresh token.
149
- *
150
- * Resolves symlinks first — `~/.codex/auth.json` is often a link into a
151
- * dotfiles repo or an encrypted volume, and renaming onto the link would
152
- * replace it with a regular file, quietly detaching the user's setup. That
153
- * resolution is the installer's resolveWriteTarget rather than a bare realpath
154
- * here, because settings.json needed the identical rule (#673) and a rule
155
- * written twice is a rule that drifts: the shared one also follows a DANGLING
156
- * link to the file it names, which a realpath cannot answer at all and which is
157
- * exactly the state a dotfiles repo is in before its first apply.
158
- *
159
- * The temp file comes from the installer's createTemp, which numbers every
160
- * write and creates it with O_EXCL, rather than from a name built out of the
161
- * pid alone. A pid names a process, not a write, so that name was one file
162
- * shared by every write this process makes, and the open that filled it
163
- * truncated whatever it found: a second refresh in flight would fill it from
164
- * offset zero underneath the first, and the loser would rename a splice of the
165
- * two over auth.json — or find its temp file already renamed away and throw
166
- * ENOENT. What is left at that name between runs is a live rotated refresh
167
- * token in cleartext, so the leftover a crashed deck strands there is not inert
168
- * either: a plain create adopts it whole, keeping its permissions, because
169
- * open() applies the mode it is given only when it is the call that creates the
170
- * file. O_EXCL turns a taken name into an error the caller handles instead,
171
- * which is also what makes the 0600 binding from the very first byte rather
172
- * than whatever the file it inherited happened to allow.
173
- *
174
- * The rename is the installer's retrying one because Windows fails it outright
175
- * with EPERM/EBUSY while another process holds auth.json open, and a virus
176
- * scanner, the search indexer or the Codex CLI itself does exactly that for a
177
- * few milliseconds at a time. Everywhere else that costs a re-download; here
178
- * the refresh token has already been spent server-side, so losing that
179
- * millisecond race logs the user out of Codex until they run `codex login`.
180
- *
181
- * Throws on failure; the caller must treat that as "the refresh did not
182
- * happen" rather than swallowing it.
183
- */
184
- async function persistAuth(auth) {
185
- const target = await resolveWriteTarget(AUTH_PATH);
186
- const { tmp, handle } = await createTemp(target, { mode: 0o600 });
187
-
188
- let ok = false;
189
- try {
190
- try {
191
- await handle.writeFile(JSON.stringify(auth, null, 2), "utf8");
192
- await handle.sync();
193
- } finally {
194
- await handle.close();
195
- }
196
- // The umask only ever clears bits off the creation mode, so the token is
197
- // never wider than 0600 — but it can land narrower, and an auth.json at
198
- // 0400 is one the Codex CLI's own writer cannot open the next time it
199
- // rotates. On Windows chmod's only effect is the read-only bit, and a
200
- // read-only target is one no rename can replace. Pin it either way.
201
- await chmod(tmp, 0o600);
202
- await renameWithRetry(tmp, target);
203
- ok = true;
204
- } finally {
205
- // A tmp left behind holds a live rotated refresh token in cleartext.
206
- if (!ok) await unlink(tmp).catch(() => {});
207
- }
208
- }
209
-
210
- /** True when the stored access token is expired, near-expiry, or stale. */
211
- function shouldRefresh(auth) {
212
- const tokens = auth?.tokens;
213
- if (!tokens?.refresh_token) return false;
214
- if (!tokens.access_token) return true;
215
-
216
- const exp = expiryMs(tokens.access_token);
217
- if (exp != null) return exp <= Date.now() + EXPIRY_SKEW_MS;
218
-
219
- // No readable expiry — fall back to how long ago the CLI last refreshed.
220
- const last = auth.last_refresh ? Date.parse(auth.last_refresh) : NaN;
221
- if (isNaN(last)) return false;
222
- return last < Date.now() - MAX_TOKEN_AGE_MS;
223
- }
224
-
225
- /** Pull the failure code out of the several shapes the endpoint returns it in. */
226
- function refreshErrorCode(body) {
227
- const raw = typeof body?.error === "object" ? body?.error?.code
228
- : typeof body?.error === "string" ? body.error
229
- : body?.code;
230
- return typeof raw === "string" ? raw.toLowerCase() : null;
231
- }
232
-
233
- /**
234
- * Spend the refresh token. Never throws — every failure is a return value,
235
- * because callers include a 60s background poll whose rejection would reach
236
- * the HTTP router as an unhandled rejection and kill the process.
237
- */
238
- async function doRefresh(auth) {
239
- let res, body;
240
- try {
241
- // Resolved per call, not once at import: the module is loaded lazily and a
242
- // test (or an embedder) can set the override after load — the same reason
243
- // ccusage.mjs reads AGENTS_DECK_NO_INSTALL per call.
244
- res = await fetch(refreshUrl(), {
245
- method: "POST",
246
- headers: { "Content-Type": "application/json" },
247
- body: JSON.stringify({
248
- client_id: CLIENT_ID,
249
- grant_type: "refresh_token",
250
- refresh_token: auth.tokens.refresh_token,
251
- }),
252
- signal: AbortSignal.timeout(15_000),
253
- });
254
- body = await res.json().catch(() => null);
255
- } catch {
256
- // Network error or timeout: the server may or may not have rotated the
257
- // token. Nothing is written, so the next attempt retries with what we
258
- // have — the only safe move when the outcome is unknown.
259
- return { ok: false, reason: "refresh_failed" };
260
- }
261
-
262
- if (!res.ok) {
263
- const code = refreshErrorCode(body);
264
- const permanent = (code && PERMANENT_CODES.has(code)) || res.status === 401;
265
- return { ok: false, reason: permanent ? "refresh_rejected" : "refresh_failed", code };
266
- }
267
-
268
- // A 2xx whose body did not survive the trip (truncated response, captive
269
- // portal, proxy error page) is NOT success: writing here would persist the
270
- // old, now-consumed token plus a fresh `last_refresh`, reporting a working
271
- // login while guaranteeing the next call fails as `refresh_token_reused`.
272
- if (typeof body?.access_token !== "string" || body.access_token === "") {
273
- return { ok: false, reason: "refresh_failed", code: "no_access_token" };
274
- }
275
-
276
- // Write back only the fields that came in, leaving the rest of auth.json
277
- // (OPENAI_API_KEY, auth_mode, account_id, anything unknown) untouched.
278
- const next = { ...auth, tokens: { ...auth.tokens } };
279
- next.tokens.access_token = body.access_token;
280
- if (body.id_token) next.tokens.id_token = body.id_token;
281
- if (body.refresh_token) next.tokens.refresh_token = body.refresh_token;
282
- next.last_refresh = new Date().toISOString();
283
-
284
- try {
285
- await persistAuth(next);
286
- } catch (err) {
287
- // The rotated token exists server-side but never reached disk. Say so
288
- // plainly — the credential on disk is now dead and only a re-login fixes
289
- // it, so reporting a transient failure would just mislead.
290
- console.error(`${PRODUCT} codex-auth: could not write auth.json:`, err?.message ?? err);
291
- return { ok: false, reason: "refresh_rejected", code: "persist_failed" };
292
- }
293
-
294
- return { ok: true, auth: next };
295
- }
296
-
297
- // Refreshes run strictly one at a time per process. A queue rather than a
298
- // shared promise: callers that arrive during a refresh must be able to make
299
- // their own decision afterwards (see the staleness checks below) instead of
300
- // inheriting a result produced before their token failed.
301
- let _chain = Promise.resolve();
302
- function serialize(fn) {
303
- const run = _chain.then(fn, fn);
304
- _chain = run.then(() => {}, () => {});
305
- return run;
306
- }
307
-
308
- /**
309
- * Refresh under the lock.
310
- *
311
- * `ifStale` — only refresh when the credentials on disk still look expiring.
312
- * `staleAccessToken` — only refresh when disk still holds the token the caller
313
- * saw fail. Both exist for the same reason: auth.json is re-read *inside* the
314
- * lock, so a caller that queued behind another refresh discovers it already
315
- * got what it needed and does not spend a second single-use token.
316
- */
317
- function refreshCredentials({ ifStale = false, staleAccessToken = null } = {}) {
318
- return serialize(async () => {
319
- const auth = await readAuthFile();
320
- if (!auth?.tokens?.refresh_token) return { ok: false, reason: "no_token" };
321
- if (ifStale && !shouldRefresh(auth)) return { ok: true, auth };
322
- if (staleAccessToken && auth.tokens.access_token !== staleAccessToken) {
323
- return { ok: true, auth }; // someone else already rotated past it
324
- }
325
- return doRefresh(auth);
326
- });
327
- }
328
-
329
- function identityFrom(auth, refreshed) {
330
- // Claims live in the id_token, not the access token. account_id is seeded at
331
- // login into tokens.account_id; the id_token claim is the fallback for
332
- // credential files written before that field existed.
333
- const claims = decodeJwt(auth.tokens.id_token) ?? {};
334
- const oai = claims["https://api.openai.com/auth"] ?? {};
335
- return {
336
- ok: true,
337
- accessToken: auth.tokens.access_token,
338
- accountId: auth.tokens.account_id ?? oai.chatgpt_account_id ?? null,
339
- isFedramp: oai.chatgpt_account_is_fedramp === true,
340
- planType: oai.chatgpt_plan_type ?? null,
341
- email: claims.email ?? null,
342
- refreshed,
343
- };
344
- }
345
-
346
- /**
347
- * Current Codex credentials, refreshed if needed.
348
- *
349
- * Returns { ok: true, accessToken, accountId, … } or { ok: false, reason }
350
- * where reason is `no_token` (never logged in) / `refresh_rejected` (re-login
351
- * required) / `refresh_failed` (transient). Never throws.
352
- */
353
- export async function getCodexAuth({ allowRefresh = true } = {}) {
354
- let auth = await readAuthFile();
355
-
356
- // An `OPENAI_API_KEY` login is a platform credential, not a ChatGPT session.
357
- // Flagged rather than rejected so the caller can say so plainly instead of
358
- // sending the key to chatgpt.com and reporting the resulting 401 as a bug.
359
- const apiKey = typeof auth?.OPENAI_API_KEY === "string" ? auth.OPENAI_API_KEY.trim() : "";
360
- if (auth?.auth_mode === "apikey" || (apiKey && !auth?.tokens?.access_token)) {
361
- return { ok: true, apiKeyMode: true, accessToken: null, accountId: null };
362
- }
363
-
364
- if (!auth?.tokens?.access_token) return { ok: false, reason: "no_token" };
365
-
366
- let refreshed = false;
367
- if (allowRefresh && shouldRefresh(auth)) {
368
- const r = await refreshCredentials({ ifStale: true });
369
- if (!r.ok) return r;
370
- refreshed = r.auth.tokens.access_token !== auth.tokens.access_token;
371
- auth = r.auth;
372
- }
373
-
374
- return identityFrom(auth, refreshed);
375
- }
376
-
377
- /**
378
- * Refresh because the backend rejected a token that looked valid locally —
379
- * OpenAI revokes server-side, so the JWT's own `exp` is not the last word.
380
- *
381
- * Pass the access token that was rejected: if disk has already moved past it
382
- * (a concurrent refresh, or the Codex CLI), this returns the newer credentials
383
- * without spending another single-use refresh token.
384
- */
385
- export async function forceCodexRefresh(rejectedAccessToken = null) {
386
- const r = await refreshCredentials({ staleAccessToken: rejectedAccessToken });
387
- return r.ok ? identityFrom(r.auth, true) : r;
388
- }