@scrylog/cli 0.2.0 → 0.3.0
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 +46 -7
- package/package.json +13 -10
- package/src/api/docs/sessions.ts +3 -2
- package/src/api/docs/system.ts +3 -0
- package/src/api/docs/usage.ts +2 -2
- package/src/api/routes/sessions.ts +6 -2
- package/src/api/routes/system.ts +4 -1
- package/src/api/routes/webhooks/claude.ts +8 -0
- package/src/api/routes/webhooks/opencode.ts +1 -0
- package/src/api/schemas/common.ts +8 -0
- package/src/api/schemas/sessions.ts +15 -1
- package/src/api/schemas/usage.ts +1 -0
- package/src/cli/commands.ts +85 -3
- package/src/cli/index.ts +18 -0
- package/src/cli/program.ts +175 -0
- package/src/daemon/collectors/claude/collector.ts +1 -1
- package/src/daemon/collectors/claude/discovery.ts +2 -23
- package/src/daemon/collectors/codex/collector.ts +3 -3
- package/src/daemon/collectors/git.ts +60 -2
- package/src/daemon/collectors/herdr/collector.ts +128 -0
- package/src/daemon/collectors/herdr/index.ts +2 -0
- package/src/daemon/collectors/herdr/normalization.ts +111 -0
- package/src/daemon/collectors/opencode-db.ts +17 -5
- package/src/daemon/collectors/tmux/collector.ts +21 -1
- package/src/daemon/collectors/tmux/enrichment.ts +1 -0
- package/src/daemon/collectors/usage.ts +115 -49
- package/src/daemon/index.ts +2 -0
- package/src/daemon/store.ts +44 -2
- package/src/postinstall.cjs +11 -0
- package/src/shared/config.ts +33 -0
- package/src/shared/session-exclusions.ts +5 -0
- package/src/shared/session-view.ts +67 -6
- package/src/shared/terminal-capabilities.ts +130 -0
- package/src/shared/types.ts +33 -2
- package/src/shared/version.ts +44 -0
- package/src/tui/App.tsx +240 -0
- package/src/tui/assets/providers/SOURCES.md +21 -0
- package/src/tui/assets/providers/claude-terminal.png +0 -0
- package/src/tui/assets/providers/claude-terminal.svg +62 -0
- package/src/tui/assets/providers/claude.png +0 -0
- package/src/tui/assets/providers/copilot.png +0 -0
- package/src/tui/assets/providers/copilot.svg +44 -0
- package/src/tui/assets/providers/openai-terminal.png +0 -0
- package/src/tui/assets/providers/openai-terminal.svg +62 -0
- package/src/tui/assets/providers/openai.png +0 -0
- package/src/tui/assets/providers/opencode-terminal.png +0 -0
- package/src/tui/assets/providers/opencode-terminal.svg +62 -0
- package/src/tui/assets/providers/opencode.png +0 -0
- package/src/tui/assets/providers/shell.png +0 -0
- package/src/tui/assets/providers/shell.svg +23 -0
- package/src/tui/assets/providers/unknown.png +0 -0
- package/src/tui/assets/providers/unknown.svg +23 -0
- package/src/tui/client.ts +22 -8
- package/src/tui/components/BindingsModal.tsx +82 -0
- package/src/tui/components/DashboardHeader.tsx +256 -0
- package/src/tui/components/KeyBar.tsx +67 -0
- package/src/tui/components/SessionDetailModal.tsx +169 -0
- package/src/tui/components/SessionsView.tsx +184 -0
- package/src/tui/components/TabBar.tsx +40 -0
- package/src/tui/components/UsageSummaryPane.tsx +272 -0
- package/src/tui/components/UsageView.tsx +242 -0
- package/src/tui/components/tool-icons.ts +24 -0
- package/src/tui/index.tsx +106 -0
- package/src/tui/keybindings.ts +49 -0
- package/src/tui/model.ts +295 -0
- package/src/tui/state.ts +77 -0
- package/src/tui/theme.ts +110 -0
- package/src/usage/README.md +3 -3
- package/src/usage/clients/anthropic-oauth-usage.ts +100 -20
- package/src/usage/clients/anthropic-web-usage.ts +133 -49
- package/src/usage/clients/claude-cli-probe.ts +458 -132
- package/src/usage/clients/codex-app-server.ts +354 -0
- package/src/usage/clients/codex-oauth-usage.ts +143 -16
- package/src/usage/clients/opencode-go-api.ts +100 -0
- package/src/usage/clients/opencode-go-usage.ts +163 -27
- package/src/usage/errors.ts +33 -0
- package/src/usage/local/opencode-go-local.ts +194 -0
- package/src/usage/mappers.ts +24 -15
- package/src/usage/planner.ts +89 -25
- package/src/usage/providers/claude.ts +203 -83
- package/src/usage/providers/codex.ts +92 -11
- package/src/usage/providers/opencode-go.ts +94 -16
- package/src/usage/resolvers/browser-cookies.ts +407 -69
- package/src/usage/resolvers/claude-auth.ts +140 -40
- package/src/usage/resolvers/claude-desktop-auth.ts +15 -5
- package/src/usage/resolvers/claude-web.ts +72 -6
- package/src/usage/resolvers/codex-auth.ts +74 -17
- package/src/usage/resolvers/keychain-coordinator.ts +397 -0
- package/src/usage/resolvers/opencode-go-web.ts +47 -6
- package/src/usage/types.ts +37 -2
- package/src/usage/web-session-cache.ts +37 -0
- package/src/neo-blessed.d.ts +0 -4
- package/src/tui/app.ts +0 -920
- package/src/tui/index.ts +0 -217
|
@@ -1,13 +1,37 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
3
2
|
import { homedir } from "node:os";
|
|
4
3
|
import { join } from "node:path";
|
|
5
4
|
import type { TokenResolver } from "../resolver";
|
|
6
5
|
import type { ClaudeOAuthCredentials } from "../types";
|
|
7
6
|
import { IS_MAC } from "../../shared/platform";
|
|
7
|
+
import { keychainCoordinator, CLAUDE_SAFE_STORAGE_ITEMS } from "./keychain-coordinator";
|
|
8
8
|
import { ClaudeDesktopAuthResolver } from "./claude-desktop-auth";
|
|
9
9
|
|
|
10
|
+
export const CLAUDE_KEYCHAIN_TOKEN_SERVICES = ["claude-api-oauth", "Claude Code-credentials"] as const;
|
|
11
|
+
|
|
12
|
+
const DEFAULT_STALE_RESCAN_INTERVAL_MS = 10 * 60 * 1_000;
|
|
13
|
+
|
|
14
|
+
export interface ClaudeAuthResolverOptions {
|
|
15
|
+
candidatesSource?: () => Promise<ClaudeOAuthCredentials[]>;
|
|
16
|
+
staleRescanIntervalMs?: number;
|
|
17
|
+
now?: () => number;
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
export class ClaudeAuthResolver implements TokenResolver<ClaudeOAuthCredentials> {
|
|
21
|
+
private cached?: ClaudeOAuthCredentials;
|
|
22
|
+
private nextScanAt = 0;
|
|
23
|
+
private readonly staleRescanIntervalMs: number;
|
|
24
|
+
private readonly now: () => number;
|
|
25
|
+
/** Last blocking keychain description seen while resolving candidates. */
|
|
26
|
+
private lastKeychainFailure: string | null = null;
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
private readonly options: ClaudeAuthResolverOptions = {},
|
|
30
|
+
) {
|
|
31
|
+
this.staleRescanIntervalMs = options.staleRescanIntervalMs ?? DEFAULT_STALE_RESCAN_INTERVAL_MS;
|
|
32
|
+
this.now = options.now ?? Date.now;
|
|
33
|
+
}
|
|
34
|
+
|
|
11
35
|
private readonly debug = (message: string, meta?: Record<string, unknown>) => {
|
|
12
36
|
// Avoid coupling to logger; stderr debug line is acceptable for resolver diagnostics.
|
|
13
37
|
if (process.env.SCRYLOG_DEBUG_AUTH) {
|
|
@@ -16,12 +40,55 @@ export class ClaudeAuthResolver implements TokenResolver<ClaudeOAuthCredentials>
|
|
|
16
40
|
};
|
|
17
41
|
|
|
18
42
|
async resolve(): Promise<ClaudeOAuthCredentials | null> {
|
|
43
|
+
const cached = this.cached;
|
|
44
|
+
if (cached && !cached.isStale) return cached;
|
|
45
|
+
// Debounce full candidate scans (which include keychain reads) while a
|
|
46
|
+
// cached-but-stale credential exists, so polls never scan every cycle.
|
|
47
|
+
if (cached && this.now() < this.nextScanAt) return cached;
|
|
48
|
+
const selected = selectClaudeCredentials(await this.resolveCandidates());
|
|
49
|
+
this.cached = selected ?? undefined;
|
|
50
|
+
this.nextScanAt = this.now() + this.staleRescanIntervalMs;
|
|
51
|
+
this.lastKeychainFailure = selected
|
|
52
|
+
? null
|
|
53
|
+
: keychainCoordinator.describeBlockingFailure([
|
|
54
|
+
{ service: CLAUDE_KEYCHAIN_TOKEN_SERVICES[0]! },
|
|
55
|
+
{ service: CLAUDE_KEYCHAIN_TOKEN_SERVICES[1]! },
|
|
56
|
+
...CLAUDE_SAFE_STORAGE_ITEMS,
|
|
57
|
+
]);
|
|
58
|
+
return this.cached ?? null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Actionable keychain diagnostic for the latest resolution attempt, or null
|
|
63
|
+
* when the last attempt's failure was not keychain-related (missing items,
|
|
64
|
+
* absent caches are not surfaced).
|
|
65
|
+
*/
|
|
66
|
+
describeKeychainFailure(): string | null {
|
|
67
|
+
return this.lastKeychainFailure;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Invalidates the daemon-lifetime credential cache (e.g. after a 401). */
|
|
71
|
+
invalidate(): void {
|
|
72
|
+
this.cached = undefined;
|
|
73
|
+
this.nextScanAt = 0;
|
|
74
|
+
for (const service of CLAUDE_KEYCHAIN_TOKEN_SERVICES) {
|
|
75
|
+
keychainCoordinator.clearMemo(service);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async resolveCandidates(): Promise<ClaudeOAuthCredentials[]> {
|
|
80
|
+
if (this.options.candidatesSource) return this.options.candidatesSource();
|
|
81
|
+
return this.resolveCandidatesUncached();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private async resolveCandidatesUncached(): Promise<ClaudeOAuthCredentials[]> {
|
|
85
|
+
const candidates: ClaudeOAuthCredentials[] = [];
|
|
86
|
+
|
|
19
87
|
// 1. macOS Keychain is the primary store on macOS.
|
|
20
88
|
if (IS_MAC) {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (creds) return creds;
|
|
89
|
+
for (const service of CLAUDE_KEYCHAIN_TOKEN_SERVICES) {
|
|
90
|
+
const creds = await this.tryService(service);
|
|
91
|
+
if (creds) candidates.push(creds);
|
|
25
92
|
}
|
|
26
93
|
} else {
|
|
27
94
|
this.debug("Keychain not available on this platform", { platform: process.platform });
|
|
@@ -33,33 +100,34 @@ export class ClaudeAuthResolver implements TokenResolver<ClaudeOAuthCredentials>
|
|
|
33
100
|
// %APPDATA%/Anthropic/Claude/config.json (Windows). This covers the
|
|
34
101
|
// common case where the user has Claude Desktop but not Claude Code CLI.
|
|
35
102
|
const desktopCreds = await new ClaudeDesktopAuthResolver().resolve();
|
|
36
|
-
if (desktopCreds)
|
|
103
|
+
if (desktopCreds) candidates.push(desktopCreds);
|
|
37
104
|
|
|
38
105
|
// 3. Linux/WSL/Windows: Claude Code stores credentials in ~/.claude/.credentials.json
|
|
39
106
|
const fileCreds = this.tryCredentialsFile();
|
|
40
|
-
if (fileCreds)
|
|
107
|
+
if (fileCreds) candidates.push(fileCreds);
|
|
41
108
|
|
|
42
109
|
// 4. Universal env-var fallback
|
|
43
110
|
const envToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
44
111
|
if (envToken) {
|
|
45
|
-
|
|
112
|
+
candidates.push({
|
|
113
|
+
accessToken: envToken,
|
|
114
|
+
owner: "environment",
|
|
115
|
+
credentialSource: "environment",
|
|
116
|
+
});
|
|
46
117
|
}
|
|
47
118
|
|
|
48
|
-
return
|
|
119
|
+
return candidates;
|
|
49
120
|
}
|
|
50
121
|
|
|
51
122
|
async isAvailable(): Promise<boolean> {
|
|
52
123
|
return (await this.resolve()) !== null;
|
|
53
124
|
}
|
|
54
125
|
|
|
55
|
-
private tryService(service: string): ClaudeOAuthCredentials | null {
|
|
126
|
+
private async tryService(service: string): Promise<ClaudeOAuthCredentials | null> {
|
|
56
127
|
try {
|
|
57
|
-
const output =
|
|
58
|
-
`security find-generic-password -s "${service}" -w`,
|
|
59
|
-
{ encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }
|
|
60
|
-
).trim();
|
|
128
|
+
const output = await keychainCoordinator.read(service);
|
|
61
129
|
if (!output) return null;
|
|
62
|
-
return
|
|
130
|
+
return parseClaudeCredentialsJson(output, "keychain");
|
|
63
131
|
} catch (error) {
|
|
64
132
|
this.debug("Keychain lookup failed", { service, error: error instanceof Error ? error.message : String(error) });
|
|
65
133
|
return null;
|
|
@@ -72,37 +140,69 @@ export class ClaudeAuthResolver implements TokenResolver<ClaudeOAuthCredentials>
|
|
|
72
140
|
|
|
73
141
|
const claudeDir = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
|
|
74
142
|
const path = join(claudeDir, ".credentials.json");
|
|
75
|
-
if (!existsSync(path)) return null;
|
|
76
|
-
|
|
77
143
|
try {
|
|
78
144
|
const raw = readFileSync(path, "utf8");
|
|
79
|
-
return
|
|
145
|
+
return parseClaudeCredentialsJson(raw, "credentials-file");
|
|
80
146
|
} catch (error) {
|
|
81
147
|
this.debug("Failed to read credentials file", { path, error: error instanceof Error ? error.message : String(error) });
|
|
82
148
|
return null;
|
|
83
149
|
}
|
|
84
150
|
}
|
|
85
151
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const EXPIRY_SKEW_MS = 60_000;
|
|
155
|
+
|
|
156
|
+
function parseExpiry(value: unknown): number | undefined | null {
|
|
157
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
158
|
+
const millis = typeof value === "number"
|
|
159
|
+
? value
|
|
160
|
+
: typeof value === "string" && /^\d+$/.test(value.trim())
|
|
161
|
+
? Number(value)
|
|
162
|
+
: typeof value === "string"
|
|
163
|
+
? Date.parse(value)
|
|
164
|
+
: Number.NaN;
|
|
165
|
+
return Number.isFinite(millis) && millis > 0 ? millis : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function parseClaudeCredentialsJson(
|
|
169
|
+
input: string,
|
|
170
|
+
credentialSource: "keychain" | "credentials-file",
|
|
171
|
+
now = Date.now(),
|
|
172
|
+
): ClaudeOAuthCredentials | null {
|
|
173
|
+
let parsed: unknown;
|
|
174
|
+
try {
|
|
175
|
+
parsed = JSON.parse(input);
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
107
178
|
}
|
|
179
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
180
|
+
const outer = parsed as Record<string, unknown>;
|
|
181
|
+
const inner = outer.claudeAiOauth && typeof outer.claudeAiOauth === "object" && !Array.isArray(outer.claudeAiOauth)
|
|
182
|
+
? outer.claudeAiOauth as Record<string, unknown>
|
|
183
|
+
: outer;
|
|
184
|
+
const token = inner.accessToken ?? inner.access_token;
|
|
185
|
+
if (typeof token !== "string" || !token.trim()) return null;
|
|
186
|
+
|
|
187
|
+
const expiryMillis = parseExpiry(inner.expiresAt ?? inner.expires_at);
|
|
188
|
+
if (expiryMillis === null) return null;
|
|
189
|
+
return {
|
|
190
|
+
accessToken: token.trim(),
|
|
191
|
+
refreshToken: typeof (inner.refreshToken ?? inner.refresh_token) === "string"
|
|
192
|
+
? inner.refreshToken as string ?? inner.refresh_token as string
|
|
193
|
+
: undefined,
|
|
194
|
+
expiresAt: expiryMillis === undefined ? undefined : new Date(expiryMillis).toISOString(),
|
|
195
|
+
owner: "claude",
|
|
196
|
+
credentialSource,
|
|
197
|
+
isStale: expiryMillis === undefined || expiryMillis <= now + EXPIRY_SKEW_MS,
|
|
198
|
+
scopes: Array.isArray(inner.scopes) && inner.scopes.every((scope) => typeof scope === "string")
|
|
199
|
+
? inner.scopes
|
|
200
|
+
: undefined,
|
|
201
|
+
rateLimitTier: typeof inner.rateLimitTier === "string" ? inner.rateLimitTier : undefined,
|
|
202
|
+
subscriptionType: typeof inner.subscriptionType === "string" ? inner.subscriptionType : undefined,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function selectClaudeCredentials(candidates: ClaudeOAuthCredentials[]): ClaudeOAuthCredentials | null {
|
|
207
|
+
return candidates.find((candidate) => !candidate.isStale) ?? candidates[0] ?? null;
|
|
108
208
|
}
|
|
@@ -5,6 +5,7 @@ import type { TokenResolver } from "../resolver";
|
|
|
5
5
|
import type { ClaudeOAuthCredentials } from "../types";
|
|
6
6
|
import { IS_MAC, IS_WINDOWS } from "../../shared/platform";
|
|
7
7
|
import { getChromiumSafeStorageKey, decryptSafeStorageBlob } from "./browser-cookies";
|
|
8
|
+
import { keychainCoordinator, CLAUDE_SAFE_STORAGE_ITEMS } from "./keychain-coordinator";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Shape of a single entry in the `oauth:tokenCache` field of Claude Desktop's
|
|
@@ -62,7 +63,7 @@ export class ClaudeDesktopAuthResolver implements TokenResolver<ClaudeOAuthCrede
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
const buf = Buffer.from(blobB64, "base64");
|
|
65
|
-
const key = getChromiumSafeStorageKey("Claude Safe Storage", "Claude");
|
|
66
|
+
const key = await getChromiumSafeStorageKey("Claude Safe Storage", "Claude");
|
|
66
67
|
if (!key) {
|
|
67
68
|
this.debug("Failed to retrieve Claude Safe Storage key from Keychain");
|
|
68
69
|
return null;
|
|
@@ -94,6 +95,11 @@ export class ClaudeDesktopAuthResolver implements TokenResolver<ClaudeOAuthCrede
|
|
|
94
95
|
return (await this.resolve()) !== null;
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
/** Actionable keychain diagnostic for the latest resolution attempt. */
|
|
99
|
+
describeKeychainFailure(): string | null {
|
|
100
|
+
return keychainCoordinator.describeBlockingFailure(CLAUDE_SAFE_STORAGE_ITEMS);
|
|
101
|
+
}
|
|
102
|
+
|
|
97
103
|
private getConfigPath(): string | null {
|
|
98
104
|
const home = homedir();
|
|
99
105
|
if (IS_MAC) {
|
|
@@ -124,17 +130,18 @@ export class ClaudeDesktopAuthResolver implements TokenResolver<ClaudeOAuthCrede
|
|
|
124
130
|
const now = Date.now();
|
|
125
131
|
let bestCreds: ClaudeOAuthCredentials | null = null;
|
|
126
132
|
let bestExpiresAt = -Infinity;
|
|
133
|
+
let bestIsStale = true;
|
|
127
134
|
|
|
128
135
|
for (const [scopeKey, entry] of Object.entries(cache)) {
|
|
129
136
|
if (!entry.token) continue;
|
|
130
|
-
// Discard expired entries
|
|
131
|
-
if (entry.expiresAt !== undefined && entry.expiresAt <= now) continue;
|
|
132
137
|
// Require user:profile scope for the OAuth usage endpoint
|
|
133
138
|
if (!scopeKey.includes("user:profile")) continue;
|
|
134
139
|
|
|
135
|
-
const
|
|
136
|
-
|
|
140
|
+
const isStale = entry.expiresAt === undefined || entry.expiresAt <= now + 60_000;
|
|
141
|
+
const expiresAt = entry.expiresAt ?? -Infinity;
|
|
142
|
+
if ((!isStale && bestIsStale) || (isStale === bestIsStale && expiresAt > bestExpiresAt)) {
|
|
137
143
|
bestExpiresAt = expiresAt;
|
|
144
|
+
bestIsStale = isStale;
|
|
138
145
|
|
|
139
146
|
// Parse space-separated scopes from the composite key.
|
|
140
147
|
// Key format after the audience URL: "...:<audience>:<scopes>"
|
|
@@ -148,6 +155,9 @@ export class ClaudeDesktopAuthResolver implements TokenResolver<ClaudeOAuthCrede
|
|
|
148
155
|
entry.expiresAt !== undefined
|
|
149
156
|
? new Date(entry.expiresAt).toISOString()
|
|
150
157
|
: undefined,
|
|
158
|
+
owner: "claude",
|
|
159
|
+
credentialSource: "claude-desktop",
|
|
160
|
+
isStale,
|
|
151
161
|
scopes,
|
|
152
162
|
subscriptionType: entry.subscriptionType,
|
|
153
163
|
rateLimitTier: entry.rateLimitTier,
|
|
@@ -1,16 +1,82 @@
|
|
|
1
1
|
import type { TokenResolver } from "../resolver";
|
|
2
2
|
import type { ClaudeWebSession } from "../types";
|
|
3
|
-
import {
|
|
3
|
+
import { usageWebSessionCache } from "../web-session-cache";
|
|
4
|
+
import { BrowserCookieSourceCursor, buildCookieHeader, hasBrowserCookieSources } from "./browser-cookies";
|
|
5
|
+
import { keychainCoordinator, CLAUDE_SAFE_STORAGE_ITEMS } from "./keychain-coordinator";
|
|
6
|
+
|
|
7
|
+
export const CLAUDE_WEB_CACHE_KEY = "claude-web";
|
|
4
8
|
|
|
5
9
|
export class ClaudeWebResolver implements TokenResolver<ClaudeWebSession> {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
constructor(
|
|
11
|
+
private readonly cursorFactory: () => BrowserCookieSourceCursor = () =>
|
|
12
|
+
new BrowserCookieSourceCursor("claude.ai", ["sessionKey"], { preferredBrowserId: "claude-desktop" }),
|
|
13
|
+
) {}
|
|
14
|
+
|
|
15
|
+
resolve(): Promise<ClaudeWebSession | null> {
|
|
16
|
+
return Promise.resolve().then(() => this.buildSession());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Cache-only resolution for planning and Web extras. Never reads a browser
|
|
21
|
+
* cookie store and never requests a Safe Storage key.
|
|
22
|
+
*/
|
|
23
|
+
async resolveCached(): Promise<ClaudeWebSession | null> {
|
|
24
|
+
const observed = usageWebSessionCache.observe(CLAUDE_WEB_CACHE_KEY);
|
|
25
|
+
if (!observed.value) return null;
|
|
26
|
+
return {
|
|
27
|
+
cookieHeader: observed.value,
|
|
28
|
+
cookieCandidates: [observed.value],
|
|
29
|
+
cacheRevision: observed.revision,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* True when an import could yield candidates: a cached value exists or some
|
|
35
|
+
* browser import source is present. Cheap existence checks only — no SQLite
|
|
36
|
+
* reads and no Keychain requests.
|
|
37
|
+
*/
|
|
38
|
+
async hasImportSources(): Promise<boolean> {
|
|
39
|
+
if (usageWebSessionCache.observe(CLAUDE_WEB_CACHE_KEY).value) return true;
|
|
40
|
+
return hasBrowserCookieSources();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private async buildSession(): Promise<ClaudeWebSession | null> {
|
|
44
|
+
const observed = usageWebSessionCache.observe(CLAUDE_WEB_CACHE_KEY);
|
|
45
|
+
if (observed.value) {
|
|
46
|
+
return {
|
|
47
|
+
cookieHeader: observed.value,
|
|
48
|
+
cookieCandidates: [observed.value],
|
|
49
|
+
cacheRevision: observed.revision,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Cache miss: import the first source. Later sources are only inspected
|
|
54
|
+
// when the client advances the cursor after a classified auth rejection.
|
|
55
|
+
const cursor = this.cursorFactory();
|
|
56
|
+
const candidate = await cursor.next();
|
|
57
|
+
if (!candidate) return null;
|
|
58
|
+
const header = buildCookieHeader(candidate.cookies);
|
|
59
|
+
if (!header) return null;
|
|
60
|
+
return {
|
|
61
|
+
cookieHeader: header,
|
|
62
|
+
cookieCandidates: [header],
|
|
63
|
+
cacheRevision: observed.revision,
|
|
64
|
+
importNext: async () => {
|
|
65
|
+
const next = await cursor.next();
|
|
66
|
+
return next ? buildCookieHeader(next.cookies) : null;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
11
69
|
}
|
|
12
70
|
|
|
13
71
|
async isAvailable(): Promise<boolean> {
|
|
14
72
|
return (await this.resolve()) !== null;
|
|
15
73
|
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Actionable keychain diagnostic for the latest import attempt (e.g. the
|
|
77
|
+
* Safe Storage read failed), or null when the miss was not keychain-related.
|
|
78
|
+
*/
|
|
79
|
+
describeKeychainFailure(): string | null {
|
|
80
|
+
return keychainCoordinator.describeBlockingFailure(CLAUDE_SAFE_STORAGE_ITEMS);
|
|
81
|
+
}
|
|
16
82
|
}
|
|
@@ -1,8 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import type { TokenResolver } from "../resolver";
|
|
5
5
|
import type { CodexOAuthCredentials } from "../types";
|
|
6
|
+
import { UsageError } from "../errors";
|
|
7
|
+
|
|
8
|
+
const NATIVE_EXPIRY_MARGIN_MS = 5 * 60 * 1_000;
|
|
9
|
+
const NATIVE_REFRESH_MAX_AGE_MS = 8 * 24 * 60 * 60 * 1_000;
|
|
10
|
+
|
|
11
|
+
export class CodexCredentialSourceError extends UsageError {
|
|
12
|
+
constructor(
|
|
13
|
+
public readonly reason: "malformed" | "unreadable",
|
|
14
|
+
message: string,
|
|
15
|
+
) {
|
|
16
|
+
super(reason === "malformed" ? "invalid-response" : "transient", message);
|
|
17
|
+
this.name = "CodexCredentialSourceError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
6
20
|
|
|
7
21
|
function getCodexDir(): string {
|
|
8
22
|
if (process.env.CODEX_HOME) return process.env.CODEX_HOME;
|
|
@@ -11,7 +25,6 @@ function getCodexDir(): string {
|
|
|
11
25
|
|
|
12
26
|
function readConfigToml(codexDir: string): string | undefined {
|
|
13
27
|
const path = join(codexDir, "config.toml");
|
|
14
|
-
if (!existsSync(path)) return undefined;
|
|
15
28
|
try {
|
|
16
29
|
const content = readFileSync(path, "utf8");
|
|
17
30
|
for (const line of content.split(/\r?\n/)) {
|
|
@@ -39,6 +52,38 @@ function decodeJwtClaims(token: string): { email?: string; sub?: string; org_id?
|
|
|
39
52
|
}
|
|
40
53
|
}
|
|
41
54
|
|
|
55
|
+
function decodeJwtExpiry(token: string): number | undefined {
|
|
56
|
+
try {
|
|
57
|
+
const payload = token.split(".")[1];
|
|
58
|
+
if (!payload) return undefined;
|
|
59
|
+
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record<string, unknown>;
|
|
60
|
+
return typeof claims["exp"] === "number" && Number.isFinite(claims["exp"])
|
|
61
|
+
? claims["exp"] * 1_000
|
|
62
|
+
: undefined;
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseTimestamp(value: unknown): number | undefined {
|
|
69
|
+
if (typeof value !== "string") return undefined;
|
|
70
|
+
const timestamp = Date.parse(value);
|
|
71
|
+
return Number.isNaN(timestamp) ? undefined : timestamp;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isCodexCredentialFresh(
|
|
75
|
+
credentials: CodexOAuthCredentials,
|
|
76
|
+
now = Date.now(),
|
|
77
|
+
): boolean {
|
|
78
|
+
if (credentials.tokenExpiresAt !== undefined) {
|
|
79
|
+
return credentials.tokenExpiresAt > now + NATIVE_EXPIRY_MARGIN_MS;
|
|
80
|
+
}
|
|
81
|
+
if (credentials.lastRefreshAt !== undefined) {
|
|
82
|
+
return now - credentials.lastRefreshAt < NATIVE_REFRESH_MAX_AGE_MS;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
42
87
|
/**
|
|
43
88
|
* Extract access_token and related fields from Codex auth.json.
|
|
44
89
|
* Codex CLI writes credentials in a nested "tokens" object, e.g.:
|
|
@@ -56,10 +101,10 @@ function extractTokens(raw: Record<string, unknown>): {
|
|
|
56
101
|
: undefined;
|
|
57
102
|
|
|
58
103
|
const accessToken =
|
|
59
|
-
typeof
|
|
60
|
-
?
|
|
61
|
-
: typeof
|
|
62
|
-
?
|
|
104
|
+
typeof tokens?.["access_token"] === "string"
|
|
105
|
+
? tokens["access_token"]
|
|
106
|
+
: typeof raw["access_token"] === "string"
|
|
107
|
+
? raw["access_token"]
|
|
63
108
|
: undefined;
|
|
64
109
|
|
|
65
110
|
const accountId =
|
|
@@ -76,40 +121,52 @@ function extractTokens(raw: Record<string, unknown>): {
|
|
|
76
121
|
}
|
|
77
122
|
|
|
78
123
|
export class CodexAuthResolver implements TokenResolver<CodexOAuthCredentials> {
|
|
124
|
+
constructor(private readonly configuredBaseUrl?: string) {}
|
|
125
|
+
|
|
79
126
|
async resolve(): Promise<CodexOAuthCredentials | null> {
|
|
80
127
|
const codexDir = getCodexDir();
|
|
81
128
|
const authPath = join(codexDir, "auth.json");
|
|
82
|
-
|
|
129
|
+
let content: string;
|
|
130
|
+
try {
|
|
131
|
+
content = readFileSync(authPath, "utf8");
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
134
|
+
throw new CodexCredentialSourceError("unreadable", "Codex auth.json could not be read");
|
|
135
|
+
}
|
|
83
136
|
|
|
84
137
|
try {
|
|
85
|
-
const
|
|
138
|
+
const parsed = JSON.parse(content) as unknown;
|
|
139
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
140
|
+
throw new Error("expected a JSON object");
|
|
141
|
+
}
|
|
142
|
+
const raw = parsed as Record<string, unknown>;
|
|
86
143
|
const tokens = extractTokens(raw);
|
|
87
144
|
if (!tokens.accessToken) return null;
|
|
88
145
|
|
|
89
146
|
const claims = decodeJwtClaims(tokens.accessToken);
|
|
90
|
-
const
|
|
147
|
+
const tokenExpiresAt = decodeJwtExpiry(tokens.accessToken) ?? parseTimestamp(raw["expires_at"]);
|
|
148
|
+
const lastRefreshAt = parseTimestamp(raw["last_refresh"]);
|
|
149
|
+
const chatgptBaseUrl = this.configuredBaseUrl ?? readConfigToml(codexDir);
|
|
91
150
|
|
|
92
151
|
return {
|
|
93
152
|
accessToken: tokens.accessToken,
|
|
94
153
|
refreshToken: tokens.refreshToken,
|
|
95
154
|
expiresAt: typeof raw["expires_at"] === "string" ? raw["expires_at"] : undefined,
|
|
155
|
+
...(tokenExpiresAt !== undefined ? { tokenExpiresAt } : {}),
|
|
156
|
+
...(lastRefreshAt !== undefined ? { lastRefreshAt } : {}),
|
|
96
157
|
accountEmail: claims.email ?? null,
|
|
97
158
|
accountId: tokens.accountId ?? claims.sub ?? claims.org_id ?? null,
|
|
98
159
|
...(chatgptBaseUrl ? { chatgptBaseUrl } : {}),
|
|
99
160
|
};
|
|
100
|
-
} catch {
|
|
101
|
-
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (error instanceof CodexCredentialSourceError) throw error;
|
|
163
|
+
throw new CodexCredentialSourceError("malformed", "Codex auth.json is malformed");
|
|
102
164
|
}
|
|
103
165
|
}
|
|
104
166
|
|
|
105
167
|
async isAvailable(): Promise<boolean> {
|
|
106
|
-
const codexDir = getCodexDir();
|
|
107
|
-
const authPath = join(codexDir, "auth.json");
|
|
108
|
-
if (!existsSync(authPath)) return false;
|
|
109
168
|
try {
|
|
110
|
-
|
|
111
|
-
const { accessToken } = extractTokens(raw);
|
|
112
|
-
return typeof accessToken === "string" && accessToken.length > 0;
|
|
169
|
+
return (await this.resolve()) !== null;
|
|
113
170
|
} catch {
|
|
114
171
|
return false;
|
|
115
172
|
}
|