Merge pull request #654 from radek-paclt/fix/claude-cli-oauth-refresh
fix(auth): enable OAuth refresh for Claude CLI credentials
This commit is contained in:
@@ -68,6 +68,7 @@
|
|||||||
- Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj.
|
- Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj.
|
||||||
- Build: Docker build cache layer (#605) — thanks @zknicker.
|
- Build: Docker build cache layer (#605) — thanks @zknicker.
|
||||||
|
|
||||||
|
- Auth: enable OAuth token refresh for Claude CLI credentials (`anthropic:claude-cli`) with bidirectional sync back to Claude Code storage (file on Linux/Windows, Keychain on macOS). This allows long-running agents to operate autonomously without manual re-authentication (#654 — thanks @radek-paclt).
|
||||||
|
|
||||||
## 2026.1.8
|
## 2026.1.8
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,24 @@ At runtime:
|
|||||||
- if `expires` is in the future → use the stored access token
|
- if `expires` is in the future → use the stored access token
|
||||||
- if expired → refresh (under a file lock) and overwrite the stored credentials
|
- if expired → refresh (under a file lock) and overwrite the stored credentials
|
||||||
|
|
||||||
The refresh flow is automatic; you generally don’t need to manage tokens manually.
|
The refresh flow is automatic; you generally don't need to manage tokens manually.
|
||||||
|
|
||||||
|
### Bidirectional sync with Claude Code
|
||||||
|
|
||||||
|
When Clawdbot refreshes an Anthropic OAuth token (profile `anthropic:claude-cli`), it **writes the new credentials back** to Claude Code's storage:
|
||||||
|
|
||||||
|
- **Linux/Windows**: updates `~/.claude/.credentials.json`
|
||||||
|
- **macOS**: updates Keychain item "Claude Code-credentials"
|
||||||
|
|
||||||
|
This ensures both tools stay in sync and neither gets "logged out" after the other refreshes.
|
||||||
|
|
||||||
|
**Why this matters for long-running agents:**
|
||||||
|
|
||||||
|
Anthropic OAuth tokens expire after a few hours. Without bidirectional sync:
|
||||||
|
1. Clawdbot refreshes the token → gets new access token
|
||||||
|
2. Claude Code still has the old token → gets logged out
|
||||||
|
|
||||||
|
With bidirectional sync, both tools always have the latest valid token, enabling autonomous operation for days or weeks without manual intervention.
|
||||||
|
|
||||||
## Multiple accounts (profiles) + routing
|
## Multiple accounts (profiles) + routing
|
||||||
|
|
||||||
|
|||||||
@@ -574,7 +574,7 @@ describe("markAuthProfileFailure", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("external CLI credential sync", () => {
|
describe("external CLI credential sync", () => {
|
||||||
it("syncs Claude CLI credentials into anthropic:claude-cli", async () => {
|
it("syncs Claude CLI OAuth credentials into anthropic:claude-cli", async () => {
|
||||||
const agentDir = fs.mkdtempSync(
|
const agentDir = fs.mkdtempSync(
|
||||||
path.join(os.tmpdir(), "clawdbot-cli-sync-"),
|
path.join(os.tmpdir(), "clawdbot-cli-sync-"),
|
||||||
);
|
);
|
||||||
@@ -582,7 +582,7 @@ describe("external CLI credential sync", () => {
|
|||||||
// Create a temp home with Claude CLI credentials
|
// Create a temp home with Claude CLI credentials
|
||||||
await withTempHome(
|
await withTempHome(
|
||||||
async (tempHome) => {
|
async (tempHome) => {
|
||||||
// Create Claude CLI credentials
|
// Create Claude CLI credentials with refreshToken (OAuth)
|
||||||
const claudeDir = path.join(tempHome, ".claude");
|
const claudeDir = path.join(tempHome, ".claude");
|
||||||
fs.mkdirSync(claudeDir, { recursive: true });
|
fs.mkdirSync(claudeDir, { recursive: true });
|
||||||
const claudeCreds = {
|
const claudeCreds = {
|
||||||
@@ -613,7 +613,7 @@ describe("external CLI credential sync", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load the store - should sync from CLI
|
// Load the store - should sync from CLI as OAuth credential
|
||||||
const store = ensureAuthProfileStore(agentDir);
|
const store = ensureAuthProfileStore(agentDir);
|
||||||
|
|
||||||
expect(store.profiles["anthropic:default"]).toBeDefined();
|
expect(store.profiles["anthropic:default"]).toBeDefined();
|
||||||
@@ -621,13 +621,120 @@ describe("external CLI credential sync", () => {
|
|||||||
(store.profiles["anthropic:default"] as { key: string }).key,
|
(store.profiles["anthropic:default"] as { key: string }).key,
|
||||||
).toBe("sk-default");
|
).toBe("sk-default");
|
||||||
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
|
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
|
||||||
expect(
|
// Should be stored as OAuth credential (type: "oauth") for auto-refresh
|
||||||
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token,
|
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
||||||
).toBe("fresh-access-token");
|
expect(cliProfile.type).toBe("oauth");
|
||||||
expect(
|
expect((cliProfile as { access: string }).access).toBe(
|
||||||
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { expires: number })
|
"fresh-access-token",
|
||||||
.expires,
|
);
|
||||||
).toBeGreaterThan(Date.now());
|
expect((cliProfile as { refresh: string }).refresh).toBe(
|
||||||
|
"fresh-refresh-token",
|
||||||
|
);
|
||||||
|
expect((cliProfile as { expires: number }).expires).toBeGreaterThan(
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ prefix: "clawdbot-home-" },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(agentDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncs Claude CLI credentials without refreshToken as token type", async () => {
|
||||||
|
const agentDir = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), "clawdbot-cli-token-sync-"),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await withTempHome(
|
||||||
|
async (tempHome) => {
|
||||||
|
// Create Claude CLI credentials WITHOUT refreshToken (fallback to token type)
|
||||||
|
const claudeDir = path.join(tempHome, ".claude");
|
||||||
|
fs.mkdirSync(claudeDir, { recursive: true });
|
||||||
|
const claudeCreds = {
|
||||||
|
claudeAiOauth: {
|
||||||
|
accessToken: "access-only-token",
|
||||||
|
// No refreshToken - backward compatibility scenario
|
||||||
|
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(claudeDir, ".credentials.json"),
|
||||||
|
JSON.stringify(claudeCreds),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||||
|
fs.writeFileSync(
|
||||||
|
authPath,
|
||||||
|
JSON.stringify({ version: 1, profiles: {} }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const store = ensureAuthProfileStore(agentDir);
|
||||||
|
|
||||||
|
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
|
||||||
|
// Should be stored as token type (no refresh capability)
|
||||||
|
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
||||||
|
expect(cliProfile.type).toBe("token");
|
||||||
|
expect((cliProfile as { token: string }).token).toBe(
|
||||||
|
"access-only-token",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ prefix: "clawdbot-home-" },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(agentDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upgrades token to oauth when Claude CLI gets refreshToken", async () => {
|
||||||
|
const agentDir = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), "clawdbot-cli-upgrade-"),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await withTempHome(
|
||||||
|
async (tempHome) => {
|
||||||
|
// Create Claude CLI credentials with refreshToken
|
||||||
|
const claudeDir = path.join(tempHome, ".claude");
|
||||||
|
fs.mkdirSync(claudeDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(claudeDir, ".credentials.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
claudeAiOauth: {
|
||||||
|
accessToken: "new-oauth-access",
|
||||||
|
refreshToken: "new-refresh-token",
|
||||||
|
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create auth-profiles.json with existing token type credential
|
||||||
|
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||||
|
fs.writeFileSync(
|
||||||
|
authPath,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
profiles: {
|
||||||
|
[CLAUDE_CLI_PROFILE_ID]: {
|
||||||
|
type: "token",
|
||||||
|
provider: "anthropic",
|
||||||
|
token: "old-token",
|
||||||
|
expires: Date.now() + 30 * 60 * 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const store = ensureAuthProfileStore(agentDir);
|
||||||
|
|
||||||
|
// Should upgrade from token to oauth
|
||||||
|
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
||||||
|
expect(cliProfile.type).toBe("oauth");
|
||||||
|
expect((cliProfile as { access: string }).access).toBe(
|
||||||
|
"new-oauth-access",
|
||||||
|
);
|
||||||
|
expect((cliProfile as { refresh: string }).refresh).toBe(
|
||||||
|
"new-refresh-token",
|
||||||
|
);
|
||||||
},
|
},
|
||||||
{ prefix: "clawdbot-home-" },
|
{ prefix: "clawdbot-home-" },
|
||||||
);
|
);
|
||||||
@@ -732,20 +839,21 @@ describe("external CLI credential sync", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not overwrite fresher store token with older Claude CLI credentials", async () => {
|
it("prefers oauth over token even if token has later expiry (oauth enables auto-refresh)", async () => {
|
||||||
const agentDir = fs.mkdtempSync(
|
const agentDir = fs.mkdtempSync(
|
||||||
path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-"),
|
path.join(os.tmpdir(), "clawdbot-cli-oauth-preferred-"),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await withTempHome(
|
await withTempHome(
|
||||||
async (tempHome) => {
|
async (tempHome) => {
|
||||||
const claudeDir = path.join(tempHome, ".claude");
|
const claudeDir = path.join(tempHome, ".claude");
|
||||||
fs.mkdirSync(claudeDir, { recursive: true });
|
fs.mkdirSync(claudeDir, { recursive: true });
|
||||||
|
// CLI has OAuth credentials (with refresh token) expiring in 30 min
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(claudeDir, ".credentials.json"),
|
path.join(claudeDir, ".credentials.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
claudeAiOauth: {
|
claudeAiOauth: {
|
||||||
accessToken: "cli-access",
|
accessToken: "cli-oauth-access",
|
||||||
refreshToken: "cli-refresh",
|
refreshToken: "cli-refresh",
|
||||||
expiresAt: Date.now() + 30 * 60 * 1000,
|
expiresAt: Date.now() + 30 * 60 * 1000,
|
||||||
},
|
},
|
||||||
@@ -753,6 +861,7 @@ describe("external CLI credential sync", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||||
|
// Store has token credentials expiring in 60 min (later than CLI)
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
authPath,
|
authPath,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -761,7 +870,7 @@ describe("external CLI credential sync", () => {
|
|||||||
[CLAUDE_CLI_PROFILE_ID]: {
|
[CLAUDE_CLI_PROFILE_ID]: {
|
||||||
type: "token",
|
type: "token",
|
||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
token: "store-access",
|
token: "store-token-access",
|
||||||
expires: Date.now() + 60 * 60 * 1000,
|
expires: Date.now() + 60 * 60 * 1000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -769,9 +878,119 @@ describe("external CLI credential sync", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const store = ensureAuthProfileStore(agentDir);
|
const store = ensureAuthProfileStore(agentDir);
|
||||||
expect(
|
// OAuth should be preferred over token because it can auto-refresh
|
||||||
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token,
|
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
||||||
).toBe("store-access");
|
expect(cliProfile.type).toBe("oauth");
|
||||||
|
expect((cliProfile as { access: string }).access).toBe(
|
||||||
|
"cli-oauth-access",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ prefix: "clawdbot-home-" },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(agentDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not overwrite fresher store oauth with older CLI oauth", async () => {
|
||||||
|
const agentDir = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), "clawdbot-cli-oauth-no-downgrade-"),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await withTempHome(
|
||||||
|
async (tempHome) => {
|
||||||
|
const claudeDir = path.join(tempHome, ".claude");
|
||||||
|
fs.mkdirSync(claudeDir, { recursive: true });
|
||||||
|
// CLI has OAuth credentials expiring in 30 min
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(claudeDir, ".credentials.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
claudeAiOauth: {
|
||||||
|
accessToken: "cli-oauth-access",
|
||||||
|
refreshToken: "cli-refresh",
|
||||||
|
expiresAt: Date.now() + 30 * 60 * 1000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||||
|
// Store has OAuth credentials expiring in 60 min (later than CLI)
|
||||||
|
fs.writeFileSync(
|
||||||
|
authPath,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
profiles: {
|
||||||
|
[CLAUDE_CLI_PROFILE_ID]: {
|
||||||
|
type: "oauth",
|
||||||
|
provider: "anthropic",
|
||||||
|
access: "store-oauth-access",
|
||||||
|
refresh: "store-refresh",
|
||||||
|
expires: Date.now() + 60 * 60 * 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const store = ensureAuthProfileStore(agentDir);
|
||||||
|
// Fresher store oauth should be kept
|
||||||
|
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
||||||
|
expect(cliProfile.type).toBe("oauth");
|
||||||
|
expect((cliProfile as { access: string }).access).toBe(
|
||||||
|
"store-oauth-access",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ prefix: "clawdbot-home-" },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(agentDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not downgrade store oauth to token when CLI lacks refresh token", async () => {
|
||||||
|
const agentDir = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-oauth-"),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await withTempHome(
|
||||||
|
async (tempHome) => {
|
||||||
|
const claudeDir = path.join(tempHome, ".claude");
|
||||||
|
fs.mkdirSync(claudeDir, { recursive: true });
|
||||||
|
// CLI has token-only credentials (no refresh token)
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(claudeDir, ".credentials.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
claudeAiOauth: {
|
||||||
|
accessToken: "cli-token-access",
|
||||||
|
expiresAt: Date.now() + 30 * 60 * 1000,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||||
|
// Store already has OAuth credentials with refresh token
|
||||||
|
fs.writeFileSync(
|
||||||
|
authPath,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
profiles: {
|
||||||
|
[CLAUDE_CLI_PROFILE_ID]: {
|
||||||
|
type: "oauth",
|
||||||
|
provider: "anthropic",
|
||||||
|
access: "store-oauth-access",
|
||||||
|
refresh: "store-refresh",
|
||||||
|
expires: Date.now() + 60 * 60 * 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const store = ensureAuthProfileStore(agentDir);
|
||||||
|
// Keep oauth to preserve auto-refresh capability
|
||||||
|
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
||||||
|
expect(cliProfile.type).toBe("oauth");
|
||||||
|
expect((cliProfile as { access: string }).access).toBe(
|
||||||
|
"store-oauth-access",
|
||||||
|
);
|
||||||
},
|
},
|
||||||
{ prefix: "clawdbot-home-" },
|
{ prefix: "clawdbot-home-" },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -136,6 +136,133 @@ function saveJsonFile(pathname: string, data: unknown) {
|
|||||||
fs.chmodSync(pathname, 0o600);
|
fs.chmodSync(pathname, 0o600);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write refreshed OAuth credentials back to Claude CLI's credential storage.
|
||||||
|
* This ensures Claude Code continues to work after ClawdBot refreshes the token.
|
||||||
|
*
|
||||||
|
* On macOS: Updates keychain entry "Claude Code-credentials" (primary storage).
|
||||||
|
* On Linux/Windows: Updates ~/.claude/.credentials.json file.
|
||||||
|
*
|
||||||
|
* Only writes if Claude CLI credentials exist (Claude Code is installed).
|
||||||
|
*/
|
||||||
|
function writeClaudeCliCredentials(newCredentials: OAuthCredentials): boolean {
|
||||||
|
// On macOS, Claude Code uses keychain as primary storage
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
return writeClaudeCliKeychainCredentials(newCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
// On Linux/Windows, use file storage
|
||||||
|
return writeClaudeCliFileCredentials(newCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write credentials to macOS keychain.
|
||||||
|
*/
|
||||||
|
function writeClaudeCliKeychainCredentials(
|
||||||
|
newCredentials: OAuthCredentials,
|
||||||
|
): boolean {
|
||||||
|
try {
|
||||||
|
// First read existing keychain entry to preserve other fields
|
||||||
|
const existingResult = execSync(
|
||||||
|
'security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null',
|
||||||
|
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingData = JSON.parse(existingResult.trim());
|
||||||
|
const existingOauth = existingData?.claudeAiOauth;
|
||||||
|
if (!existingOauth || typeof existingOauth !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update with new tokens while preserving other fields
|
||||||
|
existingData.claudeAiOauth = {
|
||||||
|
...existingOauth,
|
||||||
|
accessToken: newCredentials.access,
|
||||||
|
refreshToken: newCredentials.refresh,
|
||||||
|
expiresAt: newCredentials.expires,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newValue = JSON.stringify(existingData);
|
||||||
|
|
||||||
|
// Delete old entry and add new one (keychain doesn't support update)
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
'security delete-generic-password -s "Claude Code-credentials"',
|
||||||
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 5000,
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Entry might not exist, continue
|
||||||
|
}
|
||||||
|
|
||||||
|
execSync(
|
||||||
|
`security add-generic-password -s "Claude Code-credentials" -a "Claude Code" -w '${newValue.replace(/'/g, "'\"'\"'")}'`,
|
||||||
|
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
log.info("wrote refreshed credentials to claude cli keychain", {
|
||||||
|
expires: new Date(newCredentials.expires).toISOString(),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
log.warn("failed to write credentials to claude cli keychain", {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
// Fall back to file storage on macOS
|
||||||
|
return writeClaudeCliFileCredentials(newCredentials);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write credentials to file storage (~/.claude/.credentials.json).
|
||||||
|
*/
|
||||||
|
function writeClaudeCliFileCredentials(
|
||||||
|
newCredentials: OAuthCredentials,
|
||||||
|
): boolean {
|
||||||
|
const credPath = path.join(
|
||||||
|
resolveUserPath("~"),
|
||||||
|
CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only update if Claude CLI credentials file exists
|
||||||
|
if (!fs.existsSync(credPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = loadJsonFile(credPath);
|
||||||
|
if (!raw || typeof raw !== "object") return false;
|
||||||
|
|
||||||
|
const data = raw as Record<string, unknown>;
|
||||||
|
const existingOauth = data.claudeAiOauth as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
if (!existingOauth || typeof existingOauth !== "object") return false;
|
||||||
|
|
||||||
|
// Update with new tokens while preserving other fields (scopes, subscriptionType, etc.)
|
||||||
|
data.claudeAiOauth = {
|
||||||
|
...existingOauth,
|
||||||
|
accessToken: newCredentials.access,
|
||||||
|
refreshToken: newCredentials.refresh,
|
||||||
|
expiresAt: newCredentials.expires,
|
||||||
|
};
|
||||||
|
|
||||||
|
saveJsonFile(credPath, data);
|
||||||
|
log.info("wrote refreshed credentials to claude cli file", {
|
||||||
|
expires: new Date(newCredentials.expires).toISOString(),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
log.warn("failed to write credentials to claude cli file", {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ensureAuthStoreFile(pathname: string) {
|
function ensureAuthStoreFile(pathname: string) {
|
||||||
if (fs.existsSync(pathname)) return;
|
if (fs.existsSync(pathname)) return;
|
||||||
const payload: AuthProfileStore = {
|
const payload: AuthProfileStore = {
|
||||||
@@ -235,6 +362,16 @@ async function refreshOAuthTokenWithLock(params: {
|
|||||||
type: "oauth",
|
type: "oauth",
|
||||||
};
|
};
|
||||||
saveAuthProfileStore(store, params.agentDir);
|
saveAuthProfileStore(store, params.agentDir);
|
||||||
|
|
||||||
|
// Sync refreshed credentials back to Claude CLI if this is the claude-cli profile
|
||||||
|
// This ensures Claude Code continues to work after ClawdBot refreshes the token
|
||||||
|
if (
|
||||||
|
params.profileId === CLAUDE_CLI_PROFILE_ID &&
|
||||||
|
cred.provider === "anthropic"
|
||||||
|
) {
|
||||||
|
writeClaudeCliCredentials(result.newCredentials);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
if (release) {
|
if (release) {
|
||||||
@@ -345,14 +482,19 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
|
|||||||
*
|
*
|
||||||
* On macOS, Claude Code stores credentials in keychain "Claude Code-credentials".
|
* On macOS, Claude Code stores credentials in keychain "Claude Code-credentials".
|
||||||
* On Linux/Windows, it uses ~/.claude/.credentials.json
|
* On Linux/Windows, it uses ~/.claude/.credentials.json
|
||||||
|
*
|
||||||
|
* Returns OAuthCredential when refreshToken is available (enables auto-refresh),
|
||||||
|
* or TokenCredential as fallback for backward compatibility.
|
||||||
*/
|
*/
|
||||||
function readClaudeCliCredentials(options?: {
|
function readClaudeCliCredentials(options?: {
|
||||||
allowKeychainPrompt?: boolean;
|
allowKeychainPrompt?: boolean;
|
||||||
}): TokenCredential | null {
|
}): OAuthCredential | TokenCredential | null {
|
||||||
if (process.platform === "darwin" && options?.allowKeychainPrompt !== false) {
|
if (process.platform === "darwin" && options?.allowKeychainPrompt !== false) {
|
||||||
const keychainCreds = readClaudeCliKeychainCredentials();
|
const keychainCreds = readClaudeCliKeychainCredentials();
|
||||||
if (keychainCreds) {
|
if (keychainCreds) {
|
||||||
log.info("read anthropic credentials from claude cli keychain");
|
log.info("read anthropic credentials from claude cli keychain", {
|
||||||
|
type: keychainCreds.type,
|
||||||
|
});
|
||||||
return keychainCreds;
|
return keychainCreds;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,11 +511,24 @@ function readClaudeCliCredentials(options?: {
|
|||||||
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
||||||
|
|
||||||
const accessToken = claudeOauth.accessToken;
|
const accessToken = claudeOauth.accessToken;
|
||||||
|
const refreshToken = claudeOauth.refreshToken;
|
||||||
const expiresAt = claudeOauth.expiresAt;
|
const expiresAt = claudeOauth.expiresAt;
|
||||||
|
|
||||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||||
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
||||||
|
|
||||||
|
// Return OAuthCredential when refreshToken is available (enables auto-refresh)
|
||||||
|
if (typeof refreshToken === "string" && refreshToken) {
|
||||||
|
return {
|
||||||
|
type: "oauth",
|
||||||
|
provider: "anthropic",
|
||||||
|
access: accessToken,
|
||||||
|
refresh: refreshToken,
|
||||||
|
expires: expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to TokenCredential for backward compatibility (no auto-refresh)
|
||||||
return {
|
return {
|
||||||
type: "token",
|
type: "token",
|
||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
@@ -385,8 +540,14 @@ function readClaudeCliCredentials(options?: {
|
|||||||
/**
|
/**
|
||||||
* Read Claude Code credentials from macOS keychain.
|
* Read Claude Code credentials from macOS keychain.
|
||||||
* Uses the `security` CLI to access keychain without native dependencies.
|
* Uses the `security` CLI to access keychain without native dependencies.
|
||||||
|
*
|
||||||
|
* Returns OAuthCredential when refreshToken is available (enables auto-refresh),
|
||||||
|
* or TokenCredential as fallback for backward compatibility.
|
||||||
*/
|
*/
|
||||||
function readClaudeCliKeychainCredentials(): TokenCredential | null {
|
function readClaudeCliKeychainCredentials():
|
||||||
|
| OAuthCredential
|
||||||
|
| TokenCredential
|
||||||
|
| null {
|
||||||
try {
|
try {
|
||||||
const result = execSync(
|
const result = execSync(
|
||||||
'security find-generic-password -s "Claude Code-credentials" -w',
|
'security find-generic-password -s "Claude Code-credentials" -w',
|
||||||
@@ -398,11 +559,24 @@ function readClaudeCliKeychainCredentials(): TokenCredential | null {
|
|||||||
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
||||||
|
|
||||||
const accessToken = claudeOauth.accessToken;
|
const accessToken = claudeOauth.accessToken;
|
||||||
|
const refreshToken = claudeOauth.refreshToken;
|
||||||
const expiresAt = claudeOauth.expiresAt;
|
const expiresAt = claudeOauth.expiresAt;
|
||||||
|
|
||||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||||
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
||||||
|
|
||||||
|
// Return OAuthCredential when refreshToken is available (enables auto-refresh)
|
||||||
|
if (typeof refreshToken === "string" && refreshToken) {
|
||||||
|
return {
|
||||||
|
type: "oauth",
|
||||||
|
provider: "anthropic",
|
||||||
|
access: accessToken,
|
||||||
|
refresh: refreshToken,
|
||||||
|
expires: expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to TokenCredential for backward compatibility (no auto-refresh)
|
||||||
return {
|
return {
|
||||||
type: "token",
|
type: "token",
|
||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
@@ -501,28 +675,58 @@ function syncExternalCliCredentials(
|
|||||||
let mutated = false;
|
let mutated = false;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Sync from Claude CLI
|
// Sync from Claude CLI (supports both OAuth and Token credentials)
|
||||||
const claudeCreds = readClaudeCliCredentials(options);
|
const claudeCreds = readClaudeCliCredentials(options);
|
||||||
if (claudeCreds) {
|
if (claudeCreds) {
|
||||||
const existing = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
const existing = store.profiles[CLAUDE_CLI_PROFILE_ID];
|
||||||
const existingToken = existing?.type === "token" ? existing : undefined;
|
const claudeCredsExpires = claudeCreds.expires ?? 0;
|
||||||
|
|
||||||
// Update if: no existing profile, existing is not oauth, or CLI has newer/valid token
|
// Determine if we should update based on credential comparison
|
||||||
const shouldUpdate =
|
let shouldUpdate = false;
|
||||||
!existingToken ||
|
let isEqual = false;
|
||||||
existingToken.provider !== "anthropic" ||
|
|
||||||
(existingToken.expires ?? 0) <= now ||
|
|
||||||
((claudeCreds.expires ?? 0) > now &&
|
|
||||||
(claudeCreds.expires ?? 0) > (existingToken.expires ?? 0));
|
|
||||||
|
|
||||||
if (
|
if (claudeCreds.type === "oauth") {
|
||||||
shouldUpdate &&
|
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
|
||||||
!shallowEqualTokenCredentials(existingToken, claudeCreds)
|
isEqual = shallowEqualOAuthCredentials(existingOAuth, claudeCreds);
|
||||||
) {
|
// Update if: no existing profile, type changed to oauth, expired, or CLI has newer token
|
||||||
|
shouldUpdate =
|
||||||
|
!existingOAuth ||
|
||||||
|
existingOAuth.provider !== "anthropic" ||
|
||||||
|
existingOAuth.expires <= now ||
|
||||||
|
(claudeCredsExpires > now &&
|
||||||
|
claudeCredsExpires > existingOAuth.expires);
|
||||||
|
} else {
|
||||||
|
const existingToken = existing?.type === "token" ? existing : undefined;
|
||||||
|
isEqual = shallowEqualTokenCredentials(existingToken, claudeCreds);
|
||||||
|
// Update if: no existing profile, expired, or CLI has newer token
|
||||||
|
shouldUpdate =
|
||||||
|
!existingToken ||
|
||||||
|
existingToken.provider !== "anthropic" ||
|
||||||
|
(existingToken.expires ?? 0) <= now ||
|
||||||
|
(claudeCredsExpires > now &&
|
||||||
|
claudeCredsExpires > (existingToken.expires ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also update if credential type changed (token -> oauth upgrade)
|
||||||
|
if (existing && existing.type !== claudeCreds.type) {
|
||||||
|
// Prefer oauth over token (enables auto-refresh)
|
||||||
|
if (claudeCreds.type === "oauth") {
|
||||||
|
shouldUpdate = true;
|
||||||
|
isEqual = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avoid downgrading from oauth to token-only credentials.
|
||||||
|
if (existing?.type === "oauth" && claudeCreds.type === "token") {
|
||||||
|
shouldUpdate = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldUpdate && !isEqual) {
|
||||||
store.profiles[CLAUDE_CLI_PROFILE_ID] = claudeCreds;
|
store.profiles[CLAUDE_CLI_PROFILE_ID] = claudeCreds;
|
||||||
mutated = true;
|
mutated = true;
|
||||||
log.info("synced anthropic credentials from claude cli", {
|
log.info("synced anthropic credentials from claude cli", {
|
||||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||||
|
type: claudeCreds.type,
|
||||||
expires:
|
expires:
|
||||||
typeof claudeCreds.expires === "number"
|
typeof claudeCreds.expires === "number"
|
||||||
? new Date(claudeCreds.expires).toISOString()
|
? new Date(claudeCreds.expires).toISOString()
|
||||||
|
|||||||
Reference in New Issue
Block a user