@timo972/cc-router 0.12.1 → 0.12.2-rc.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/Dockerfile +1 -0
  3. package/README.md +1 -1
  4. package/dist/cli/cmd-accounts.js +116 -65
  5. package/dist/cli/cmd-setup.js +100 -30
  6. package/dist/cli/cmd-status.js +14 -2
  7. package/dist/cli/cmd-telemetry.js +43 -32
  8. package/dist/cli/index.js +15 -1
  9. package/dist/config/directory.js +14 -0
  10. package/dist/config/telemetry.js +192 -41
  11. package/dist/providers/anthropic/usage-refresher.js +35 -1
  12. package/dist/providers/model-discovery.js +17 -11
  13. package/dist/providers/openai/device-oauth.js +88 -31
  14. package/dist/providers/openai/token-refresher.js +12 -2
  15. package/dist/providers/openai/usage-fetch.js +19 -2
  16. package/dist/proxy/anthropic-messages-route.js +144 -5
  17. package/dist/proxy/anthropic-proxy.js +10 -0
  18. package/dist/proxy/anthropic-response-capture.js +6 -19
  19. package/dist/proxy/openai-ingress.js +98 -1
  20. package/dist/proxy/server.js +35 -22
  21. package/dist/proxy/token-refresher.js +12 -2
  22. package/dist/proxy/usage-capture.js +41 -4
  23. package/dist/telemetry/contracts.js +129 -0
  24. package/dist/telemetry/facade.js +654 -0
  25. package/dist/telemetry/otel-exporters.js +289 -0
  26. package/dist/telemetry/posthog-client.js +398 -0
  27. package/dist/telemetry/privacy.js +567 -0
  28. package/dist/telemetry/runtime.js +306 -0
  29. package/dist/telemetry/setup-diagnostics.js +239 -0
  30. package/dist/utils/token-extractor.js +79 -11
  31. package/dist/utils/token-validator.js +25 -9
  32. package/docs/README.md +34 -0
  33. package/docs/telemetry.md +167 -0
  34. package/package.json +12 -2
  35. package/dist/utils/telemetry.js +0 -88
