Skip to content

Commit 922c436

Browse files
authored
Support Claude auth tokens and OAuth failure handling (#427)
- Let Claude use ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN - Surface clearer auth health and runtime errors for unsupported OAuth - Add settings UI for helper commands and token presets
1 parent 4cae17a commit 922c436

28 files changed

Lines changed: 3885 additions & 43 deletions

apps/server/src/doctor.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,9 @@ const doctorProgram = Effect.gen(function* () {
7979
console.log("No providers are ready. Set up at least one provider to start coding:");
8080
console.log("");
8181
console.log(" Codex: npm install -g @openai/codex && codex login");
82-
console.log(" Claude Code: npm install -g @anthropic-ai/claude-code && claude auth login");
82+
console.log(
83+
" Claude Code: npm install -g @anthropic-ai/claude-code && set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN",
84+
);
8385
} else if (readyCount === statuses.length) {
8486
console.log("All providers are ready.");
8587
} else {

apps/server/src/provider/Layers/ClaudeAdapter.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ class FakeClaudeQuery implements AsyncIterable<SDKMessage> {
132132
function makeHarness(config?: {
133133
readonly nativeEventLogPath?: string;
134134
readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"];
135+
readonly readAuthTokenFromHelperCommand?: ClaudeAdapterLiveOptions["readAuthTokenFromHelperCommand"];
135136
readonly cwd?: string;
136137
readonly baseDir?: string;
137138
}) {
@@ -148,6 +149,11 @@ function makeHarness(config?: {
148149
createInput = input;
149150
return query;
150151
},
152+
...(config?.readAuthTokenFromHelperCommand
153+
? {
154+
readAuthTokenFromHelperCommand: config.readAuthTokenFromHelperCommand,
155+
}
156+
: {}),
151157
...(config?.nativeEventLogger
152158
? {
153159
nativeEventLogger: config.nativeEventLogger,
@@ -237,6 +243,7 @@ async function readFirstPromptMessage(
237243

238244
const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1");
239245
const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-claude-resume");
246+
const THREAD_CWD = "/tmp/claude-session-workspace";
240247

241248
describe("ClaudeAdapterLive", () => {
242249
it.effect("returns validation error for non-claude provider on startSession", () => {
@@ -351,6 +358,90 @@ describe("ClaudeAdapterLive", () => {
351358
);
352359
});
353360

361+
it.effect("sources ANTHROPIC_AUTH_TOKEN from the configured helper command", () => {
362+
const helperCommand = "op read op://shared/anthropic/token --no-newline";
363+
const helperToken = "helper-token";
364+
let helperCall: {
365+
readonly command: string;
366+
readonly options?: { readonly cwd?: string };
367+
} | null = null;
368+
const helper: NonNullable<ClaudeAdapterLiveOptions["readAuthTokenFromHelperCommand"]> = (
369+
command,
370+
options,
371+
) => {
372+
helperCall = {
373+
command,
374+
...(options?.cwd ? { options: { cwd: options.cwd } } : {}),
375+
};
376+
return helperToken;
377+
};
378+
const harness = makeHarness({
379+
readAuthTokenFromHelperCommand: helper,
380+
});
381+
382+
return Effect.gen(function* () {
383+
const adapter = yield* ClaudeAdapter;
384+
yield* adapter.startSession({
385+
threadId: THREAD_ID,
386+
provider: "claudeAgent",
387+
cwd: THREAD_CWD,
388+
runtimeMode: "full-access",
389+
env: {
390+
ANTHROPIC_AUTH_TOKEN: "",
391+
},
392+
providerOptions: {
393+
claudeAgent: {
394+
authTokenHelperCommand: helperCommand,
395+
},
396+
},
397+
});
398+
399+
const createInput = harness.getLastCreateQueryInput();
400+
assert.equal(createInput?.options.env?.ANTHROPIC_AUTH_TOKEN, helperToken);
401+
assert.equal(helperCall?.command, helperCommand);
402+
assert.equal(helperCall?.options?.cwd, THREAD_CWD);
403+
}).pipe(
404+
Effect.provideService(Random.Random, makeDeterministicRandomService()),
405+
Effect.provide(harness.layer),
406+
);
407+
});
408+
409+
it.effect("prefers an explicit ANTHROPIC_AUTH_TOKEN over the helper command", () => {
410+
let helperCallCount = 0;
411+
const helper: NonNullable<ClaudeAdapterLiveOptions["readAuthTokenFromHelperCommand"]> = () => {
412+
helperCallCount += 1;
413+
return "helper-token";
414+
};
415+
const harness = makeHarness({
416+
readAuthTokenFromHelperCommand: helper,
417+
});
418+
419+
return Effect.gen(function* () {
420+
const adapter = yield* ClaudeAdapter;
421+
yield* adapter.startSession({
422+
threadId: THREAD_ID,
423+
provider: "claudeAgent",
424+
cwd: THREAD_CWD,
425+
runtimeMode: "full-access",
426+
env: {
427+
ANTHROPIC_AUTH_TOKEN: "env-token",
428+
},
429+
providerOptions: {
430+
claudeAgent: {
431+
authTokenHelperCommand: "op read op://shared/anthropic/token --no-newline",
432+
},
433+
},
434+
});
435+
436+
const createInput = harness.getLastCreateQueryInput();
437+
assert.equal(createInput?.options.env?.ANTHROPIC_AUTH_TOKEN, "env-token");
438+
assert.equal(helperCallCount, 0);
439+
}).pipe(
440+
Effect.provideService(Random.Random, makeDeterministicRandomService()),
441+
Effect.provide(harness.layer),
442+
);
443+
});
444+
354445
it.effect("forwards claude effort levels into query options", () => {
355446
const harness = makeHarness();
356447
return Effect.gen(function* () {

apps/server/src/provider/Layers/ClaudeAdapter.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
extractTextAttachmentContents,
7676
} from "../../attachmentText.ts";
7777
import { ServerConfig } from "../../config.ts";
78+
import { readClaudeAuthTokenFromHelperCommand } from "../claudeAuthTokenHelper.ts";
7879
import {
7980
ProviderAdapterProcessError,
8081
ProviderAdapterRequestError,
@@ -186,6 +187,7 @@ export interface ClaudeAdapterLiveOptions {
186187
readonly prompt: AsyncIterable<SDKUserMessage>;
187188
readonly options: ClaudeQueryOptions;
188189
}) => ClaudeQueryRuntime;
190+
readonly readAuthTokenFromHelperCommand?: typeof readClaudeAuthTokenFromHelperCommand;
189191
readonly nativeEventLogPath?: string;
190192
readonly nativeEventLogger?: EventNdjsonLogger;
191193
}
@@ -209,6 +211,12 @@ function toError(cause: unknown, fallback: string): Error {
209211
return cause instanceof Error ? cause : new Error(toMessage(cause, fallback));
210212
}
211213

214+
function nonEmptyTrimmed(value: string | undefined): string | undefined {
215+
if (!value) return undefined;
216+
const trimmed = value.trim();
217+
return trimmed.length > 0 ? trimmed : undefined;
218+
}
219+
212220
function normalizeClaudeStreamMessages(cause: Cause.Cause<Error>): ReadonlyArray<string> {
213221
const errors = Cause.prettyErrors(cause)
214222
.map((error) => error.message.trim())
@@ -237,6 +245,27 @@ function isClaudeInterruptedCause(cause: Cause.Cause<Error>): boolean {
237245
);
238246
}
239247

248+
const CLAUDE_AUTH_ERROR_PATTERNS = [
249+
"oauth authentication is currently not supported",
250+
"could not resolve authentication method",
251+
"expected either apiKey or authToken to be set",
252+
"no access token was provided",
253+
"no auth token was provided",
254+
] as const;
255+
256+
function isClaudeAuthErrorMessage(message: string): boolean {
257+
const normalized = message.toLowerCase();
258+
return CLAUDE_AUTH_ERROR_PATTERNS.some((pattern) => normalized.includes(pattern.toLowerCase()));
259+
}
260+
261+
function isClaudeAuthCause(cause: Cause.Cause<Error>): boolean {
262+
return normalizeClaudeStreamMessages(cause).some(isClaudeAuthErrorMessage);
263+
}
264+
265+
function claudeAuthFailureMessage(): string {
266+
return "Claude Code is not configured with a supported Anthropic credential. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN and try again.";
267+
}
268+
240269
function messageFromClaudeStreamCause(cause: Cause.Cause<Error>, fallback: string): string {
241270
return normalizeClaudeStreamMessages(cause)[0] ?? fallback;
242271
}
@@ -996,6 +1025,8 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
9961025
stream: "native",
9971026
})
9981027
: undefined);
1028+
const readAuthTokenFromHelperCommand =
1029+
options?.readAuthTokenFromHelperCommand ?? readClaudeAuthTokenFromHelperCommand;
9991030

10001031
const createQuery =
10011032
options?.createQuery ??
@@ -2349,6 +2380,10 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
23492380
interruptionMessageFromClaudeCause(exit.cause),
23502381
);
23512382
}
2383+
} else if (isClaudeAuthCause(exit.cause)) {
2384+
const message = claudeAuthFailureMessage();
2385+
yield* emitRuntimeError(context, message, Cause.pretty(exit.cause));
2386+
yield* completeTurn(context, "failed", message);
23522387
} else {
23532388
const message = messageFromClaudeStreamCause(
23542389
exit.cause,
@@ -2796,6 +2831,29 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
27962831
...(fastMode ? { fastMode: true } : {}),
27972832
};
27982833
const runtimeEnv = input.env ? compactNodeProcessEnv(input.env) : undefined;
2834+
const baseEnv = mergeNodeProcessEnv(process.env, runtimeEnv);
2835+
const explicitAuthToken = nonEmptyTrimmed(baseEnv.ANTHROPIC_AUTH_TOKEN);
2836+
const helperCommand = providerOptions?.authTokenHelperCommand;
2837+
let authToken = explicitAuthToken;
2838+
if (!authToken && helperCommand) {
2839+
authToken = yield* Effect.try({
2840+
try: () =>
2841+
readAuthTokenFromHelperCommand(helperCommand, {
2842+
...(input.cwd ? { cwd: input.cwd } : {}),
2843+
env: baseEnv,
2844+
}),
2845+
catch: (cause) =>
2846+
new ProviderAdapterProcessError({
2847+
provider: PROVIDER,
2848+
threadId,
2849+
detail: `Failed to resolve Claude auth token from helper command: ${toMessage(cause, "unknown error")}`,
2850+
cause,
2851+
}),
2852+
});
2853+
}
2854+
const queryEnv = authToken
2855+
? mergeNodeProcessEnv(baseEnv, { ANTHROPIC_AUTH_TOKEN: authToken })
2856+
: baseEnv;
27992857

28002858
const queryOptions: ClaudeQueryOptions = {
28012859
...(input.cwd ? { cwd: input.cwd } : {}),
@@ -2815,7 +2873,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
28152873
...(newSessionId ? { sessionId: newSessionId } : {}),
28162874
includePartialMessages: true,
28172875
canUseTool,
2818-
env: sanitizeShellEnvironment(mergeNodeProcessEnv(process.env, runtimeEnv)),
2876+
env: sanitizeShellEnvironment(queryEnv),
28192877
...(input.cwd ? { additionalDirectories: [input.cwd] } : {}),
28202878
};
28212879

@@ -2829,7 +2887,9 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
28292887
new ProviderAdapterProcessError({
28302888
provider: PROVIDER,
28312889
threadId,
2832-
detail: toMessage(cause, "Failed to start Claude runtime session."),
2890+
detail: isClaudeAuthErrorMessage(toMessage(cause, ""))
2891+
? claudeAuthFailureMessage()
2892+
: toMessage(cause, "Failed to start Claude runtime session."),
28332893
cause,
28342894
}),
28352895
});

apps/server/src/provider/Layers/ProviderHealth.test.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -484,7 +484,7 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => {
484484
if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
485485
if (joined === "auth status")
486486
return {
487-
stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
487+
stdout: '{"loggedIn":true,"authMethod":"apiKey"}\n',
488488
stderr: "",
489489
code: 0,
490490
};
@@ -535,7 +535,7 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => {
535535
assert.strictEqual(status.authStatus, "unauthenticated");
536536
assert.strictEqual(
537537
status.message,
538-
"Claude is not authenticated. Run `claude auth login` and try again.",
538+
"Claude is not configured with a supported Anthropic credential. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN and try again.",
539539
);
540540
}).pipe(
541541
Effect.provide(
@@ -554,6 +554,34 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => {
554554
),
555555
);
556556

557+
it.effect("returns unauthenticated when auth status reports oauth auth", () =>
558+
Effect.gen(function* () {
559+
const status = yield* checkClaudeProviderStatus;
560+
assert.strictEqual(status.provider, "claudeAgent");
561+
assert.strictEqual(status.status, "error");
562+
assert.strictEqual(status.available, true);
563+
assert.strictEqual(status.authStatus, "unauthenticated");
564+
assert.strictEqual(
565+
status.message,
566+
"Claude Code is signed in with OAuth, which is not supported here. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN and try again.",
567+
);
568+
}).pipe(
569+
Effect.provide(
570+
mockSpawnerLayer((args) => {
571+
const joined = args.join(" ");
572+
if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
573+
if (joined === "auth status")
574+
return {
575+
stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
576+
stderr: "",
577+
code: 0,
578+
};
579+
throw new Error(`Unexpected args: ${joined}`);
580+
}),
581+
),
582+
),
583+
);
584+
557585
it.effect("returns unauthenticated when output includes 'not logged in'", () =>
558586
Effect.gen(function* () {
559587
const status = yield* checkClaudeProviderStatus;
@@ -613,8 +641,12 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => {
613641
stderr: "",
614642
code: 0,
615643
});
616-
assert.strictEqual(parsed.status, "ready");
617-
assert.strictEqual(parsed.authStatus, "authenticated");
644+
assert.strictEqual(parsed.status, "error");
645+
assert.strictEqual(parsed.authStatus, "unauthenticated");
646+
assert.strictEqual(
647+
parsed.message,
648+
"Claude Code is signed in with OAuth, which is not supported here. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN and try again.",
649+
);
618650
});
619651

620652
it("JSON with loggedIn=false is unauthenticated", () => {

0 commit comments

Comments
 (0)