Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/server/src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ const doctorProgram = Effect.gen(function* () {
console.log("No providers are ready. Set up at least one provider to start coding:");
console.log("");
console.log(" Codex: npm install -g @openai/codex && codex login");
console.log(" Claude Code: npm install -g @anthropic-ai/claude-code && claude auth login");
console.log(
" Claude Code: npm install -g @anthropic-ai/claude-code && set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN",
);
} else if (readyCount === statuses.length) {
console.log("All providers are ready.");
} else {
Expand Down
91 changes: 91 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ class FakeClaudeQuery implements AsyncIterable<SDKMessage> {
function makeHarness(config?: {
readonly nativeEventLogPath?: string;
readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"];
readonly readAuthTokenFromHelperCommand?: ClaudeAdapterLiveOptions["readAuthTokenFromHelperCommand"];
readonly cwd?: string;
readonly baseDir?: string;
}) {
Expand All @@ -148,6 +149,11 @@ function makeHarness(config?: {
createInput = input;
return query;
},
...(config?.readAuthTokenFromHelperCommand
? {
readAuthTokenFromHelperCommand: config.readAuthTokenFromHelperCommand,
}
: {}),
...(config?.nativeEventLogger
? {
nativeEventLogger: config.nativeEventLogger,
Expand Down Expand Up @@ -237,6 +243,7 @@ async function readFirstPromptMessage(

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

describe("ClaudeAdapterLive", () => {
it.effect("returns validation error for non-claude provider on startSession", () => {
Expand Down Expand Up @@ -351,6 +358,90 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("sources ANTHROPIC_AUTH_TOKEN from the configured helper command", () => {
const helperCommand = "op read op://shared/anthropic/token --no-newline";
const helperToken = "helper-token";
let helperCall: {
readonly command: string;
readonly options?: { readonly cwd?: string };
} | null = null;
const helper: NonNullable<ClaudeAdapterLiveOptions["readAuthTokenFromHelperCommand"]> = (
command,
options,
) => {
helperCall = {
command,
...(options?.cwd ? { options: { cwd: options.cwd } } : {}),
};
return helperToken;
};
const harness = makeHarness({
readAuthTokenFromHelperCommand: helper,
});

return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
yield* adapter.startSession({
threadId: THREAD_ID,
provider: "claudeAgent",
cwd: THREAD_CWD,
runtimeMode: "full-access",
env: {
ANTHROPIC_AUTH_TOKEN: "",
},
providerOptions: {
claudeAgent: {
authTokenHelperCommand: helperCommand,
},
},
});

const createInput = harness.getLastCreateQueryInput();
assert.equal(createInput?.options.env?.ANTHROPIC_AUTH_TOKEN, helperToken);
assert.equal(helperCall?.command, helperCommand);
assert.equal(helperCall?.options?.cwd, THREAD_CWD);
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("prefers an explicit ANTHROPIC_AUTH_TOKEN over the helper command", () => {
let helperCallCount = 0;
const helper: NonNullable<ClaudeAdapterLiveOptions["readAuthTokenFromHelperCommand"]> = () => {
helperCallCount += 1;
return "helper-token";
};
const harness = makeHarness({
readAuthTokenFromHelperCommand: helper,
});

return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
yield* adapter.startSession({
threadId: THREAD_ID,
provider: "claudeAgent",
cwd: THREAD_CWD,
runtimeMode: "full-access",
env: {
ANTHROPIC_AUTH_TOKEN: "env-token",
},
providerOptions: {
claudeAgent: {
authTokenHelperCommand: "op read op://shared/anthropic/token --no-newline",
},
},
});

const createInput = harness.getLastCreateQueryInput();
assert.equal(createInput?.options.env?.ANTHROPIC_AUTH_TOKEN, "env-token");
assert.equal(helperCallCount, 0);
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("forwards claude effort levels into query options", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down
64 changes: 62 additions & 2 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import {
extractTextAttachmentContents,
} from "../../attachmentText.ts";
import { ServerConfig } from "../../config.ts";
import { readClaudeAuthTokenFromHelperCommand } from "../claudeAuthTokenHelper.ts";
import {
ProviderAdapterProcessError,
ProviderAdapterRequestError,
Expand Down Expand Up @@ -186,6 +187,7 @@ export interface ClaudeAdapterLiveOptions {
readonly prompt: AsyncIterable<SDKUserMessage>;
readonly options: ClaudeQueryOptions;
}) => ClaudeQueryRuntime;
readonly readAuthTokenFromHelperCommand?: typeof readClaudeAuthTokenFromHelperCommand;
readonly nativeEventLogPath?: string;
readonly nativeEventLogger?: EventNdjsonLogger;
}
Expand All @@ -209,6 +211,12 @@ function toError(cause: unknown, fallback: string): Error {
return cause instanceof Error ? cause : new Error(toMessage(cause, fallback));
}

function nonEmptyTrimmed(value: string | undefined): string | undefined {
if (!value) return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}

function normalizeClaudeStreamMessages(cause: Cause.Cause<Error>): ReadonlyArray<string> {
const errors = Cause.prettyErrors(cause)
.map((error) => error.message.trim())
Expand Down Expand Up @@ -237,6 +245,27 @@ function isClaudeInterruptedCause(cause: Cause.Cause<Error>): boolean {
);
}

const CLAUDE_AUTH_ERROR_PATTERNS = [
"oauth authentication is currently not supported",
"could not resolve authentication method",
"expected either apiKey or authToken to be set",
"no access token was provided",
"no auth token was provided",
] as const;

function isClaudeAuthErrorMessage(message: string): boolean {
const normalized = message.toLowerCase();
return CLAUDE_AUTH_ERROR_PATTERNS.some((pattern) => normalized.includes(pattern.toLowerCase()));
}

function isClaudeAuthCause(cause: Cause.Cause<Error>): boolean {
return normalizeClaudeStreamMessages(cause).some(isClaudeAuthErrorMessage);
}

function claudeAuthFailureMessage(): string {
return "Claude Code is not configured with a supported Anthropic credential. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN and try again.";
}

function messageFromClaudeStreamCause(cause: Cause.Cause<Error>, fallback: string): string {
return normalizeClaudeStreamMessages(cause)[0] ?? fallback;
}
Expand Down Expand Up @@ -996,6 +1025,8 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
stream: "native",
})
: undefined);
const readAuthTokenFromHelperCommand =
options?.readAuthTokenFromHelperCommand ?? readClaudeAuthTokenFromHelperCommand;

const createQuery =
options?.createQuery ??
Expand Down Expand Up @@ -2349,6 +2380,10 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
interruptionMessageFromClaudeCause(exit.cause),
);
}
} else if (isClaudeAuthCause(exit.cause)) {
const message = claudeAuthFailureMessage();
yield* emitRuntimeError(context, message, Cause.pretty(exit.cause));
yield* completeTurn(context, "failed", message);
} else {
const message = messageFromClaudeStreamCause(
exit.cause,
Expand Down Expand Up @@ -2796,6 +2831,29 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
...(fastMode ? { fastMode: true } : {}),
};
const runtimeEnv = input.env ? compactNodeProcessEnv(input.env) : undefined;
const baseEnv = mergeNodeProcessEnv(process.env, runtimeEnv);
const explicitAuthToken = nonEmptyTrimmed(baseEnv.ANTHROPIC_AUTH_TOKEN);
const helperCommand = providerOptions?.authTokenHelperCommand;
let authToken = explicitAuthToken;
if (!authToken && helperCommand) {
authToken = yield* Effect.try({
try: () =>
readAuthTokenFromHelperCommand(helperCommand, {
...(input.cwd ? { cwd: input.cwd } : {}),
env: baseEnv,
}),
catch: (cause) =>
new ProviderAdapterProcessError({
provider: PROVIDER,
threadId,
detail: `Failed to resolve Claude auth token from helper command: ${toMessage(cause, "unknown error")}`,
cause,
}),
});
}
const queryEnv = authToken
? mergeNodeProcessEnv(baseEnv, { ANTHROPIC_AUTH_TOKEN: authToken })
: baseEnv;

const queryOptions: ClaudeQueryOptions = {
...(input.cwd ? { cwd: input.cwd } : {}),
Expand All @@ -2815,7 +2873,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
...(newSessionId ? { sessionId: newSessionId } : {}),
includePartialMessages: true,
canUseTool,
env: sanitizeShellEnvironment(mergeNodeProcessEnv(process.env, runtimeEnv)),
env: sanitizeShellEnvironment(queryEnv),
...(input.cwd ? { additionalDirectories: [input.cwd] } : {}),
};

Expand All @@ -2829,7 +2887,9 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
new ProviderAdapterProcessError({
provider: PROVIDER,
threadId,
detail: toMessage(cause, "Failed to start Claude runtime session."),
detail: isClaudeAuthErrorMessage(toMessage(cause, ""))
? claudeAuthFailureMessage()
: toMessage(cause, "Failed to start Claude runtime session."),
cause,
}),
});
Expand Down
40 changes: 36 additions & 4 deletions apps/server/src/provider/Layers/ProviderHealth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,7 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => {
if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
if (joined === "auth status")
return {
stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
stdout: '{"loggedIn":true,"authMethod":"apiKey"}\n',
stderr: "",
code: 0,
};
Expand Down Expand Up @@ -535,7 +535,7 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => {
assert.strictEqual(status.authStatus, "unauthenticated");
assert.strictEqual(
status.message,
"Claude is not authenticated. Run `claude auth login` and try again.",
"Claude is not configured with a supported Anthropic credential. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN and try again.",
);
}).pipe(
Effect.provide(
Expand All @@ -554,6 +554,34 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => {
),
);

it.effect("returns unauthenticated when auth status reports oauth auth", () =>
Effect.gen(function* () {
const status = yield* checkClaudeProviderStatus;
assert.strictEqual(status.provider, "claudeAgent");
assert.strictEqual(status.status, "error");
assert.strictEqual(status.available, true);
assert.strictEqual(status.authStatus, "unauthenticated");
assert.strictEqual(
status.message,
"Claude Code is signed in with OAuth, which is not supported here. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN and try again.",
);
}).pipe(
Effect.provide(
mockSpawnerLayer((args) => {
const joined = args.join(" ");
if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
if (joined === "auth status")
return {
stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
stderr: "",
code: 0,
};
throw new Error(`Unexpected args: ${joined}`);
}),
),
),
);

it.effect("returns unauthenticated when output includes 'not logged in'", () =>
Effect.gen(function* () {
const status = yield* checkClaudeProviderStatus;
Expand Down Expand Up @@ -613,8 +641,12 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => {
stderr: "",
code: 0,
});
assert.strictEqual(parsed.status, "ready");
assert.strictEqual(parsed.authStatus, "authenticated");
assert.strictEqual(parsed.status, "error");
assert.strictEqual(parsed.authStatus, "unauthenticated");
assert.strictEqual(
parsed.message,
"Claude Code is signed in with OAuth, which is not supported here. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN and try again.",
);
});

it("JSON with loggedIn=false is unauthenticated", () => {
Expand Down
Loading
Loading