@@ -0,0 +1,306 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { context, propagation, trace } from "@opentelemetry/api";
4
+ import { logs } from "@opentelemetry/api-logs";
5
+ import { resourceFromAttributes } from "@opentelemetry/resources";
6
+ import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
7
+ import { BatchSpanProcessor, ParentBasedSampler, SamplingDecision, TraceIdRatioBasedSampler, } from "@opentelemetry/sdk-trace-base";
8
+ import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
9
+ import { createTelemetryConsentGate, getTelemetrySnapshot, } from "../config/telemetry.js";
10
+ import { TELEMETRY_PATH } from "../config/paths.js";
11
+ import { getCurrentVersion } from "../utils/self-update.js";
12
+ import { createPostHogOtlpExporters } from "./otel-exporters.js";
13
+ import { createPostHogTelemetryClient } from "./posthog-client.js";
14
+ import { rebuildSanitizedException, sanitizeException } from "./privacy.js";
15
+ const TRACE_SAMPLE_RATIO = 0.1;
16
+ const QUEUE_SIZE = 100;
17
+ const BATCH_SIZE = 20;
18
+ const EXPORT_DELAY_MS = 500;
19
+ const EXPORT_TIMEOUT_MS = 2_000;
20
+ /**
21
+ * Fatal exceptions cannot be sent from a crashing process (Node exits as soon
22
+ * as the monitor returns), so the sanitized record is written here
23
+ * synchronously and delivered by the next start that still holds consent.
24
+ */
25
+ const MAX_PENDING_EXCEPTIONS = 20;
26
+ // Resolved lazily (like the state path itself) so the module loads even when
27
+ // the paths module is partially mocked; every caller runs inside a try/catch.
28
+ function pendingExceptionsPath() {
29
+ return `${TELEMETRY_PATH}.pending.json`;
30
+ }
31
+ function readPendingExceptions() {
32
+ try {
33
+ const parsed = JSON.parse(readFileSync(pendingExceptionsPath(), "utf8"));
34
+ return Array.isArray(parsed) ? parsed : [];
35
+ }
36
+ catch {
37
+ return [];
38
+ }
39
+ }
40
+ function persistFatalException(exception, snapshot) {
41
+ const { error: _error, ...record } = exception;
42
+ const pending = [
43
+ ...readPendingExceptions().slice(-(MAX_PENDING_EXCEPTIONS - 1)),
44
+ { installationId: snapshot.state.installId, consentGeneration: snapshot.state.consentGeneration, exception: record },
45
+ ];
46
+ const target = pendingExceptionsPath();
47
+ const candidate = `${target}.${process.pid}.tmp`;
48
+ writeFileSync(candidate, JSON.stringify(pending), { mode: 0o600 });
49
+ renameSync(candidate, target);
50
+ }
51
+ /** Deliver records left by a crash, only under the consent they were written with. */
52
+ function deliverPendingExceptions(consent, posthog) {
53
+ try {
54
+ const records = readPendingExceptions();
55
+ // Remove first: a crash during delivery must never resend the same records.
56
+ unlinkSync(pendingExceptionsPath());
57
+ const current = consent.getSnapshot();
58
+ if (!current)
59
+ return;
60
+ for (const raw of records) {
61
+ if (typeof raw !== "object" || raw === null)
62
+ continue;
63
+ const record = raw;
64
+ if (record.installationId !== current.state.installId)
65
+ continue;
66
+ // A different generation means an explicit choice happened in between.
67
+ if (record.consentGeneration !== current.state.consentGeneration)
68
+ continue;
69
+ const exception = rebuildSanitizedException(record.exception);
70
+ if (exception)
71
+ posthog.captureException(exception, current.state.consentGeneration);
72
+ }
73
+ }
74
+ catch {
75
+ // Nothing pending, or an unreadable file: never affects startup.
76
+ }
77
+ }
78
+ /** Telemetry must never join or emit a distributed trace outside this process. */
79
+ export const noopPropagator = {
80
+ inject() { },
81
+ extract(carrierContext) {
82
+ return carrierContext;
83
+ },
84
+ fields() {
85
+ return [];
86
+ },
87
+ };
88
+ let activeRuntime;
89
+ function osFamily() {
90
+ switch (process.platform) {
91
+ case "darwin": return "macos";
92
+ case "linux": return "linux";
93
+ case "win32": return "windows";
94
+ default: return "other";
95
+ }
96
+ }
97
+ function cpuArchitecture() {
98
+ return process.arch === "arm64" || process.arch === "x64" ? process.arch : "other";
99
+ }
100
+ /** Loopback OTLP endpoints for the telemetry test suite; never read in production. */
101
+ function testOtlpUrls() {
102
+ if (process.env["NODE_ENV"] !== "test")
103
+ return {};
104
+ return {
105
+ traceUrl: process.env["CC_ROUTER_TEST_OTLP_TRACE_URL"],
106
+ logUrl: process.env["CC_ROUTER_TEST_OTLP_LOG_URL"],
107
+ };
108
+ }
109
+ function exporterOptions(consent, options) {
110
+ const test = testOtlpUrls();
111
+ return {
112
+ getSnapshot: () => consent.getSnapshot(),
113
+ traceUrl: options.traceUrl ?? test.traceUrl,
114
+ logUrl: options.logUrl ?? test.logUrl,
115
+ };
116
+ }
117
+ function telemetryResource(snapshot, runtimeMode) {
118
+ return resourceFromAttributes({
119
+ "service.name": "cc-router",
120
+ "service.version": getCurrentVersion(),
121
+ "service.instance.id": snapshot.state.installId,
122
+ "process.runtime.version": process.versions.node,
123
+ "os.type": osFamily(),
124
+ "host.arch": cpuArchitecture(),
125
+ "cc_router.runtime_mode": runtimeMode,
126
+ });
127
+ }
128
+ function consentGatedSampler(consent) {
129
+ const delegate = new ParentBasedSampler({ root: new TraceIdRatioBasedSampler(TRACE_SAMPLE_RATIO) });
130
+ return {
131
+ shouldSample(...args) {
132
+ return consent.getSnapshot()
133
+ ? delegate.shouldSample(...args)
134
+ : { decision: SamplingDecision.NOT_RECORD };
135
+ },
136
+ toString: () => `ConsentGated{${delegate.toString()}}`,
137
+ };
138
+ }
139
+ function startTracing(consent, snapshot, options) {
140
+ const { spanExporter } = createPostHogOtlpExporters(exporterOptions(consent, options));
141
+ const provider = new NodeTracerProvider({
142
+ resource: telemetryResource(snapshot, options.runtimeMode),
143
+ sampler: consentGatedSampler(consent),
144
+ spanProcessors: [new BatchSpanProcessor(spanExporter, {
145
+ maxQueueSize: QUEUE_SIZE,
146
+ maxExportBatchSize: BATCH_SIZE,
147
+ scheduledDelayMillis: EXPORT_DELAY_MS,
148
+ exportTimeoutMillis: EXPORT_TIMEOUT_MS,
149
+ })],
150
+ });
151
+ // register() installs the AsyncLocalStorage context manager together with the
152
+ // global tracer provider; the inert propagator keeps trace ids off the wire.
153
+ provider.register({ propagator: noopPropagator });
154
+ trace.setGlobalTracerProvider(provider);
155
+ return provider;
156
+ }
157
+ function settleWithin(operation, deadlineMs) {
158
+ const bounded = Number.isFinite(deadlineMs) ? Math.max(0, Math.min(10_000, Math.floor(deadlineMs))) : 0;
159
+ let timer;
160
+ return Promise.race([
161
+ Promise.resolve().then(operation).catch(() => undefined),
162
+ new Promise(resolve => {
163
+ timer = setTimeout(resolve, bounded);
164
+ timer.unref?.();
165
+ }),
166
+ ]).then(() => undefined).catch(() => undefined).finally(() => clearTimeout(timer));
167
+ }
168
+ /**
169
+ * Start (or upgrade to tracing) the one telemetry runtime of this process.
170
+ * Spans are created manually through the facade; no instrumentation is loaded.
171
+ */
172
+ export function startTelemetryRuntime(options) {
173
+ try {
174
+ const existing = activeRuntime;
175
+ if (existing) {
176
+ if (options.tracing && !existing.tracerProvider && !existing.shuttingDown) {
177
+ existing.tracerProvider = startTracing(existing.consent, existing.snapshot, options);
178
+ }
179
+ return !existing.shuttingDown;
180
+ }
181
+ const snapshot = getTelemetrySnapshot();
182
+ if (!snapshot.enabled)
183
+ return false;
184
+ let discardQueuedTelemetry = () => undefined;
185
+ const consent = createTelemetryConsentGate(getTelemetrySnapshot, () => discardQueuedTelemetry());
186
+ const { logExporter } = createPostHogOtlpExporters(exporterOptions(consent, options));
187
+ const logProcessor = new BatchLogRecordProcessor({
188
+ exporter: logExporter,
189
+ maxQueueSize: QUEUE_SIZE,
190
+ maxExportBatchSize: BATCH_SIZE,
191
+ scheduledDelayMillis: EXPORT_DELAY_MS,
192
+ exportTimeoutMillis: EXPORT_TIMEOUT_MS,
193
+ });
194
+ const posthog = createPostHogTelemetryClient({ getSnapshot: () => consent.getSnapshot() });
195
+ const fatalMonitor = (error) => {
196
+ try {
197
+ const current = consent.getSnapshot();
198
+ if (!current)
199
+ return;
200
+ const exception = sanitizeException(error, {
201
+ category: "runtime",
202
+ reason: "other",
203
+ runtimeMode: options.runtimeMode,
204
+ }, {
205
+ installationId: current.state.installId,
206
+ diagnosticId: randomUUID(),
207
+ });
208
+ if (!exception)
209
+ return;
210
+ // Node's default handler prints the Error itself; add correlation only.
211
+ // The process exits as soon as this monitor returns, so the record is
212
+ // persisted synchronously and sent by the next consenting start.
213
+ persistFatalException(exception, current);
214
+ console.error(`[cc-router] Unexpected runtime failure (diagnostic ID: ${exception.diagnosticId})`);
215
+ }
216
+ catch {
217
+ // The monitor observes only; it never changes Node's crash behavior.
218
+ }
219
+ };
220
+ const runtime = {
221
+ loggerProvider: new LoggerProvider({
222
+ resource: telemetryResource(snapshot, options.runtimeMode),
223
+ processors: [logProcessor],
224
+ }),
225
+ posthog,
226
+ consent,
227
+ snapshot,
228
+ fatalMonitor,
229
+ exitCleanup: () => process.removeListener("uncaughtExceptionMonitor", fatalMonitor),
230
+ shuttingDown: false,
231
+ };
232
+ discardQueuedTelemetry = () => {
233
+ posthog.discardPending();
234
+ void logProcessor.shutdown().catch(() => undefined);
235
+ void runtime.tracerProvider?.shutdown().catch(() => undefined);
236
+ };
237
+ logs.setGlobalLoggerProvider(runtime.loggerProvider);
238
+ if (options.tracing)
239
+ runtime.tracerProvider = startTracing(consent, snapshot, options);
240
+ process.on("uncaughtExceptionMonitor", fatalMonitor);
241
+ process.once("exit", runtime.exitCleanup);
242
+ activeRuntime = runtime;
243
+ deliverPendingExceptions(consent, posthog);
244
+ return true;
245
+ }
246
+ catch {
247
+ return false;
248
+ }
249
+ }
250
+ export function isTelemetryRuntimeActive() {
251
+ return activeRuntime !== undefined && !activeRuntime.shuttingDown;
252
+ }
253
+ /** True while this process runs the proxy's tracing runtime, which owns its own shutdown. */
254
+ export function isTelemetryTracingActive() {
255
+ return isTelemetryRuntimeActive() && activeRuntime?.tracerProvider !== undefined;
256
+ }
257
+ export async function flushTelemetryRuntimeWithin(deadlineMs) {
258
+ const runtime = activeRuntime;
259
+ if (!runtime || runtime.shuttingDown)
260
+ return;
261
+ try {
262
+ if (!runtime.consent.getSnapshot()) {
263
+ runtime.posthog.discardPending();
264
+ return;
265
+ }
266
+ }
267
+ catch {
268
+ return;
269
+ }
270
+ await settleWithin(async () => {
271
+ await Promise.all([
272
+ runtime.loggerProvider.forceFlush(),
273
+ runtime.tracerProvider?.forceFlush(),
274
+ runtime.posthog.flushWithin(deadlineMs),
275
+ ]);
276
+ }, deadlineMs);
277
+ }
278
+ export async function shutdownTelemetryRuntimeWithin(deadlineMs) {
279
+ const runtime = activeRuntime;
280
+ if (!runtime || runtime.shuttingDown)
281
+ return;
282
+ runtime.shuttingDown = true;
283
+ process.removeListener("uncaughtExceptionMonitor", runtime.fatalMonitor);
284
+ process.removeListener("exit", runtime.exitCleanup);
285
+ try {
286
+ if (!runtime.consent.getSnapshot())
287
+ runtime.posthog.discardPending();
288
+ }
289
+ catch {
290
+ runtime.posthog.discardPending();
291
+ }
292
+ await settleWithin(async () => {
293
+ await Promise.all([
294
+ runtime.loggerProvider.shutdown(),
295
+ runtime.tracerProvider?.shutdown(),
296
+ runtime.posthog.shutdownWithin(deadlineMs),
297
+ ]);
298
+ }, deadlineMs);
299
+ logs.disable();
300
+ if (runtime.tracerProvider) {
301
+ trace.disable();
302
+ propagation.disable();
303
+ context.disable();
304
+ }
305
+ activeRuntime = undefined;
306
+ }
@@ -0,0 +1,239 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { flushTelemetryWithin, recordExpectedSetupFailure, recordSetupResult, recordSetupStage, recordSetupStageFailure, recordUnexpectedException, } from "./facade.js";
3
+ /**
4
+ * A setup error keeps detailed text and its original cause local while exposing
5
+ * a separate, closed classification to telemetry call sites.
6
+ */
7
+ export class SetupDiagnosticError extends Error {
8
+ classification;
9
+ constructor(localMessage, classification, options = {}) {
10
+ super(localMessage, options.cause === undefined ? undefined : { cause: options.cause });
11
+ this.name = "SetupDiagnosticError";
12
+ this.classification = { ...classification };
13
+ }
14
+ }
15
+ export function classifyHttpSetupFailure(stage, status, localMessage, cause) {
16
+ let reason;
17
+ if (status === 401)
18
+ reason = "unauthorized";
19
+ else if (status === 403)
20
+ reason = "forbidden";
21
+ else if (status === 429)
22
+ reason = "rate_limited";
23
+ else if (status >= 400 && status < 500)
24
+ reason = "upstream_4xx";
25
+ else if (status >= 500 && status < 600)
26
+ reason = "upstream_5xx";
27
+ else
28
+ reason = "other";
29
+ return new SetupDiagnosticError(localMessage, {
30
+ stage,
31
+ reason,
32
+ expected: reason !== "other",
33
+ ...(Number.isInteger(status) && status >= 100 && status <= 599
34
+ ? { httpStatusCode: status }
35
+ : {}),
36
+ }, { cause });
37
+ }
38
+ function ownStringProperty(input, property) {
39
+ if ((typeof input !== "object" && typeof input !== "function") || input === null)
40
+ return undefined;
41
+ try {
42
+ const descriptor = Object.getOwnPropertyDescriptor(input, property);
43
+ return descriptor && "value" in descriptor && typeof descriptor.value === "string"
44
+ ? descriptor.value
45
+ : undefined;
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ function ownCause(input) {
52
+ if ((typeof input !== "object" && typeof input !== "function") || input === null)
53
+ return undefined;
54
+ try {
55
+ const descriptor = Object.getOwnPropertyDescriptor(input, "cause");
56
+ return descriptor && "value" in descriptor ? descriptor.value : undefined;
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ }
62
+ function safeBuiltInErrorName(input) {
63
+ const ownName = ownStringProperty(input, "name");
64
+ if (ownName === "AbortError" || ownName === "TimeoutError")
65
+ return ownName;
66
+ if (typeof DOMException === "undefined")
67
+ return undefined;
68
+ try {
69
+ if (!(input instanceof DOMException))
70
+ return undefined;
71
+ }
72
+ catch {
73
+ return undefined;
74
+ }
75
+ const descriptor = Object.getOwnPropertyDescriptor(DOMException.prototype, "name");
76
+ if (!descriptor || typeof descriptor.get !== "function")
77
+ return undefined;
78
+ try {
79
+ const name = descriptor.get.call(input);
80
+ return name === "AbortError" || name === "TimeoutError" ? name : undefined;
81
+ }
82
+ catch {
83
+ return undefined;
84
+ }
85
+ }
86
+ const NETWORK_CODES = new Set([
87
+ "EAI_AGAIN",
88
+ "ECONNREFUSED",
89
+ "ECONNRESET",
90
+ "ENETUNREACH",
91
+ "ENOTFOUND",
92
+ "EPIPE",
93
+ "ETIMEDOUT",
94
+ ]);
95
+ export function classifyNetworkSetupFailure(stage, cause, localMessage = cause instanceof Error ? cause.message : String(cause)) {
96
+ const name = safeBuiltInErrorName(cause);
97
+ let code;
98
+ let current = cause;
99
+ const seen = new Set();
100
+ for (let depth = 0; depth <= 2 && current !== undefined && !seen.has(current); depth++) {
101
+ seen.add(current);
102
+ const candidate = ownStringProperty(current, "code");
103
+ if (candidate && NETWORK_CODES.has(candidate)) {
104
+ code = candidate;
105
+ break;
106
+ }
107
+ current = ownCause(current);
108
+ }
109
+ const reason = name === "AbortError" || name === "TimeoutError" || code === "ETIMEDOUT"
110
+ ? "timeout"
111
+ : code && NETWORK_CODES.has(code)
112
+ ? "network_failure"
113
+ : "other";
114
+ return new SetupDiagnosticError(localMessage, {
115
+ stage,
116
+ reason,
117
+ expected: reason !== "other",
118
+ }, { cause });
119
+ }
120
+ function durationBucket(durationMs) {
121
+ if (durationMs < 1_000)
122
+ return "under_1s";
123
+ if (durationMs < 5_000)
124
+ return "1s_to_5s";
125
+ if (durationMs < 30_000)
126
+ return "5s_to_30s";
127
+ if (durationMs < 120_000)
128
+ return "30s_to_2m";
129
+ return "over_2m";
130
+ }
131
+ export function createSetupAttempt(input) {
132
+ const startedAt = Date.now();
133
+ const diagnosticId = randomUUID();
134
+ const base = { provider: input.provider, method: input.method, diagnosticId };
135
+ const withDuration = () => ({
136
+ ...base,
137
+ durationBucket: durationBucket(Math.max(0, Date.now() - startedAt)),
138
+ });
139
+ recordSetupStage({ ...withDuration(), stage: "attempt_start" });
140
+ let terminal = false;
141
+ const classify = (error, fallbackStage) => error instanceof SetupDiagnosticError
142
+ ? error.classification
143
+ : { stage: fallbackStage, reason: "other", expected: false };
144
+ const failureInput = (classification) => ({
145
+ ...withDuration(),
146
+ stage: classification.stage,
147
+ reason: classification.reason,
148
+ ...(classification.httpStatusCode === undefined
149
+ ? {}
150
+ : { httpStatusCode: classification.httpStatusCode }),
151
+ });
152
+ const recordException = (error, classification) => {
153
+ if (classification.expected)
154
+ return;
155
+ const exceptionCause = error instanceof SetupDiagnosticError && error.cause !== undefined
156
+ ? error.cause
157
+ : error;
158
+ recordUnexpectedException(exceptionCause, {
159
+ category: "setup",
160
+ provider: input.provider,
161
+ setupStage: classification.stage,
162
+ reason: classification.reason,
163
+ }, diagnosticId);
164
+ };
165
+ return {
166
+ ...base,
167
+ stageCompleted(stage) {
168
+ if (terminal)
169
+ return;
170
+ recordSetupStage({ ...withDuration(), stage });
171
+ },
172
+ stageFailed(error, fallbackStage) {
173
+ const classification = classify(error, fallbackStage);
174
+ if (!terminal) {
175
+ recordSetupStageFailure(failureInput(classification));
176
+ recordException(error, classification);
177
+ }
178
+ return { diagnosticId, unexpected: !classification.expected };
179
+ },
180
+ failed(error, fallbackStage) {
181
+ const classification = classify(error, fallbackStage);
182
+ if (!terminal) {
183
+ terminal = true;
184
+ recordExpectedSetupFailure(failureInput(classification));
185
+ recordException(error, classification);
186
+ }
187
+ return { diagnosticId, unexpected: !classification.expected };
188
+ },
189
+ cancelled() {
190
+ if (terminal)
191
+ return;
192
+ terminal = true;
193
+ recordSetupResult({ ...withDuration(), result: "cancelled" });
194
+ },
195
+ succeeded() {
196
+ if (terminal)
197
+ return;
198
+ terminal = true;
199
+ recordSetupResult({ ...withDuration(), result: "succeeded" });
200
+ },
201
+ };
202
+ }
203
+ export const SETUP_TELEMETRY_FLUSH_DEADLINE_MS = 1_500;
204
+ /** Never let a bounded telemetry flush change or delay a command result. */
205
+ export async function withSetupTelemetryFlush(operation) {
206
+ try {
207
+ return await operation();
208
+ }
209
+ finally {
210
+ await new Promise(resolve => {
211
+ let settled = false;
212
+ const finish = () => {
213
+ if (settled)
214
+ return;
215
+ settled = true;
216
+ clearTimeout(timer);
217
+ resolve();
218
+ };
219
+ const timer = setTimeout(finish, SETUP_TELEMETRY_FLUSH_DEADLINE_MS);
220
+ void Promise.resolve()
221
+ .then(() => flushTelemetryWithin(SETUP_TELEMETRY_FLUSH_DEADLINE_MS))
222
+ .then(finish, finish);
223
+ });
224
+ }
225
+ }
226
+ export function isPromptCancellation(error) {
227
+ return ownStringProperty(error, "name") === "ExitPromptError";
228
+ }
229
+ /**
230
+ * Close an attempt that ended in a thrown error: a cancelled prompt is a user
231
+ * decision (no outcome returned); anything else is a failure at the given stage.
232
+ */
233
+ export function failAttemptFromError(attempt, error, fallbackStage) {
234
+ if (isPromptCancellation(error)) {
235
+ attempt.cancelled();
236
+ return undefined;
237
+ }
238
+ return attempt.failed(error, fallbackStage);
239
+ }
@@ -3,28 +3,74 @@ import { promisify } from "util";
3
3
  import { readFileSync, existsSync } from "fs";
4
4
  import { join } from "path";
5
5
  import os from "os";
6
+ import { SetupDiagnosticError } from "../telemetry/setup-diagnostics.js";
6
7
  const execFileAsync = promisify(execFile);
8
+ function ownErrorCode(error) {
9
+ if ((typeof error !== "object" && typeof error !== "function") || error === null)
10
+ return undefined;
11
+ const descriptor = Object.getOwnPropertyDescriptor(error, "code");
12
+ return descriptor && "value" in descriptor
13
+ && (typeof descriptor.value === "string" || typeof descriptor.value === "number")
14
+ ? descriptor.value
15
+ : undefined;
16
+ }
17
+ function credentialReadError(error, source) {
18
+ const code = ownErrorCode(error);
19
+ // `security find-generic-password` exits 44 when the item does not exist.
20
+ const reason = code === "EACCES" || code === "EPERM"
21
+ ? "permission_denied"
22
+ : code === "ENOENT" || code === 44
23
+ ? "not_found"
24
+ : "other";
25
+ const detail = error instanceof Error ? error.message : String(error);
26
+ return new SetupDiagnosticError(`${source} read failed: ${detail}`, {
27
+ stage: "credential_read",
28
+ reason,
29
+ expected: reason !== "other",
30
+ }, { cause: error });
31
+ }
32
+ function credentialParseError(error) {
33
+ const detail = error instanceof Error ? error.message : String(error);
34
+ return new SetupDiagnosticError(`Credential parse failed: ${detail}`, {
35
+ stage: "credential_parse",
36
+ reason: "malformed_credentials",
37
+ expected: true,
38
+ }, { cause: error });
39
+ }
7
40
  /**
8
41
  * macOS: extract OAuth tokens from the macOS Keychain.
9
42
  * Uses execFile (not exec/execSync) — args are passed as an array,
10
43
  * preventing any shell injection.
11
44
  */
12
45
  export async function extractFromKeychain() {
46
+ const result = await extractFromKeychainDetailed();
47
+ return result.ok ? result.tokens : null;
48
+ }
49
+ export async function extractFromKeychainDetailed() {
50
+ let stdout;
13
51
  try {
14
- const { stdout } = await execFileAsync("security", [
52
+ ({ stdout } = await execFileAsync("security", [
15
53
  "find-generic-password",
16
54
  "-s", "Claude Code-credentials",
17
55
  "-w",
18
- ]);
56
+ ]));
57
+ }
58
+ catch (error) {
59
+ return { ok: false, error: credentialReadError(error, "Keychain") };
60
+ }
61
+ try {
19
62
  const raw = JSON.parse(stdout.trim());
20
63
  // Keychain JSON can be either:
21
64
  // { claudeAiOauth: { accessToken, refreshToken, ... }, mcpOAuth: {...} }
22
65
  // { accessToken, refreshToken, ... } (direct, older versions)
23
66
  const oauth = raw.claudeAiOauth ?? raw;
24
- return parseCredentialJson(oauth);
67
+ const tokens = parseCredentialJson(oauth);
68
+ if (!tokens)
69
+ throw new TypeError("Credential object is missing required OAuth token fields");
70
+ return { ok: true, tokens };
25
71
  }
26
- catch {
27
- return null;
72
+ catch (error) {
73
+ return { ok: false, error: credentialParseError(error) };
28
74
  }
29
75
  }
30
76
  /**
@@ -33,19 +79,41 @@ export async function extractFromKeychain() {
33
79
  * No shell — pure Node.js file read.
34
80
  */
35
81
  export function extractFromCredentialsFile() {
82
+ const result = extractFromCredentialsFileDetailed();
83
+ return result.ok ? result.tokens : null;
84
+ }
85
+ export function extractFromCredentialsFileDetailed() {
36
86
  const credPath = join(os.homedir(), ".claude", ".credentials.json");
37
- if (!existsSync(credPath))
38
- return null;
87
+ if (!existsSync(credPath)) {
88
+ return {
89
+ ok: false,
90
+ error: new SetupDiagnosticError("Claude credentials file was not found", {
91
+ stage: "credential_read",
92
+ reason: "not_found",
93
+ expected: true,
94
+ }),
95
+ };
96
+ }
97
+ let contents;
98
+ try {
99
+ contents = readFileSync(credPath, "utf-8");
100
+ }
101
+ catch (error) {
102
+ return { ok: false, error: credentialReadError(error, "credentials file") };
103
+ }
39
104
  try {
40
- const raw = JSON.parse(readFileSync(credPath, "utf-8"));
105
+ const raw = JSON.parse(contents);
41
106
  // The file can have two shapes:
42
107
  // { claudeAiOauth: { accessToken, refreshToken, expiresAt, scopes } }
43
108
  // { accessToken, refreshToken, expiresAt, scopes } (direct)
44
109
  const oauth = raw.claudeAiOauth ?? raw;
45
- return parseCredentialJson(oauth);
110
+ const tokens = parseCredentialJson(oauth);
111
+ if (!tokens)
112
+ throw new TypeError("Credential object is missing required OAuth token fields");
113
+ return { ok: true, tokens };
46
114
  }
47
- catch {
48
- return null;
115
+ catch (error) {
116
+ return { ok: false, error: credentialParseError(error) };
49
117
  }
50
118
  }
51
119
  /** Parse and normalise either a raw JSON string or an already-parsed object. */