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.
- package/README.md +6 -477
- package/package.json +14 -48
- package/shim.js +107 -0
- package/LICENSE +0 -661
- package/LICENSING.md +0 -82
- package/THIRD_PARTY_NOTICES.md +0 -395
- package/bin/agent-dag.js +0 -626
- package/bin/deck.js +0 -1805
- package/dist/web/assets/index-3FWd7g_W.css +0 -1
- package/dist/web/assets/index-BOwtoP02.js +0 -266
- package/dist/web/index.html +0 -49
- package/hook/hook.js +0 -542
- package/release-notes.json +0 -392
- package/src/server/activity.mjs +0 -52
- package/src/server/agent-activity.mjs +0 -522
- package/src/server/args.mjs +0 -183
- package/src/server/auto-update.mjs +0 -79
- package/src/server/block-notify.mjs +0 -173
- package/src/server/boot-deadline.mjs +0 -127
- package/src/server/brand.mjs +0 -16
- package/src/server/browser-history.mjs +0 -497
- package/src/server/browser-presence.mjs +0 -211
- package/src/server/browser-profiles.mjs +0 -279
- package/src/server/browser-react.mjs +0 -284
- package/src/server/browser-watch-store.mjs +0 -350
- package/src/server/browser-watch.mjs +0 -905
- package/src/server/ccusage.mjs +0 -1168
- package/src/server/claude-accounts.mjs +0 -951
- package/src/server/claude-dir.mjs +0 -213
- package/src/server/codex-auth.mjs +0 -388
- package/src/server/codex-dir.mjs +0 -171
- package/src/server/codex-quota.mjs +0 -449
- package/src/server/codex-usage.mjs +0 -512
- package/src/server/cswap-admin.mjs +0 -1562
- package/src/server/cswap-auto.mjs +0 -658
- package/src/server/cswap-install.mjs +0 -641
- package/src/server/deck-home.mjs +0 -243
- package/src/server/deck-prefs.mjs +0 -301
- package/src/server/deck-probe.mjs +0 -111
- package/src/server/detach.mjs +0 -244
- package/src/server/exec.mjs +0 -996
- package/src/server/global-install.mjs +0 -67
- package/src/server/hwmonitor.mjs +0 -56
- package/src/server/index.mjs +0 -6043
- package/src/server/installer.mjs +0 -912
- package/src/server/invoked-as.mjs +0 -144
- package/src/server/lan-about.mjs +0 -119
- package/src/server/lan-engine.mjs +0 -952
- package/src/server/lan-reach.mjs +0 -256
- package/src/server/lan-socket.mjs +0 -682
- package/src/server/lan-sync.mjs +0 -941
- package/src/server/lhm-parse.mjs +0 -91
- package/src/server/log-tail.mjs +0 -139
- package/src/server/log-writer.mjs +0 -322
- package/src/server/login-service.mjs +0 -473
- package/src/server/macmon.mjs +0 -310
- package/src/server/npx.mjs +0 -264
- package/src/server/open-url.mjs +0 -242
- package/src/server/presence.mjs +0 -40
- package/src/server/quota.mjs +0 -792
- package/src/server/relay-guard.mjs +0 -507
- package/src/server/reset-label.mjs +0 -78
- package/src/server/retire-sound-hook.mjs +0 -349
- package/src/server/running-deck.mjs +0 -234
- package/src/server/self-update.mjs +0 -1380
- package/src/server/stop-deck.mjs +0 -171
- package/src/server/supervisor.mjs +0 -392
- package/src/server/system-metrics.mjs +0 -1825
- package/src/server/term.mjs +0 -686
- 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
|
-
}
|