Skip to content

Commit 9ce0fd8

Browse files
authored
Merge pull request #409 from code-yeongyu/feat/claude-agent-sdk-oauth-provider
feat(coding-agent): claude-agent-sdk provider with native multi-account OAuth
2 parents 5847605 + 4461b08 commit 9ce0fd8

76 files changed

Lines changed: 6186 additions & 32 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Live auth spike for the claude-agent-sdk provider (todo 3, opt-in).
4+
*
5+
* Verifies which multi-account lane the real Claude Code subprocess accepts:
6+
* lane=oauth-slots : access token injected as CLAUDE_CODE_OAUTH_TOKEN via Options.env
7+
* lane=config-dir : per-account CLAUDE_CONFIG_DIR with a .credentials.json
8+
*
9+
* Usage:
10+
* SENPI_LIVE_CLAUDE_AGENT_SDK=1 SENPI_CODING_AGENT_DIR=<sandbox> \
11+
* node .agents/skills/senpi-qa/scripts/claude-agent-sdk-auth-spike.mjs
12+
*
13+
* Outcomes (final line):
14+
* exit 0 "ACCEPTED lane=oauth-slots" | exit 0 "ACCEPTED lane=config-dir" | exit 2 "REJECTED"
15+
* Never prints token material.
16+
*/
17+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
18+
import { tmpdir } from "node:os";
19+
import { join } from "node:path";
20+
21+
if (process.env.SENPI_LIVE_CLAUDE_AGENT_SDK !== "1") {
22+
console.log("SKIPPED: set SENPI_LIVE_CLAUDE_AGENT_SDK=1 to run the live spike");
23+
process.exit(0);
24+
}
25+
26+
const sandbox = process.env.SENPI_CODING_AGENT_DIR;
27+
if (!sandbox) {
28+
console.error("REJECTED: SENPI_CODING_AGENT_DIR must point at the seeded sandbox");
29+
process.exit(2);
30+
}
31+
32+
let stored;
33+
try {
34+
stored = JSON.parse(readFileSync(join(sandbox, "auth.json"), "utf8"));
35+
} catch (error) {
36+
console.error(`REJECTED: sandbox auth.json unreadable: ${error instanceof Error ? error.message : error}`);
37+
process.exit(2);
38+
}
39+
40+
const credential = stored["claude-agent-sdk-spike"] ?? stored["anthropic"];
41+
if (!credential || credential.type !== "oauth" || typeof credential.access !== "string") {
42+
console.error("REJECTED: sandbox auth.json has no usable oauth credential");
43+
process.exit(2);
44+
}
45+
46+
const { query } = await import("@anthropic-ai/claude-agent-sdk");
47+
const { resolveClaudeCodeExecutable, defaultExecutableDeps } = await import(
48+
"../../../packages/coding-agent/src/core/extensions/builtin/claude-agent-sdk/executable.ts"
49+
);
50+
51+
const executable = resolveClaudeCodeExecutable(defaultExecutableDeps());
52+
53+
function neutralizedEnv(extra) {
54+
const env = { ...process.env };
55+
delete env.ANTHROPIC_API_KEY;
56+
delete env.ANTHROPIC_AUTH_TOKEN;
57+
delete env.CLAUDECODE;
58+
return { ...env, ...extra };
59+
}
60+
61+
async function attempt(lane, extraEnv) {
62+
const q = query({
63+
prompt: "Reply with exactly: ok",
64+
options: {
65+
model: "claude-haiku-4-5",
66+
maxTurns: 1,
67+
tools: [],
68+
permissionMode: "dontAsk",
69+
pathToClaudeCodeExecutable: executable,
70+
settingSources: [],
71+
env: neutralizedEnv(extraEnv),
72+
},
73+
});
74+
let sawAssistant = false;
75+
let authError = null;
76+
try {
77+
for await (const message of q) {
78+
if (message.type === "assistant") sawAssistant = true;
79+
if (message.type === "result" && message.subtype !== "success") {
80+
authError = `${message.subtype}`;
81+
}
82+
if (message.type === "assistant" && message.error) {
83+
authError = `${message.error}`;
84+
}
85+
}
86+
} catch (error) {
87+
authError = error instanceof Error ? error.message.slice(0, 120) : `${error}`;
88+
} finally {
89+
try {
90+
q.close();
91+
} catch {
92+
// close best-effort
93+
}
94+
}
95+
if (sawAssistant && !authError) return { ok: true };
96+
return { ok: false, error: authError ?? "no assistant message" };
97+
}
98+
99+
const direct = await attempt("oauth-slots", { CLAUDE_CODE_OAUTH_TOKEN: credential.access });
100+
if (direct.ok) {
101+
console.log(`ACCEPTED lane=oauth-slots`);
102+
process.exit(0);
103+
}
104+
console.error(`lane=oauth-slots rejected (${(direct.error ?? "unknown").replaceAll(/sk-[^\s"]+/g, "[redacted]")})`);
105+
106+
const configDir = mkdtempSync(join(tmpdir(), "claude-agent-sdk-spike-"));
107+
try {
108+
writeFileSync(
109+
join(configDir, ".credentials.json"),
110+
JSON.stringify({
111+
claudeAiOauth: {
112+
accessToken: credential.access,
113+
refreshToken: credential.refresh,
114+
expiresAt: credential.expires,
115+
scopes: credential.scopes ?? ["user:inference", "user:profile", "user:sessions:claude_code"],
116+
},
117+
}),
118+
{ mode: 0o600 },
119+
);
120+
const viaConfigDir = await attempt("config-dir", { CLAUDE_CONFIG_DIR: configDir });
121+
if (viaConfigDir.ok) {
122+
console.log(`ACCEPTED lane=config-dir`);
123+
process.exit(0);
124+
}
125+
console.error(`lane=config-dir rejected (${(viaConfigDir.error ?? "unknown").replaceAll(/sk-[^\s"]+/g, "[redacted]")})`);
126+
} finally {
127+
rmSync(configDir, { recursive: true, force: true });
128+
}
129+
130+
console.error("REJECTED");
131+
process.exit(2);

0 commit comments

Comments
 (0)