Merge branch 'main' into feat/tools-alsoAllow

This commit is contained in:
Shakker
2026-01-26 19:15:39 +00:00
committed by GitHub
63 changed files with 1291 additions and 1849 deletions

View File

@@ -2,12 +2,10 @@ import type { ClawdbotConfig } from "../config/config.js";
import {
type AuthProfileCredential,
type AuthProfileStore,
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
resolveAuthProfileDisplayLabel,
} from "./auth-profiles.js";
export type AuthProfileSource = "claude-cli" | "codex-cli" | "store";
export type AuthProfileSource = "store";
export type AuthProfileHealthStatus = "ok" | "expiring" | "expired" | "missing" | "static";
@@ -41,9 +39,7 @@ export type AuthHealthSummary = {
export const DEFAULT_OAUTH_WARN_MS = 24 * 60 * 60 * 1000;
export function resolveAuthProfileSource(profileId: string): AuthProfileSource {
if (profileId === CLAUDE_CLI_PROFILE_ID) return "claude-cli";
if (profileId === CODEX_CLI_PROFILE_ID) return "codex-cli";
export function resolveAuthProfileSource(_profileId: string): AuthProfileSource {
return "store";
}

View File

@@ -3,8 +3,7 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { ensureAuthProfileStore } from "./auth-profiles.js";
import { AUTH_STORE_VERSION, CODEX_CLI_PROFILE_ID } from "./auth-profiles/constants.js";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js";
describe("ensureAuthProfileStore", () => {
it("migrates legacy auth.json and deletes it (PR #368)", () => {
@@ -123,80 +122,4 @@ describe("ensureAuthProfileStore", () => {
fs.rmSync(root, { recursive: true, force: true });
}
});
it("drops codex-cli from merged store when a custom openai-codex profile matches", async () => {
await withTempHome(async (tempHome) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-dedup-merge-"));
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
try {
const mainDir = path.join(root, "main-agent");
const agentDir = path.join(root, "agent-x");
fs.mkdirSync(mainDir, { recursive: true });
fs.mkdirSync(agentDir, { recursive: true });
process.env.CLAWDBOT_AGENT_DIR = mainDir;
process.env.PI_CODING_AGENT_DIR = mainDir;
process.env.HOME = tempHome;
fs.writeFileSync(
path.join(mainDir, "auth-profiles.json"),
`${JSON.stringify(
{
version: AUTH_STORE_VERSION,
profiles: {
[CODEX_CLI_PROFILE_ID]: {
type: "oauth",
provider: "openai-codex",
access: "shared-access-token",
refresh: "shared-refresh-token",
expires: Date.now() + 3600000,
},
},
},
null,
2,
)}\n`,
"utf8",
);
fs.writeFileSync(
path.join(agentDir, "auth-profiles.json"),
`${JSON.stringify(
{
version: AUTH_STORE_VERSION,
profiles: {
"openai-codex:my-custom-profile": {
type: "oauth",
provider: "openai-codex",
access: "shared-access-token",
refresh: "shared-refresh-token",
expires: Date.now() + 3600000,
},
},
},
null,
2,
)}\n`,
"utf8",
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined();
expect(store.profiles["openai-codex:my-custom-profile"]).toBeDefined();
} finally {
if (previousAgentDir === undefined) {
delete process.env.CLAWDBOT_AGENT_DIR;
} else {
process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
}
if (previousPiAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
}
fs.rmSync(root, { recursive: true, force: true });
}
});
});
});

View File

@@ -1,102 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { CLAUDE_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js";
describe("external CLI credential sync", () => {
it("does not overwrite API keys when syncing external CLI creds", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-no-overwrite-"));
try {
await withTempHome(
async (tempHome) => {
// Create Claude Code CLI credentials
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
const claudeCreds = {
claudeAiOauth: {
accessToken: "cli-access",
refreshToken: "cli-refresh",
expiresAt: Date.now() + 30 * 60 * 1000,
},
};
fs.writeFileSync(path.join(claudeDir, ".credentials.json"), JSON.stringify(claudeCreds));
// Create auth-profiles.json with an API key
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-store",
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
// Should keep the store's API key and still add the CLI profile.
expect((store.profiles["anthropic:default"] as { key: string }).key).toBe("sk-store");
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("prefers oauth over token even if token has later expiry (oauth enables auto-refresh)", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-oauth-preferred-"));
try {
await withTempHome(
async (tempHome) => {
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
// CLI has OAuth credentials (with refresh token) 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 token credentials expiring in 60 min (later than CLI)
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
[CLAUDE_CLI_PROFILE_ID]: {
type: "token",
provider: "anthropic",
token: "store-token-access",
expires: Date.now() + 60 * 60 * 1000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
// OAuth should be preferred over token because it can auto-refresh
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
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 });
}
});
});

View File

@@ -1,106 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { CLAUDE_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js";
describe("external CLI credential sync", () => {
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-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
});

View File

@@ -1,166 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js";
describe("external CLI credential sync", () => {
it("skips codex-cli sync when credentials already exist in another openai-codex profile", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-dedup-skip-"));
try {
await withTempHome(
async (tempHome) => {
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(
codexAuthPath,
JSON.stringify({
tokens: {
access_token: "shared-access-token",
refresh_token: "shared-refresh-token",
},
}),
);
fs.utimesSync(codexAuthPath, new Date(), new Date());
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"openai-codex:my-custom-profile": {
type: "oauth",
provider: "openai-codex",
access: "shared-access-token",
refresh: "shared-refresh-token",
expires: Date.now() + 3600000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined();
expect(store.profiles["openai-codex:my-custom-profile"]).toBeDefined();
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("creates codex-cli profile when credentials differ from existing openai-codex profiles", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-dedup-create-"));
try {
await withTempHome(
async (tempHome) => {
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(
codexAuthPath,
JSON.stringify({
tokens: {
access_token: "unique-access-token",
refresh_token: "unique-refresh-token",
},
}),
);
fs.utimesSync(codexAuthPath, new Date(), new Date());
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"openai-codex:my-custom-profile": {
type: "oauth",
provider: "openai-codex",
access: "different-access-token",
refresh: "different-refresh-token",
expires: Date.now() + 3600000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeDefined();
expect((store.profiles[CODEX_CLI_PROFILE_ID] as { access: string }).access).toBe(
"unique-access-token",
);
expect(store.profiles["openai-codex:my-custom-profile"]).toBeDefined();
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("removes codex-cli profile when it duplicates another openai-codex profile", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-dedup-remove-"));
try {
await withTempHome(
async (tempHome) => {
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(
codexAuthPath,
JSON.stringify({
tokens: {
access_token: "shared-access-token",
refresh_token: "shared-refresh-token",
},
}),
);
fs.utimesSync(codexAuthPath, new Date(), new Date());
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
[CODEX_CLI_PROFILE_ID]: {
type: "oauth",
provider: "openai-codex",
access: "shared-access-token",
refresh: "shared-refresh-token",
expires: Date.now() + 3600000,
},
"openai-codex:my-custom-profile": {
type: "oauth",
provider: "openai-codex",
access: "shared-access-token",
refresh: "shared-refresh-token",
expires: Date.now() + 3600000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined();
const saved = JSON.parse(fs.readFileSync(authPath, "utf8")) as {
profiles?: Record<string, unknown>;
};
expect(saved.profiles?.[CODEX_CLI_PROFILE_ID]).toBeUndefined();
expect(saved.profiles?.["openai-codex:my-custom-profile"]).toBeDefined();
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
});

View File

@@ -1,96 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { CLAUDE_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js";
describe("external CLI credential sync", () => {
it("syncs Claude Code CLI OAuth credentials into anthropic:claude-cli", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-sync-"));
try {
// Create a temp home with Claude Code CLI credentials
await withTempHome(
async (tempHome) => {
// Create Claude Code CLI credentials with refreshToken (OAuth)
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
const claudeCreds = {
claudeAiOauth: {
accessToken: "fresh-access-token",
refreshToken: "fresh-refresh-token",
expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour from now
},
};
fs.writeFileSync(path.join(claudeDir, ".credentials.json"), JSON.stringify(claudeCreds));
// Create empty auth-profiles.json
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
},
}),
);
// Load the store - should sync from CLI as OAuth credential
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles["anthropic:default"]).toBeDefined();
expect((store.profiles["anthropic:default"] as { key: string }).key).toBe("sk-default");
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
// Should be stored as OAuth credential (type: "oauth") for auto-refresh
const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
expect(cliProfile.type).toBe("oauth");
expect((cliProfile as { access: string }).access).toBe("fresh-access-token");
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 Code 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 Code 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 });
}
});
});

View File

@@ -1,56 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js";
describe("external CLI credential sync", () => {
it("updates codex-cli profile when Codex CLI refresh token changes", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-refresh-sync-"));
try {
await withTempHome(
async (tempHome) => {
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(
codexAuthPath,
JSON.stringify({
tokens: {
access_token: "same-access",
refresh_token: "new-refresh",
},
}),
);
fs.utimesSync(codexAuthPath, new Date(), new Date());
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
[CODEX_CLI_PROFILE_ID]: {
type: "oauth",
provider: "openai-codex",
access: "same-access",
refresh: "old-refresh",
expires: Date.now() - 1000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect((store.profiles[CODEX_CLI_PROFILE_ID] as { refresh: string }).refresh).toBe(
"new-refresh",
);
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
});

View File

@@ -1,103 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
} from "./auth-profiles.js";
describe("external CLI credential sync", () => {
it("upgrades token to oauth when Claude Code CLI gets refreshToken", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-upgrade-"));
try {
await withTempHome(
async (tempHome) => {
// Create Claude Code 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-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("syncs Codex CLI credentials into openai-codex:codex-cli", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-sync-"));
try {
await withTempHome(
async (tempHome) => {
// Create Codex CLI credentials
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexCreds = {
tokens: {
access_token: "codex-access-token",
refresh_token: "codex-refresh-token",
},
};
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(codexAuthPath, JSON.stringify(codexCreds));
// Create empty auth-profiles.json
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeDefined();
expect((store.profiles[CODEX_CLI_PROFILE_ID] as { access: string }).access).toBe(
"codex-access-token",
);
},
{ prefix: "clawdbot-home-" },
);
} finally {
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
});

View File

@@ -1,22 +1,11 @@
import { readQwenCliCredentialsCached } from "../cli-credentials.js";
import {
readClaudeCliCredentialsCached,
readCodexCliCredentialsCached,
readQwenCliCredentialsCached,
} from "../cli-credentials.js";
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
EXTERNAL_CLI_NEAR_EXPIRY_MS,
EXTERNAL_CLI_SYNC_TTL_MS,
QWEN_CLI_PROFILE_ID,
log,
} from "./constants.js";
import type {
AuthProfileCredential,
AuthProfileStore,
OAuthCredential,
TokenCredential,
} from "./types.js";
import type { AuthProfileCredential, AuthProfileStore, OAuthCredential } from "./types.js";
function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCredential): boolean {
if (!a) return false;
@@ -33,25 +22,10 @@ function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCr
);
}
function shallowEqualTokenCredentials(a: TokenCredential | undefined, b: TokenCredential): boolean {
if (!a) return false;
if (a.type !== "token") return false;
return (
a.provider === b.provider &&
a.token === b.token &&
a.expires === b.expires &&
a.email === b.email
);
}
function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: number): boolean {
if (!cred) return false;
if (cred.type !== "oauth" && cred.type !== "token") return false;
if (
cred.provider !== "anthropic" &&
cred.provider !== "openai-codex" &&
cred.provider !== "qwen-portal"
) {
if (cred.provider !== "qwen-portal") {
return false;
}
if (typeof cred.expires !== "number") return true;
@@ -59,163 +33,14 @@ function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: nu
}
/**
* Find any existing openai-codex profile (other than codex-cli) that has the same
* access and refresh tokens. This prevents creating a duplicate codex-cli profile
* when the user has already set up a custom profile with the same credentials.
*/
export function findDuplicateCodexProfile(
store: AuthProfileStore,
creds: OAuthCredential,
): string | undefined {
for (const [profileId, profile] of Object.entries(store.profiles)) {
if (profileId === CODEX_CLI_PROFILE_ID) continue;
if (profile.type !== "oauth") continue;
if (profile.provider !== "openai-codex") continue;
if (profile.access === creds.access && profile.refresh === creds.refresh) {
return profileId;
}
}
return undefined;
}
/**
* Sync OAuth credentials from external CLI tools (Claude Code CLI, Codex CLI) into the store.
* This allows clawdbot to use the same credentials as these tools without requiring
* separate authentication, and keeps credentials in sync when CLI tools refresh tokens.
* Sync OAuth credentials from external CLI tools (Qwen Code CLI) into the store.
*
* Returns true if any credentials were updated.
*/
export function syncExternalCliCredentials(
store: AuthProfileStore,
options?: { allowKeychainPrompt?: boolean },
): boolean {
export function syncExternalCliCredentials(store: AuthProfileStore): boolean {
let mutated = false;
const now = Date.now();
// Sync from Claude Code CLI (supports both OAuth and Token credentials)
const existingClaude = store.profiles[CLAUDE_CLI_PROFILE_ID];
const shouldSyncClaude =
!existingClaude ||
existingClaude.provider !== "anthropic" ||
existingClaude.type === "token" ||
!isExternalProfileFresh(existingClaude, now);
const claudeCreds = shouldSyncClaude
? readClaudeCliCredentialsCached({
allowKeychainPrompt: options?.allowKeychainPrompt,
ttlMs: EXTERNAL_CLI_SYNC_TTL_MS,
})
: null;
if (claudeCreds) {
const existing = store.profiles[CLAUDE_CLI_PROFILE_ID];
const claudeCredsExpires = claudeCreds.expires ?? 0;
// Determine if we should update based on credential comparison
let shouldUpdate = false;
let isEqual = false;
if (claudeCreds.type === "oauth") {
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
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;
mutated = true;
log.info("synced anthropic credentials from claude cli", {
profileId: CLAUDE_CLI_PROFILE_ID,
type: claudeCreds.type,
expires:
typeof claudeCreds.expires === "number"
? new Date(claudeCreds.expires).toISOString()
: "unknown",
});
}
}
// Sync from Codex CLI
const existingCodex = store.profiles[CODEX_CLI_PROFILE_ID];
const existingCodexOAuth = existingCodex?.type === "oauth" ? existingCodex : undefined;
const duplicateExistingId = existingCodexOAuth
? findDuplicateCodexProfile(store, existingCodexOAuth)
: undefined;
if (duplicateExistingId) {
delete store.profiles[CODEX_CLI_PROFILE_ID];
mutated = true;
log.info("removed codex-cli profile: credentials already exist in another profile", {
existingProfileId: duplicateExistingId,
removedProfileId: CODEX_CLI_PROFILE_ID,
});
}
const shouldSyncCodex =
!existingCodex ||
existingCodex.provider !== "openai-codex" ||
!isExternalProfileFresh(existingCodex, now);
const codexCreds =
shouldSyncCodex || duplicateExistingId
? readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS })
: null;
if (codexCreds) {
const duplicateProfileId = findDuplicateCodexProfile(store, codexCreds);
if (duplicateProfileId) {
if (store.profiles[CODEX_CLI_PROFILE_ID]) {
delete store.profiles[CODEX_CLI_PROFILE_ID];
mutated = true;
log.info("removed codex-cli profile: credentials already exist in another profile", {
existingProfileId: duplicateProfileId,
removedProfileId: CODEX_CLI_PROFILE_ID,
});
}
} else {
const existing = store.profiles[CODEX_CLI_PROFILE_ID];
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
// Codex creds don't carry expiry; use file mtime heuristic for freshness.
const shouldUpdate =
!existingOAuth ||
existingOAuth.provider !== "openai-codex" ||
existingOAuth.expires <= now ||
codexCreds.expires > existingOAuth.expires;
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, codexCreds)) {
store.profiles[CODEX_CLI_PROFILE_ID] = codexCreds;
mutated = true;
log.info("synced openai-codex credentials from codex cli", {
profileId: CODEX_CLI_PROFILE_ID,
expires: new Date(codexCreds.expires).toISOString(),
});
}
}
}
// Sync from Qwen Code CLI
const existingQwen = store.profiles[QWEN_CLI_PROFILE_ID];
const shouldSyncQwen =

View File

@@ -4,8 +4,7 @@ import lockfile from "proper-lockfile";
import type { ClawdbotConfig } from "../../config/config.js";
import { refreshChutesTokens } from "../chutes-oauth.js";
import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js";
import { writeClaudeCliCredentials } from "../cli-credentials.js";
import { AUTH_STORE_LOCK_OPTIONS, CLAUDE_CLI_PROFILE_ID } from "./constants.js";
import { AUTH_STORE_LOCK_OPTIONS } from "./constants.js";
import { formatAuthDoctorHint } from "./doctor.js";
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
@@ -72,12 +71,6 @@ async function refreshOAuthTokenWithLock(params: {
};
saveAuthProfileStore(store, params.agentDir);
// Sync refreshed credentials back to Claude Code 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;
} finally {
if (release) {

View File

@@ -3,13 +3,8 @@ import type { OAuthCredentials } from "@mariozechner/pi-ai";
import lockfile from "proper-lockfile";
import { resolveOAuthPath } from "../../config/paths.js";
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
import {
AUTH_STORE_LOCK_OPTIONS,
AUTH_STORE_VERSION,
CODEX_CLI_PROFILE_ID,
log,
} from "./constants.js";
import { findDuplicateCodexProfile, syncExternalCliCredentials } from "./external-cli-sync.js";
import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION, log } from "./constants.js";
import { syncExternalCliCredentials } from "./external-cli-sync.js";
import { ensureAuthStoreFile, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js";
import type { AuthProfileCredential, AuthProfileStore, ProfileUsageStats } from "./types.js";
@@ -229,14 +224,14 @@ export function loadAuthProfileStore(): AuthProfileStore {
function loadAuthProfileStoreForAgent(
agentDir?: string,
options?: { allowKeychainPrompt?: boolean },
_options?: { allowKeychainPrompt?: boolean },
): AuthProfileStore {
const authPath = resolveAuthStorePath(agentDir);
const raw = loadJsonFile(authPath);
const asStore = coerceAuthStore(raw);
if (asStore) {
// Sync from external CLI tools on every load
const synced = syncExternalCliCredentials(asStore, options);
const synced = syncExternalCliCredentials(asStore);
if (synced) {
saveJsonFile(authPath, asStore);
}
@@ -297,7 +292,7 @@ function loadAuthProfileStoreForAgent(
}
const mergedOAuth = mergeOAuthFileIntoStore(store);
const syncedCli = syncExternalCliCredentials(store, options);
const syncedCli = syncExternalCliCredentials(store);
const shouldWrite = legacy !== null || mergedOAuth || syncedCli;
if (shouldWrite) {
saveJsonFile(authPath, store);
@@ -337,15 +332,6 @@ export function ensureAuthProfileStore(
const mainStore = loadAuthProfileStoreForAgent(undefined, options);
const merged = mergeAuthProfileStores(mainStore, store);
// Keep per-agent view clean even if the main store has codex-cli.
const codexProfile = merged.profiles[CODEX_CLI_PROFILE_ID];
if (codexProfile?.type === "oauth") {
const duplicateId = findDuplicateCodexProfile(merged, codexProfile);
if (duplicateId) {
delete merged.profiles[CODEX_CLI_PROFILE_ID];
}
}
return merged;
}

View File

@@ -101,7 +101,7 @@ describe("runWithModelFallback", () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(new Error('No credentials found for profile "anthropic:claude-cli".'))
.mockRejectedValueOnce(new Error('No credentials found for profile "anthropic:default".'))
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({

View File

@@ -12,7 +12,7 @@ const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstr
describe("isAuthErrorMessage", () => {
it("matches credential validation errors", () => {
const samples = [
'No credentials found for profile "anthropic:claude-cli".',
'No credentials found for profile "anthropic:default".',
"No API key found for profile openai.",
];
for (const sample of samples) {

View File

@@ -389,7 +389,7 @@ export function registerModelsCli(program: Command) {
.description("Set per-agent auth order override (locks rotation to this list)")
.requiredOption("--provider <name>", "Provider id (e.g. anthropic)")
.option("--agent <id>", "Agent id (default: configured default agent)")
.argument("<profileIds...>", "Auth profile ids (e.g. anthropic:claude-cli)")
.argument("<profileIds...>", "Auth profile ids (e.g. anthropic:default)")
.action(async (profileIds: string[], opts) => {
await runModelsCommand(async () => {
await modelsAuthOrderSetCommand(

View File

@@ -52,7 +52,7 @@ export function registerOnboardCommand(program: Command) {
.option("--mode <mode>", "Wizard mode: local|remote")
.option(
"--auth-choice <choice>",
"Auth: setup-token|claude-cli|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|venice-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
"Auth: setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
)
.option(
"--token-provider <id>",

View File

@@ -87,16 +87,23 @@ export function registerSecurityCli(program: Command) {
lines.push(muted(` ${shortenHomeInString(change)}`));
}
for (const action of fixResult.actions) {
const mode = action.mode.toString(8).padStart(3, "0");
if (action.ok) lines.push(muted(` chmod ${mode} ${shortenHomePath(action.path)}`));
else if (action.skipped)
lines.push(
muted(` skip chmod ${mode} ${shortenHomePath(action.path)} (${action.skipped})`),
);
else if (action.error)
lines.push(
muted(` chmod ${mode} ${shortenHomePath(action.path)} failed: ${action.error}`),
);
if (action.kind === "chmod") {
const mode = action.mode.toString(8).padStart(3, "0");
if (action.ok) lines.push(muted(` chmod ${mode} ${shortenHomePath(action.path)}`));
else if (action.skipped)
lines.push(
muted(` skip chmod ${mode} ${shortenHomePath(action.path)} (${action.skipped})`),
);
else if (action.error)
lines.push(
muted(` chmod ${mode} ${shortenHomePath(action.path)} failed: ${action.error}`),
);
continue;
}
const command = shortenHomeInString(action.command);
if (action.ok) lines.push(muted(` ${command}`));
else if (action.skipped) lines.push(muted(` skip ${command} (${action.skipped})`));
else if (action.error) lines.push(muted(` ${command} failed: ${action.error}`));
}
if (fixResult.errors.length > 0) {
for (const err of fixResult.errors) {

View File

@@ -258,7 +258,6 @@ export async function agentsAddCommand(
prompter,
store: authStore,
includeSkip: true,
includeClaudeCliIfMissing: true,
});
const authResult = await applyAuthChoice({

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { type AuthProfileStore, CLAUDE_CLI_PROFILE_ID } from "../agents/auth-profiles.js";
import type { AuthProfileStore } from "../agents/auth-profiles.js";
import { buildAuthChoiceOptions } from "./auth-choice-options.js";
describe("buildAuthChoiceOptions", () => {
@@ -9,60 +9,18 @@ describe("buildAuthChoiceOptions", () => {
const options = buildAuthChoiceOptions({
store,
includeSkip: false,
includeClaudeCliIfMissing: false,
platform: "linux",
});
expect(options.find((opt) => opt.value === "github-copilot")).toBeDefined();
});
it("includes Claude Code CLI option on macOS even when missing", () => {
it("includes setup-token option for Anthropic", () => {
const store: AuthProfileStore = { version: 1, profiles: {} };
const options = buildAuthChoiceOptions({
store,
includeSkip: false,
includeClaudeCliIfMissing: true,
platform: "darwin",
});
const claudeCli = options.find((opt) => opt.value === "claude-cli");
expect(claudeCli).toBeDefined();
expect(claudeCli?.hint).toBe("reuses existing Claude Code auth · requires Keychain access");
});
it("skips missing Claude Code CLI option off macOS", () => {
const store: AuthProfileStore = { version: 1, profiles: {} };
const options = buildAuthChoiceOptions({
store,
includeSkip: false,
includeClaudeCliIfMissing: true,
platform: "linux",
});
expect(options.find((opt) => opt.value === "claude-cli")).toBeUndefined();
});
it("uses token hint when Claude Code CLI credentials exist", () => {
const store: AuthProfileStore = {
version: 1,
profiles: {
[CLAUDE_CLI_PROFILE_ID]: {
type: "token",
provider: "anthropic",
token: "token",
expires: Date.now() + 60 * 60 * 1000,
},
},
};
const options = buildAuthChoiceOptions({
store,
includeSkip: false,
includeClaudeCliIfMissing: true,
platform: "darwin",
});
const claudeCli = options.find((opt) => opt.value === "claude-cli");
expect(claudeCli?.hint).toContain("token ok");
expect(options.some((opt) => opt.value === "token")).toBe(true);
});
it("includes Z.AI (GLM) auth choice", () => {
@@ -70,8 +28,6 @@ describe("buildAuthChoiceOptions", () => {
const options = buildAuthChoiceOptions({
store,
includeSkip: false,
includeClaudeCliIfMissing: true,
platform: "darwin",
});
expect(options.some((opt) => opt.value === "zai-api-key")).toBe(true);
@@ -82,8 +38,6 @@ describe("buildAuthChoiceOptions", () => {
const options = buildAuthChoiceOptions({
store,
includeSkip: false,
includeClaudeCliIfMissing: true,
platform: "darwin",
});
expect(options.some((opt) => opt.value === "minimax-api")).toBe(true);
@@ -95,8 +49,6 @@ describe("buildAuthChoiceOptions", () => {
const options = buildAuthChoiceOptions({
store,
includeSkip: false,
includeClaudeCliIfMissing: true,
platform: "darwin",
});
expect(options.some((opt) => opt.value === "moonshot-api-key")).toBe(true);
@@ -108,8 +60,6 @@ describe("buildAuthChoiceOptions", () => {
const options = buildAuthChoiceOptions({
store,
includeSkip: false,
includeClaudeCliIfMissing: true,
platform: "darwin",
});
expect(options.some((opt) => opt.value === "ai-gateway-api-key")).toBe(true);
@@ -120,8 +70,6 @@ describe("buildAuthChoiceOptions", () => {
const options = buildAuthChoiceOptions({
store,
includeSkip: false,
includeClaudeCliIfMissing: true,
platform: "darwin",
});
expect(options.some((opt) => opt.value === "synthetic-api-key")).toBe(true);
@@ -132,8 +80,6 @@ describe("buildAuthChoiceOptions", () => {
const options = buildAuthChoiceOptions({
store,
includeSkip: false,
includeClaudeCliIfMissing: true,
platform: "darwin",
});
expect(options.some((opt) => opt.value === "chutes")).toBe(true);
@@ -144,8 +90,6 @@ describe("buildAuthChoiceOptions", () => {
const options = buildAuthChoiceOptions({
store,
includeSkip: false,
includeClaudeCliIfMissing: true,
platform: "darwin",
});
expect(options.some((opt) => opt.value === "qwen-portal")).toBe(true);

View File

@@ -1,6 +1,4 @@
import type { AuthProfileStore } from "../agents/auth-profiles.js";
import { CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID } from "../agents/auth-profiles.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import type { AuthChoice } from "./onboard-types.js";
export type AuthChoiceOption = {
@@ -41,13 +39,13 @@ const AUTH_CHOICE_GROUP_DEFS: {
value: "openai",
label: "OpenAI",
hint: "Codex OAuth + API key",
choices: ["codex-cli", "openai-codex", "openai-api-key"],
choices: ["openai-codex", "openai-api-key"],
},
{
value: "anthropic",
label: "Anthropic",
hint: "Claude Code CLI + API key",
choices: ["token", "claude-cli", "apiKey"],
hint: "setup-token + API key",
choices: ["token", "apiKey"],
},
{
value: "minimax",
@@ -117,65 +115,12 @@ const AUTH_CHOICE_GROUP_DEFS: {
},
];
function formatOAuthHint(expires?: number, opts?: { allowStale?: boolean }): string {
const rich = isRich();
if (!expires) {
return colorize(rich, theme.muted, "token unavailable");
}
const now = Date.now();
const remaining = expires - now;
if (remaining <= 0) {
if (opts?.allowStale) {
return colorize(rich, theme.warn, "token present · refresh on use");
}
return colorize(rich, theme.error, "token expired");
}
const minutes = Math.round(remaining / (60 * 1000));
const duration =
minutes >= 120
? `${Math.round(minutes / 60)}h`
: minutes >= 60
? "1h"
: `${Math.max(minutes, 1)}m`;
const label = `token ok · expires in ${duration}`;
if (minutes <= 10) {
return colorize(rich, theme.warn, label);
}
return colorize(rich, theme.success, label);
}
export function buildAuthChoiceOptions(params: {
store: AuthProfileStore;
includeSkip: boolean;
includeClaudeCliIfMissing?: boolean;
platform?: NodeJS.Platform;
}): AuthChoiceOption[] {
void params.store;
const options: AuthChoiceOption[] = [];
const platform = params.platform ?? process.platform;
const codexCli = params.store.profiles[CODEX_CLI_PROFILE_ID];
if (codexCli?.type === "oauth") {
options.push({
value: "codex-cli",
label: "OpenAI Codex OAuth (Codex CLI)",
hint: formatOAuthHint(codexCli.expires, { allowStale: true }),
});
}
const claudeCli = params.store.profiles[CLAUDE_CLI_PROFILE_ID];
if (claudeCli?.type === "oauth" || claudeCli?.type === "token") {
options.push({
value: "claude-cli",
label: "Anthropic token (Claude Code CLI)",
hint: `reuses existing Claude Code auth · ${formatOAuthHint(claudeCli.expires)}`,
});
} else if (params.includeClaudeCliIfMissing && platform === "darwin") {
options.push({
value: "claude-cli",
label: "Anthropic token (Claude Code CLI)",
hint: "reuses existing Claude Code auth · requires Keychain access",
});
}
options.push({
value: "token",
@@ -245,12 +190,7 @@ export function buildAuthChoiceOptions(params: {
return options;
}
export function buildAuthChoiceGroups(params: {
store: AuthProfileStore;
includeSkip: boolean;
includeClaudeCliIfMissing?: boolean;
platform?: NodeJS.Platform;
}): {
export function buildAuthChoiceGroups(params: { store: AuthProfileStore; includeSkip: boolean }): {
groups: AuthChoiceGroup[];
skipOption?: AuthChoiceOption;
} {

View File

@@ -9,8 +9,6 @@ export async function promptAuthChoiceGrouped(params: {
prompter: WizardPrompter;
store: AuthProfileStore;
includeSkip: boolean;
includeClaudeCliIfMissing?: boolean;
platform?: NodeJS.Platform;
}): Promise<AuthChoice> {
const { groups, skipOption } = buildAuthChoiceGroups(params);
const availableGroups = groups.filter((group) => group.options.length > 0);

View File

@@ -1,8 +1,4 @@
import {
CLAUDE_CLI_PROFILE_ID,
ensureAuthProfileStore,
upsertAuthProfile,
} from "../agents/auth-profiles.js";
import { upsertAuthProfile } from "../agents/auth-profiles.js";
import {
formatApiKeyPreview,
normalizeApiKeyInput,
@@ -15,153 +11,17 @@ import { applyAuthProfileConfig, setAnthropicApiKey } from "./onboard-auth.js";
export async function applyAuthChoiceAnthropic(
params: ApplyAuthChoiceParams,
): Promise<ApplyAuthChoiceResult | null> {
if (params.authChoice === "claude-cli") {
if (
params.authChoice === "setup-token" ||
params.authChoice === "oauth" ||
params.authChoice === "token"
) {
let nextConfig = params.config;
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
const hasClaudeCli = Boolean(store.profiles[CLAUDE_CLI_PROFILE_ID]);
if (!hasClaudeCli && process.platform === "darwin") {
await params.prompter.note(
[
"macOS will show a Keychain prompt next.",
'Choose "Always Allow" so the launchd gateway can start without prompts.',
'If you choose "Allow" or "Deny", each restart will block on a Keychain alert.',
].join("\n"),
"Claude Code CLI Keychain",
);
const proceed = await params.prompter.confirm({
message: "Check Keychain for Claude Code CLI credentials now?",
initialValue: true,
});
if (!proceed) return { config: nextConfig };
}
const storeWithKeychain = hasClaudeCli
? store
: ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: true,
});
if (!storeWithKeychain.profiles[CLAUDE_CLI_PROFILE_ID]) {
if (process.stdin.isTTY) {
const runNow = await params.prompter.confirm({
message: "Run `claude setup-token` now?",
initialValue: true,
});
if (runNow) {
const res = await (async () => {
const { spawnSync } = await import("node:child_process");
return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
})();
if (res.error) {
await params.prompter.note(
`Failed to run claude: ${String(res.error)}`,
"Claude setup-token",
);
}
}
} else {
await params.prompter.note(
"`claude setup-token` requires an interactive TTY.",
"Claude setup-token",
);
}
const refreshed = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: true,
});
if (!refreshed.profiles[CLAUDE_CLI_PROFILE_ID]) {
await params.prompter.note(
process.platform === "darwin"
? 'No Claude Code CLI credentials found in Keychain ("Claude Code-credentials") or ~/.claude/.credentials.json.'
: "No Claude Code CLI credentials found at ~/.claude/.credentials.json.",
"Claude Code CLI OAuth",
);
return { config: nextConfig };
}
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "oauth",
});
return { config: nextConfig };
}
if (params.authChoice === "setup-token" || params.authChoice === "oauth") {
let nextConfig = params.config;
await params.prompter.note(
[
"This will run `claude setup-token` to create a long-lived Anthropic token.",
"Requires an interactive TTY and a Claude Pro/Max subscription.",
].join("\n"),
"Anthropic setup-token",
);
if (!process.stdin.isTTY) {
await params.prompter.note(
"`claude setup-token` requires an interactive TTY.",
"Anthropic setup-token",
);
return { config: nextConfig };
}
const proceed = await params.prompter.confirm({
message: "Run `claude setup-token` now?",
initialValue: true,
});
if (!proceed) return { config: nextConfig };
const res = await (async () => {
const { spawnSync } = await import("node:child_process");
return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
})();
if (res.error) {
await params.prompter.note(
`Failed to run claude: ${String(res.error)}`,
"Anthropic setup-token",
);
return { config: nextConfig };
}
if (typeof res.status === "number" && res.status !== 0) {
await params.prompter.note(
`claude setup-token failed (exit ${res.status})`,
"Anthropic setup-token",
);
return { config: nextConfig };
}
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: true,
});
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
await params.prompter.note(
`No Claude Code CLI credentials found after setup-token. Expected ${CLAUDE_CLI_PROFILE_ID}.`,
"Anthropic setup-token",
);
return { config: nextConfig };
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "oauth",
});
return { config: nextConfig };
}
if (params.authChoice === "token") {
let nextConfig = params.config;
const provider = (await params.prompter.select({
message: "Token provider",
options: [{ value: "anthropic", label: "Anthropic (only supported)" }],
})) as "anthropic";
await params.prompter.note(
["Run `claude setup-token` in your terminal.", "Then paste the generated token below."].join(
"\n",
),
"Anthropic token",
"Anthropic setup-token",
);
const tokenRaw = await params.prompter.text({
@@ -174,6 +34,7 @@ export async function applyAuthChoiceAnthropic(
message: "Token name (blank = default)",
placeholder: "default",
});
const provider = "anthropic";
const namedProfileId = buildTokenProfileId({
provider,
name: String(profileNameRaw ?? ""),

View File

@@ -1,5 +1,4 @@
import { loginOpenAICodex } from "@mariozechner/pi-ai";
import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore } from "../agents/auth-profiles.js";
import { resolveEnvApiKey } from "../agents/model-auth.js";
import { upsertSharedEnvVar } from "../infra/env-file.js";
import { isRemoteEnvironment } from "./oauth-env.js";
@@ -146,45 +145,5 @@ export async function applyAuthChoiceOpenAI(
return { config: nextConfig, agentModelOverride };
}
if (params.authChoice === "codex-cli") {
let nextConfig = params.config;
let agentModelOverride: string | undefined;
const noteAgentModel = async (model: string) => {
if (!params.agentId) return;
await params.prompter.note(
`Default model set to ${model} for agent "${params.agentId}".`,
"Model configured",
);
};
const store = ensureAuthProfileStore(params.agentDir);
if (!store.profiles[CODEX_CLI_PROFILE_ID]) {
await params.prompter.note(
"No Codex CLI credentials found at ~/.codex/auth.json.",
"Codex CLI OAuth",
);
return { config: nextConfig, agentModelOverride };
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CODEX_CLI_PROFILE_ID,
provider: "openai-codex",
mode: "oauth",
});
if (params.setDefaultModel) {
const applied = applyOpenAICodexModelDefault(nextConfig);
nextConfig = applied.next;
if (applied.changed) {
await params.prompter.note(
`Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`,
"Model configured",
);
}
} else {
agentModelOverride = OPENAI_CODEX_DEFAULT_MODEL;
await noteAgentModel(OPENAI_CODEX_DEFAULT_MODEL);
}
return { config: nextConfig, agentModelOverride };
}
return null;
}

View File

@@ -244,7 +244,7 @@ describe("channels command", () => {
authMocks.loadAuthProfileStore.mockReturnValue({
version: 1,
profiles: {
"anthropic:claude-cli": {
"anthropic:default": {
type: "oauth",
provider: "anthropic",
access: "token",
@@ -252,7 +252,7 @@ describe("channels command", () => {
expires: 0,
created: 0,
},
"openai-codex:codex-cli": {
"openai-codex:default": {
type: "oauth",
provider: "openai",
access: "token",
@@ -268,8 +268,8 @@ describe("channels command", () => {
auth?: Array<{ id: string }>;
};
const ids = payload.auth?.map((entry) => entry.id) ?? [];
expect(ids).toContain("anthropic:claude-cli");
expect(ids).toContain("openai-codex:codex-cli");
expect(ids).toContain("anthropic:default");
expect(ids).toContain("openai-codex:default");
});
it("stores default account names in accounts when multiple accounts exist", async () => {

View File

@@ -1,8 +1,4 @@
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
loadAuthProfileStore,
} from "../../agents/auth-profiles.js";
import { loadAuthProfileStore } from "../../agents/auth-profiles.js";
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js";
import type { ChannelAccountSnapshot, ChannelPlugin } from "../../channels/plugins/types.js";
@@ -115,7 +111,7 @@ export async function channelsListCommand(
id: profileId,
provider: profile.provider,
type: profile.type,
isExternal: profileId === CLAUDE_CLI_PROFILE_ID || profileId === CODEX_CLI_PROFILE_ID,
isExternal: false,
}));
if (opts.json) {
const usage = includeUsage ? await loadProviderUsageSummary() : undefined;

View File

@@ -47,7 +47,6 @@ export async function promptAuthConfig(
allowKeychainPrompt: false,
}),
includeSkip: true,
includeClaudeCliIfMissing: true,
});
let next = cfg;
@@ -74,10 +73,7 @@ export async function promptAuthConfig(
}
const anthropicOAuth =
authChoice === "claude-cli" ||
authChoice === "setup-token" ||
authChoice === "token" ||
authChoice === "oauth";
authChoice === "setup-token" || authChoice === "token" || authChoice === "oauth";
const allowlistSelection = await promptModelAllowlist({
config: next,

View File

@@ -0,0 +1,109 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { maybeRemoveDeprecatedCliAuthProfiles } from "./doctor-auth.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
let originalAgentDir: string | undefined;
let originalPiAgentDir: string | undefined;
let tempAgentDir: string | undefined;
function makePrompter(confirmValue: boolean): DoctorPrompter {
return {
confirm: vi.fn().mockResolvedValue(confirmValue),
confirmRepair: vi.fn().mockResolvedValue(confirmValue),
confirmAggressive: vi.fn().mockResolvedValue(confirmValue),
confirmSkipInNonInteractive: vi.fn().mockResolvedValue(confirmValue),
select: vi.fn().mockResolvedValue(""),
shouldRepair: confirmValue,
shouldForce: false,
};
}
beforeEach(() => {
originalAgentDir = process.env.CLAWDBOT_AGENT_DIR;
originalPiAgentDir = process.env.PI_CODING_AGENT_DIR;
tempAgentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-"));
process.env.CLAWDBOT_AGENT_DIR = tempAgentDir;
process.env.PI_CODING_AGENT_DIR = tempAgentDir;
});
afterEach(() => {
if (originalAgentDir === undefined) {
delete process.env.CLAWDBOT_AGENT_DIR;
} else {
process.env.CLAWDBOT_AGENT_DIR = originalAgentDir;
}
if (originalPiAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = originalPiAgentDir;
}
if (tempAgentDir) {
fs.rmSync(tempAgentDir, { recursive: true, force: true });
tempAgentDir = undefined;
}
});
describe("maybeRemoveDeprecatedCliAuthProfiles", () => {
it("removes deprecated CLI auth profiles from store + config", async () => {
if (!tempAgentDir) throw new Error("Missing temp agent dir");
const authPath = path.join(tempAgentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
`${JSON.stringify(
{
version: 1,
profiles: {
"anthropic:claude-cli": {
type: "oauth",
provider: "anthropic",
access: "token-a",
refresh: "token-r",
expires: Date.now() + 60_000,
},
"openai-codex:codex-cli": {
type: "oauth",
provider: "openai-codex",
access: "token-b",
refresh: "token-r2",
expires: Date.now() + 60_000,
},
},
},
null,
2,
)}\n`,
"utf8",
);
const cfg = {
auth: {
profiles: {
"anthropic:claude-cli": { provider: "anthropic", mode: "oauth" },
"openai-codex:codex-cli": { provider: "openai-codex", mode: "oauth" },
},
order: {
anthropic: ["anthropic:claude-cli"],
"openai-codex": ["openai-codex:codex-cli"],
},
},
} as const;
const next = await maybeRemoveDeprecatedCliAuthProfiles(cfg, makePrompter(true));
const raw = JSON.parse(fs.readFileSync(authPath, "utf8")) as {
profiles?: Record<string, unknown>;
};
expect(raw.profiles?.["anthropic:claude-cli"]).toBeUndefined();
expect(raw.profiles?.["openai-codex:codex-cli"]).toBeUndefined();
expect(next.auth?.profiles?.["anthropic:claude-cli"]).toBeUndefined();
expect(next.auth?.profiles?.["openai-codex:codex-cli"]).toBeUndefined();
expect(next.auth?.order?.anthropic).toBeUndefined();
expect(next.auth?.order?.["openai-codex"]).toBeUndefined();
});
});

View File

@@ -11,6 +11,7 @@ import {
resolveApiKeyForProfile,
resolveProfileUnusableUntilForDisplay,
} from "../agents/auth-profiles.js";
import { updateAuthProfileStoreWithLock } from "../agents/auth-profiles/store.js";
import type { ClawdbotConfig } from "../config/config.js";
import { note } from "../terminal/note.js";
import { formatCliCommand } from "../cli/command-format.js";
@@ -38,6 +39,148 @@ export async function maybeRepairAnthropicOAuthProfileId(
return repair.config;
}
function pruneAuthOrder(
order: Record<string, string[]> | undefined,
profileIds: Set<string>,
): { next: Record<string, string[]> | undefined; changed: boolean } {
if (!order) return { next: order, changed: false };
let changed = false;
const next: Record<string, string[]> = {};
for (const [provider, list] of Object.entries(order)) {
const filtered = list.filter((id) => !profileIds.has(id));
if (filtered.length !== list.length) changed = true;
if (filtered.length > 0) next[provider] = filtered;
}
return { next: Object.keys(next).length > 0 ? next : undefined, changed };
}
function pruneAuthProfiles(
cfg: ClawdbotConfig,
profileIds: Set<string>,
): { next: ClawdbotConfig; changed: boolean } {
const profiles = cfg.auth?.profiles;
const order = cfg.auth?.order;
const nextProfiles = profiles ? { ...profiles } : undefined;
let changed = false;
if (nextProfiles) {
for (const id of profileIds) {
if (id in nextProfiles) {
delete nextProfiles[id];
changed = true;
}
}
}
const prunedOrder = pruneAuthOrder(order, profileIds);
if (prunedOrder.changed) changed = true;
if (!changed) return { next: cfg, changed: false };
const nextAuth =
nextProfiles || prunedOrder.next
? {
...cfg.auth,
profiles: nextProfiles && Object.keys(nextProfiles).length > 0 ? nextProfiles : undefined,
order: prunedOrder.next,
}
: undefined;
return {
next: {
...cfg,
auth: nextAuth,
},
changed: true,
};
}
export async function maybeRemoveDeprecatedCliAuthProfiles(
cfg: ClawdbotConfig,
prompter: DoctorPrompter,
): Promise<ClawdbotConfig> {
const store = ensureAuthProfileStore(undefined, { allowKeychainPrompt: false });
const deprecated = new Set<string>();
if (store.profiles[CLAUDE_CLI_PROFILE_ID] || cfg.auth?.profiles?.[CLAUDE_CLI_PROFILE_ID]) {
deprecated.add(CLAUDE_CLI_PROFILE_ID);
}
if (store.profiles[CODEX_CLI_PROFILE_ID] || cfg.auth?.profiles?.[CODEX_CLI_PROFILE_ID]) {
deprecated.add(CODEX_CLI_PROFILE_ID);
}
if (deprecated.size === 0) return cfg;
const lines = ["Deprecated external CLI auth profiles detected (no longer supported):"];
if (deprecated.has(CLAUDE_CLI_PROFILE_ID)) {
lines.push(
`- ${CLAUDE_CLI_PROFILE_ID} (Anthropic): use setup-token → ${formatCliCommand("clawdbot models auth setup-token")}`,
);
}
if (deprecated.has(CODEX_CLI_PROFILE_ID)) {
lines.push(
`- ${CODEX_CLI_PROFILE_ID} (OpenAI Codex): use OAuth → ${formatCliCommand(
"clawdbot models auth login --provider openai-codex",
)}`,
);
}
note(lines.join("\n"), "Auth profiles");
const shouldRemove = await prompter.confirmRepair({
message: "Remove deprecated CLI auth profiles now?",
initialValue: true,
});
if (!shouldRemove) return cfg;
await updateAuthProfileStoreWithLock({
updater: (nextStore) => {
let mutated = false;
for (const id of deprecated) {
if (nextStore.profiles[id]) {
delete nextStore.profiles[id];
mutated = true;
}
if (nextStore.usageStats?.[id]) {
delete nextStore.usageStats[id];
mutated = true;
}
}
if (nextStore.order) {
for (const [provider, list] of Object.entries(nextStore.order)) {
const filtered = list.filter((id) => !deprecated.has(id));
if (filtered.length !== list.length) {
mutated = true;
if (filtered.length > 0) {
nextStore.order[provider] = filtered;
} else {
delete nextStore.order[provider];
}
}
}
}
if (nextStore.lastGood) {
for (const [provider, profileId] of Object.entries(nextStore.lastGood)) {
if (deprecated.has(profileId)) {
delete nextStore.lastGood[provider];
mutated = true;
}
}
}
return mutated;
},
});
const pruned = pruneAuthProfiles(cfg, deprecated);
if (pruned.changed) {
note(
Array.from(deprecated.values())
.map((id) => `- removed ${id} from config`)
.join("\n"),
"Doctor changes",
);
}
return pruned.next;
}
type AuthIssue = {
profileId: string;
provider: string;
@@ -47,10 +190,14 @@ type AuthIssue = {
function formatAuthIssueHint(issue: AuthIssue): string | null {
if (issue.provider === "anthropic" && issue.profileId === CLAUDE_CLI_PROFILE_ID) {
return "Run `claude setup-token` on the gateway host.";
return `Deprecated profile. Use ${formatCliCommand("clawdbot models auth setup-token")} or ${formatCliCommand(
"clawdbot configure",
)}.`;
}
if (issue.provider === "openai-codex" && issue.profileId === CODEX_CLI_PROFILE_ID) {
return `Run \`codex login\` (or \`${formatCliCommand("clawdbot configure")}\` → OpenAI Codex OAuth).`;
return `Deprecated profile. Use ${formatCliCommand(
"clawdbot models auth login --provider openai-codex",
)} or ${formatCliCommand("clawdbot configure")}.`;
}
return `Re-auth via \`${formatCliCommand("clawdbot configure")}\` or \`${formatCliCommand("clawdbot onboard")}\`.`;
}

View File

@@ -22,7 +22,11 @@ import { defaultRuntime } from "../runtime.js";
import { note } from "../terminal/note.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import { shortenHomePath } from "../utils.js";
import { maybeRepairAnthropicOAuthProfileId, noteAuthProfileHealth } from "./doctor-auth.js";
import {
maybeRemoveDeprecatedCliAuthProfiles,
maybeRepairAnthropicOAuthProfileId,
noteAuthProfileHealth,
} from "./doctor-auth.js";
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
import { maybeRepairGatewayDaemon } from "./doctor-gateway-daemon-flow.js";
import { checkGatewayHealth } from "./doctor-gateway-health.js";
@@ -104,6 +108,7 @@ export async function doctorCommand(
}
cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter);
cfg = await maybeRemoveDeprecatedCliAuthProfiles(cfg, prompter);
await noteAuthProfileHealth({
cfg,
prompter,

View File

@@ -1,12 +1,6 @@
import { spawnSync } from "node:child_process";
import { confirm as clackConfirm, select as clackSelect, text as clackText } from "@clack/prompts";
import {
CLAUDE_CLI_PROFILE_ID,
ensureAuthProfileStore,
upsertAuthProfile,
} from "../../agents/auth-profiles.js";
import { upsertAuthProfile } from "../../agents/auth-profiles.js";
import { normalizeProviderId } from "../../agents/model-selection.js";
import {
resolveAgentDir,
@@ -33,6 +27,7 @@ import type {
ProviderPlugin,
} from "../../plugins/types.js";
import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js";
import { validateAnthropicSetupToken } from "../auth-token.js";
const confirm = (params: Parameters<typeof clackConfirm>[0]) =>
clackConfirm({
@@ -73,9 +68,7 @@ export async function modelsAuthSetupTokenCommand(
) {
const provider = resolveTokenProvider(opts.provider ?? "anthropic");
if (provider !== "anthropic") {
throw new Error(
"Only --provider anthropic is supported for setup-token (uses `claude setup-token`).",
);
throw new Error("Only --provider anthropic is supported for setup-token.");
}
if (!process.stdin.isTTY) {
@@ -84,38 +77,38 @@ export async function modelsAuthSetupTokenCommand(
if (!opts.yes) {
const proceed = await confirm({
message: "Run `claude setup-token` now?",
message: "Have you run `claude setup-token` and copied the token?",
initialValue: true,
});
if (!proceed) return;
}
const res = spawnSync("claude", ["setup-token"], { stdio: "inherit" });
if (res.error) throw res.error;
if (typeof res.status === "number" && res.status !== 0) {
throw new Error(`claude setup-token failed (exit ${res.status})`);
}
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: true,
const tokenInput = await text({
message: "Paste Anthropic setup-token",
validate: (value) => validateAnthropicSetupToken(String(value ?? "")),
});
const token = String(tokenInput).trim();
const profileId = resolveDefaultTokenProfileId(provider);
upsertAuthProfile({
profileId,
credential: {
type: "token",
provider,
token,
},
});
const synced = store.profiles[CLAUDE_CLI_PROFILE_ID];
if (!synced) {
throw new Error(
`No Claude Code CLI credentials found after setup-token. Expected auth profile ${CLAUDE_CLI_PROFILE_ID}.`,
);
}
await updateConfig((cfg) =>
applyAuthProfileConfig(cfg, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "oauth",
profileId,
provider,
mode: "token",
}),
);
logConfigUpdated(runtime);
runtime.log(`Auth profile: ${CLAUDE_CLI_PROFILE_ID} (anthropic/oauth)`);
runtime.log(`Auth profile: ${profileId} (${provider}/token)`);
}
export async function modelsAuthPasteTokenCommand(
@@ -189,7 +182,7 @@ export async function modelsAuthAddCommand(_opts: Record<string, never>, runtime
{
value: "setup-token",
label: "setup-token (claude)",
hint: "Runs `claude setup-token` (recommended)",
hint: "Paste a setup-token from `claude setup-token`",
},
]
: []),

View File

@@ -487,7 +487,7 @@ export async function modelsStatusCommand(
for (const provider of missingProvidersInUse) {
const hint =
provider === "anthropic"
? `Run \`claude setup-token\` or \`${formatCliCommand("clawdbot configure")}\`.`
? `Run \`claude setup-token\`, then \`${formatCliCommand("clawdbot models auth setup-token")}\` or \`${formatCliCommand("clawdbot configure")}\`.`
: `Run \`${formatCliCommand("clawdbot configure")}\` or set an API key env var.`;
runtime.log(`- ${theme.heading(provider)} ${hint}`);
}
@@ -558,9 +558,7 @@ export async function modelsStatusCommand(
: profile.expiresAt
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
: " expires unknown";
const source =
profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : "";
runtime.log(` - ${label} ${status}${expiry}${source}`);
runtime.log(` - ${label} ${status}${expiry}`);
}
}
}

View File

@@ -154,13 +154,13 @@ describe("applyAuthProfileConfig", () => {
},
},
{
profileId: "anthropic:claude-cli",
profileId: "anthropic:work",
provider: "anthropic",
mode: "oauth",
},
);
expect(next.auth?.order?.anthropic).toEqual(["anthropic:claude-cli", "anthropic:default"]);
expect(next.auth?.order?.anthropic).toEqual(["anthropic:work", "anthropic:default"]);
});
});

View File

@@ -1,9 +1,4 @@
import {
CLAUDE_CLI_PROFILE_ID,
CODEX_CLI_PROFILE_ID,
ensureAuthProfileStore,
upsertAuthProfile,
} from "../../../agents/auth-profiles.js";
import { upsertAuthProfile } from "../../../agents/auth-profiles.js";
import { normalizeProviderId } from "../../../agents/model-selection.js";
import { parseDurationMs } from "../../../cli/parse-duration.js";
import type { ClawdbotConfig } from "../../../config/config.js";
@@ -36,7 +31,6 @@ import {
setZaiApiKey,
} from "../../onboard-auth.js";
import type { AuthChoice, OnboardOptions } from "../../onboard-types.js";
import { applyOpenAICodexModelDefault } from "../../openai-codex-model-default.js";
import { resolveNonInteractiveApiKey } from "../api-keys.js";
import { shortenHomePath } from "../../../utils.js";
@@ -50,6 +44,28 @@ export async function applyNonInteractiveAuthChoice(params: {
const { authChoice, opts, runtime, baseConfig } = params;
let nextConfig = params.nextConfig;
if (authChoice === "claude-cli" || authChoice === "codex-cli") {
runtime.error(
[
`Auth choice "${authChoice}" is deprecated.`,
'Use "--auth-choice token" (Anthropic setup-token) or "--auth-choice openai-codex".',
].join("\n"),
);
runtime.exit(1);
return null;
}
if (authChoice === "setup-token") {
runtime.error(
[
'Auth choice "setup-token" requires interactive mode.',
'Use "--auth-choice token" with --token and --token-provider anthropic.',
].join("\n"),
);
runtime.exit(1);
return null;
}
if (authChoice === "apiKey") {
const resolved = await resolveNonInteractiveApiKey({
provider: "anthropic",
@@ -318,41 +334,6 @@ export async function applyNonInteractiveAuthChoice(params: {
return applyMinimaxApiConfig(nextConfig, modelId);
}
if (authChoice === "claude-cli") {
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false,
});
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
runtime.error(
process.platform === "darwin"
? 'No Claude Code CLI credentials found. Run interactive onboarding to approve Keychain access for "Claude Code-credentials".'
: "No Claude Code CLI credentials found at ~/.claude/.credentials.json",
);
runtime.exit(1);
return null;
}
return applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "oauth",
});
}
if (authChoice === "codex-cli") {
const store = ensureAuthProfileStore();
if (!store.profiles[CODEX_CLI_PROFILE_ID]) {
runtime.error("No Codex CLI credentials found at ~/.codex/auth.json");
runtime.exit(1);
return null;
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CODEX_CLI_PROFILE_ID,
provider: "openai-codex",
mode: "oauth",
});
return applyOpenAICodexModelDefault(nextConfig).next;
}
if (authChoice === "minimax") return applyMinimaxConfig(nextConfig);
if (authChoice === "opencode-zen") {

View File

@@ -12,9 +12,33 @@ import type { OnboardOptions } from "./onboard-types.js";
export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv = defaultRuntime) {
assertSupportedRuntime(runtime);
const authChoice = opts.authChoice === "oauth" ? ("setup-token" as const) : opts.authChoice;
const normalizedAuthChoice =
authChoice === "claude-cli"
? ("setup-token" as const)
: authChoice === "codex-cli"
? ("openai-codex" as const)
: authChoice;
if (opts.nonInteractive && (authChoice === "claude-cli" || authChoice === "codex-cli")) {
runtime.error(
[
`Auth choice "${authChoice}" is deprecated.`,
'Use "--auth-choice token" (Anthropic setup-token) or "--auth-choice openai-codex".',
].join("\n"),
);
runtime.exit(1);
return;
}
if (authChoice === "claude-cli") {
runtime.log('Auth choice "claude-cli" is deprecated; using setup-token flow instead.');
}
if (authChoice === "codex-cli") {
runtime.log('Auth choice "codex-cli" is deprecated; using OpenAI Codex OAuth instead.');
}
const flow = opts.flow === "manual" ? ("advanced" as const) : opts.flow;
const normalizedOpts =
authChoice === opts.authChoice && flow === opts.flow ? opts : { ...opts, authChoice, flow };
normalizedAuthChoice === opts.authChoice && flow === opts.flow
? opts
: { ...opts, authChoice: normalizedAuthChoice, flow };
if (normalizedOpts.nonInteractive && normalizedOpts.acceptRisk !== true) {
runtime.error(

View File

@@ -5,8 +5,8 @@ import { authorizeGatewayConnect } from "./auth.js";
describe("gateway auth", () => {
it("does not throw when req is missing socket", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: false },
connectAuth: null,
auth: { mode: "token", token: "secret", allowTailscale: false },
connectAuth: { token: "secret" },
// Regression: avoid crashing on req.socket.remoteAddress when callers pass a non-IncomingMessage.
req: {} as never,
});
@@ -63,40 +63,10 @@ describe("gateway auth", () => {
expect(res.reason).toBe("password_missing_config");
});
it("reports tailscale auth reasons when required", async () => {
const reqBase = {
socket: { remoteAddress: "100.100.100.100" },
headers: { host: "gateway.local" },
};
const missingUser = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: true },
connectAuth: null,
req: reqBase as never,
});
expect(missingUser.ok).toBe(false);
expect(missingUser.reason).toBe("tailscale_user_missing");
const missingProxy = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: true },
connectAuth: null,
req: {
...reqBase,
headers: {
host: "gateway.local",
"tailscale-user-login": "peter",
"tailscale-user-name": "Peter",
},
} as never,
});
expect(missingProxy.ok).toBe(false);
expect(missingProxy.reason).toBe("tailscale_proxy_missing");
});
it("treats local tailscale serve hostnames as direct", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: true },
connectAuth: null,
auth: { mode: "token", token: "secret", allowTailscale: true },
connectAuth: { token: "secret" },
req: {
socket: { remoteAddress: "127.0.0.1" },
headers: { host: "gateway.tailnet-1234.ts.net:443" },
@@ -104,21 +74,7 @@ describe("gateway auth", () => {
});
expect(res.ok).toBe(true);
expect(res.method).toBe("none");
});
it("does not treat tailscale clients as direct", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: true },
connectAuth: null,
req: {
socket: { remoteAddress: "100.64.0.42" },
headers: { host: "gateway.tailnet-1234.ts.net" },
} as never,
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("tailscale_user_missing");
expect(res.method).toBe("token");
});
it("allows tailscale identity to satisfy token mode auth", async () => {
@@ -143,41 +99,4 @@ describe("gateway auth", () => {
expect(res.method).toBe("tailscale");
expect(res.user).toBe("peter");
});
it("rejects mismatched tailscale identity when required", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: true },
connectAuth: null,
tailscaleWhois: async () => ({ login: "alice@example.com", name: "Alice" }),
req: {
socket: { remoteAddress: "127.0.0.1" },
headers: {
host: "gateway.local",
"x-forwarded-for": "100.64.0.1",
"x-forwarded-proto": "https",
"x-forwarded-host": "ai-hub.bone-egret.ts.net",
"tailscale-user-login": "peter@example.com",
"tailscale-user-name": "Peter",
},
} as never,
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("tailscale_user_mismatch");
});
it("treats trusted proxy loopback clients as direct", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: true },
connectAuth: null,
trustedProxies: ["10.0.0.2"],
req: {
socket: { remoteAddress: "10.0.0.2" },
headers: { host: "localhost", "x-forwarded-for": "127.0.0.1" },
} as never,
});
expect(res.ok).toBe(true);
expect(res.method).toBe("none");
});
});

View File

@@ -3,7 +3,7 @@ import type { IncomingMessage } from "node:http";
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js";
import { isTrustedProxyAddress, parseForwardedForClientIp, resolveGatewayClientIp } from "./net.js";
export type ResolvedGatewayAuthMode = "none" | "token" | "password";
export type ResolvedGatewayAuthMode = "token" | "password";
export type ResolvedGatewayAuth = {
mode: ResolvedGatewayAuthMode;
@@ -14,7 +14,7 @@ export type ResolvedGatewayAuth = {
export type GatewayAuthResult = {
ok: boolean;
method?: "none" | "token" | "password" | "tailscale" | "device-token";
method?: "token" | "password" | "tailscale" | "device-token";
user?: string;
reason?: string;
};
@@ -84,7 +84,7 @@ function resolveRequestClientIp(
});
}
function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: string[]): boolean {
export function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: string[]): boolean {
if (!req) return false;
const clientIp = resolveRequestClientIp(req, trustedProxies) ?? "";
if (!isLoopbackAddress(clientIp)) return false;
@@ -219,13 +219,6 @@ export async function authorizeGatewayConnect(params: {
user: tailscaleCheck.user.login,
};
}
if (auth.mode === "none") {
return { ok: false, reason: tailscaleCheck.reason };
}
}
if (auth.mode === "none") {
return { ok: true, method: "none" };
}
if (auth.mode === "token") {

View File

@@ -181,7 +181,7 @@ describe("gateway e2e", () => {
const port = await getFreeGatewayPort();
const server = await startGatewayServer(port, {
bind: "loopback",
auth: { mode: "none" },
auth: { mode: "token", token: wizardToken },
controlUiEnabled: false,
wizardRunner: async (_opts, _runtime, prompter) => {
await prompter.intro("Wizard E2E");
@@ -197,6 +197,7 @@ describe("gateway e2e", () => {
const client = await connectGatewayClient({
url: `ws://127.0.0.1:${port}`,
token: wizardToken,
clientDisplayName: "vitest-wizard",
});

View File

@@ -122,6 +122,18 @@ describe("gateway server auth/connect", () => {
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
});
test("requires nonce when host is non-local", async () => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`, {
headers: { host: "example.com" },
});
await new Promise<void>((resolve) => ws.once("open", resolve));
const res = await connectReq(ws);
expect(res.ok).toBe(false);
expect(res.error?.message).toBe("device nonce required");
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
});
test(
"invalid connect params surface in response and close reason",
{ timeout: 60_000 },
@@ -290,6 +302,7 @@ describe("gateway server auth/connect", () => {
test("allows control ui with device identity when insecure auth is enabled", async () => {
testState.gatewayControlUi = { allowInsecureAuth: true };
testState.gatewayAuth = { mode: "token", token: "secret" };
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({
gateway: {
@@ -354,6 +367,7 @@ describe("gateway server auth/connect", () => {
test("allows control ui with stale device identity when device auth is disabled", async () => {
testState.gatewayControlUi = { dangerouslyDisableDeviceAuth: true };
testState.gatewayAuth = { mode: "token", token: "secret" };
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
process.env.CLAWDBOT_GATEWAY_TOKEN = "secret";
const port = await getFreePort();
@@ -399,28 +413,6 @@ describe("gateway server auth/connect", () => {
}
});
test("rejects proxied connections without auth when proxy headers are untrusted", async () => {
testState.gatewayAuth = { mode: "none" };
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
const port = await getFreePort();
const server = await startGatewayServer(port);
const ws = new WebSocket(`ws://127.0.0.1:${port}`, {
headers: { "x-forwarded-for": "203.0.113.10" },
});
await new Promise<void>((resolve) => ws.once("open", resolve));
const res = await connectReq(ws, { skipDefaultAuth: true });
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("gateway auth required");
ws.close();
await server.close();
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
});
test("accepts device token auth for paired device", async () => {
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
const { approveDevicePairing, getPairedDevice, listDevicePairing } =

View File

@@ -23,10 +23,10 @@ import { rawDataToString } from "../../../infra/ws.js";
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
import type { ResolvedGatewayAuth } from "../../auth.js";
import { authorizeGatewayConnect } from "../../auth.js";
import { authorizeGatewayConnect, isLocalDirectRequest } from "../../auth.js";
import { loadConfig } from "../../../config/config.js";
import { buildDeviceAuthPayload } from "../../device-auth.js";
import { isLocalGatewayAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js";
import { isLoopbackAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js";
import { resolveNodeCommandAllowlist } from "../../node-command-policy.js";
import {
type ConnectParams,
@@ -60,6 +60,17 @@ type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
const DEVICE_SIGNATURE_SKEW_MS = 10 * 60 * 1000;
function resolveHostName(hostHeader?: string): string {
const host = (hostHeader ?? "").trim().toLowerCase();
if (!host) return "";
if (host.startsWith("[")) {
const end = host.indexOf("]");
if (end !== -1) return host.slice(1, end);
}
const [name] = host.split(":");
return name ?? "";
}
type AuthProvidedKind = "token" | "password" | "none";
function formatGatewayAuthFailureMessage(params: {
@@ -189,8 +200,17 @@ export function attachGatewayWsMessageHandler(params: {
const hasProxyHeaders = Boolean(forwardedFor || realIp);
const remoteIsTrustedProxy = isTrustedProxyAddress(remoteAddr, trustedProxies);
const hasUntrustedProxyHeaders = hasProxyHeaders && !remoteIsTrustedProxy;
const isLocalClient = !hasUntrustedProxyHeaders && isLocalGatewayAddress(clientIp);
const reportedClientIp = hasUntrustedProxyHeaders ? undefined : clientIp;
const hostName = resolveHostName(requestHost);
const hostIsLocal = hostName === "localhost" || hostName === "127.0.0.1" || hostName === "::1";
const hostIsTailscaleServe = hostName.endsWith(".ts.net");
const hostIsLocalish = hostIsLocal || hostIsTailscaleServe;
const isLocalClient = isLocalDirectRequest(upgradeReq, trustedProxies);
const reportedClientIp =
isLocalClient || hasUntrustedProxyHeaders
? undefined
: clientIp && !isLoopbackAddress(clientIp)
? clientIp
: undefined;
if (hasUntrustedProxyHeaders) {
logWsControl.warn(
@@ -199,6 +219,13 @@ export function attachGatewayWsMessageHandler(params: {
"Configure gateway.trustedProxies to restore local client detection behind your proxy.",
);
}
if (!hostIsLocalish && isLoopbackAddress(remoteAddr) && !hasProxyHeaders) {
logWsControl.warn(
"Loopback connection with non-local Host header. " +
"Treating it as remote. If you're behind a reverse proxy, " +
"set gateway.trustedProxies and forward X-Forwarded-For/X-Real-IP.",
);
}
const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client);
@@ -347,32 +374,6 @@ export function attachGatewayWsMessageHandler(params: {
isControlUi && configSnapshot.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true;
const allowControlUiBypass = allowInsecureControlUi || disableControlUiDeviceAuth;
const device = disableControlUiDeviceAuth ? null : deviceRaw;
if (hasUntrustedProxyHeaders && resolvedAuth.mode === "none") {
setHandshakeState("failed");
setCloseCause("proxy-auth-required", {
client: connectParams.client.id,
clientDisplayName: connectParams.client.displayName,
mode: connectParams.client.mode,
version: connectParams.client.version,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(
ErrorCodes.INVALID_REQUEST,
"gateway auth required behind reverse proxy",
{
details: {
hint: "set gateway.auth or configure gateway.trustedProxies",
},
},
),
});
close(1008, "gateway auth required");
return;
}
if (!device) {
const canSkipDevice = allowControlUiBypass ? hasSharedAuth : hasTokenAuth;
@@ -570,7 +571,8 @@ export function attachGatewayWsMessageHandler(params: {
trustedProxies,
});
let authOk = authResult.ok;
let authMethod = authResult.method ?? "none";
let authMethod =
authResult.method ?? (resolvedAuth.mode === "password" ? "password" : "token");
if (!authOk && connectParams.auth?.token && device) {
const tokenCheck = await verifyDeviceToken({
deviceId: device.id,

View File

@@ -260,6 +260,9 @@ export async function startGatewayServer(port: number, opts?: GatewayServerOptio
export async function startServerWithClient(token?: string, opts?: GatewayServerOptions) {
let port = await getFreePort();
const prev = process.env.CLAWDBOT_GATEWAY_TOKEN;
if (typeof token === "string") {
testState.gatewayAuth = { mode: "token", token };
}
const fallbackToken =
token ??
(typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string"

View File

@@ -3,7 +3,6 @@ import os from "node:os";
import path from "node:path";
import {
CLAUDE_CLI_PROFILE_ID,
ensureAuthProfileStore,
listProfilesForProvider,
resolveApiKeyForProfile,
@@ -111,9 +110,7 @@ async function resolveOAuthToken(params: {
provider: params.provider,
});
// Claude Code CLI creds are the only Anthropic tokens that reliably include the
// `user:profile` scope required for the OAuth usage endpoint.
const candidates = params.provider === "anthropic" ? [CLAUDE_CLI_PROFILE_ID, ...order] : order;
const candidates = order;
const deduped: string[] = [];
for (const entry of candidates) {
if (!deduped.includes(entry)) deduped.push(entry);

View File

@@ -335,81 +335,6 @@ describe("provider usage loading", () => {
);
});
it("prefers claude-cli token for Anthropic usage snapshots", async () => {
await withTempHome(
async () => {
const stateDir = process.env.CLAWDBOT_STATE_DIR;
if (!stateDir) throw new Error("Missing CLAWDBOT_STATE_DIR");
const agentDir = path.join(stateDir, "agents", "main", "agent");
fs.mkdirSync(agentDir, { recursive: true, mode: 0o700 });
fs.writeFileSync(
path.join(agentDir, "auth-profiles.json"),
`${JSON.stringify(
{
version: 1,
profiles: {
"anthropic:default": {
type: "token",
provider: "anthropic",
token: "token-default",
expires: Date.UTC(2100, 0, 1, 0, 0, 0),
},
"anthropic:claude-cli": {
type: "token",
provider: "anthropic",
token: "token-cli",
expires: Date.UTC(2100, 0, 1, 0, 0, 0),
},
},
},
null,
2,
)}\n`,
"utf8",
);
const makeResponse = (status: number, body: unknown): Response => {
const payload = typeof body === "string" ? body : JSON.stringify(body);
const headers =
typeof body === "string" ? undefined : { "Content-Type": "application/json" };
return new Response(payload, { status, headers });
};
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(
async (input, init) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
if (url.includes("api.anthropic.com/api/oauth/usage")) {
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers.Authorization).toBe("Bearer token-cli");
return makeResponse(200, {
five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" },
});
}
return makeResponse(404, "not found");
},
);
const summary = await loadProviderUsageSummary({
now: Date.UTC(2026, 0, 7, 0, 0, 0),
providers: ["anthropic"],
agentDir,
fetch: mockFetch,
});
expect(summary.providers).toHaveLength(1);
expect(summary.providers[0]?.provider).toBe("anthropic");
expect(summary.providers[0]?.windows[0]?.label).toBe("5h");
expect(mockFetch).toHaveBeenCalled();
},
{ prefix: "clawdbot-provider-usage-" },
);
});
it("falls back to claude.ai web usage when OAuth scope is missing", async () => {
const cookieSnapshot = process.env.CLAUDE_AI_SESSION_KEY;
process.env.CLAUDE_AI_SESSION_KEY = "sk-ant-web-1";

View File

@@ -22,14 +22,12 @@ import type { SandboxToolPolicy } from "../agents/sandbox/types.js";
import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js";
import { normalizeAgentId } from "../routing/session-key.js";
import {
formatOctal,
isGroupReadable,
isGroupWritable,
isWorldReadable,
isWorldWritable,
modeBits,
formatPermissionDetail,
formatPermissionRemediation,
inspectPathPermissions,
safeStat,
} from "./audit-fs.js";
import type { ExecFn } from "./windows-acl.js";
export type SecurityAuditFinding = {
checkId: string;
@@ -707,6 +705,9 @@ async function collectIncludePathsRecursive(params: {
export async function collectIncludeFilePermFindings(params: {
configSnapshot: ConfigFileSnapshot;
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
execIcacls?: ExecFn;
}): Promise<SecurityAuditFinding[]> {
const findings: SecurityAuditFinding[] = [];
if (!params.configSnapshot.exists) return findings;
@@ -720,32 +721,53 @@ export async function collectIncludeFilePermFindings(params: {
for (const p of includePaths) {
// eslint-disable-next-line no-await-in-loop
const st = await safeStat(p);
if (!st.ok) continue;
const bits = modeBits(st.mode);
if (isWorldWritable(bits) || isGroupWritable(bits)) {
const perms = await inspectPathPermissions(p, {
env: params.env,
platform: params.platform,
exec: params.execIcacls,
});
if (!perms.ok) continue;
if (perms.worldWritable || perms.groupWritable) {
findings.push({
checkId: "fs.config_include.perms_writable",
severity: "critical",
title: "Config include file is writable by others",
detail: `${p} mode=${formatOctal(bits)}; another user could influence your effective config.`,
remediation: `chmod 600 ${p}`,
detail: `${formatPermissionDetail(p, perms)}; another user could influence your effective config.`,
remediation: formatPermissionRemediation({
targetPath: p,
perms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
});
} else if (isWorldReadable(bits)) {
} else if (perms.worldReadable) {
findings.push({
checkId: "fs.config_include.perms_world_readable",
severity: "critical",
title: "Config include file is world-readable",
detail: `${p} mode=${formatOctal(bits)}; include files can contain tokens and private settings.`,
remediation: `chmod 600 ${p}`,
detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`,
remediation: formatPermissionRemediation({
targetPath: p,
perms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
});
} else if (isGroupReadable(bits)) {
} else if (perms.groupReadable) {
findings.push({
checkId: "fs.config_include.perms_group_readable",
severity: "warn",
title: "Config include file is group-readable",
detail: `${p} mode=${formatOctal(bits)}; include files can contain tokens and private settings.`,
remediation: `chmod 600 ${p}`,
detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`,
remediation: formatPermissionRemediation({
targetPath: p,
perms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
});
}
}
@@ -757,28 +779,45 @@ export async function collectStateDeepFilesystemFindings(params: {
cfg: ClawdbotConfig;
env: NodeJS.ProcessEnv;
stateDir: string;
platform?: NodeJS.Platform;
execIcacls?: ExecFn;
}): Promise<SecurityAuditFinding[]> {
const findings: SecurityAuditFinding[] = [];
const oauthDir = resolveOAuthDir(params.env, params.stateDir);
const oauthStat = await safeStat(oauthDir);
if (oauthStat.ok && oauthStat.isDir) {
const bits = modeBits(oauthStat.mode);
if (isWorldWritable(bits) || isGroupWritable(bits)) {
const oauthPerms = await inspectPathPermissions(oauthDir, {
env: params.env,
platform: params.platform,
exec: params.execIcacls,
});
if (oauthPerms.ok && oauthPerms.isDir) {
if (oauthPerms.worldWritable || oauthPerms.groupWritable) {
findings.push({
checkId: "fs.credentials_dir.perms_writable",
severity: "critical",
title: "Credentials dir is writable by others",
detail: `${oauthDir} mode=${formatOctal(bits)}; another user could drop/modify credential files.`,
remediation: `chmod 700 ${oauthDir}`,
detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; another user could drop/modify credential files.`,
remediation: formatPermissionRemediation({
targetPath: oauthDir,
perms: oauthPerms,
isDir: true,
posixMode: 0o700,
env: params.env,
}),
});
} else if (isGroupReadable(bits) || isWorldReadable(bits)) {
} else if (oauthPerms.groupReadable || oauthPerms.worldReadable) {
findings.push({
checkId: "fs.credentials_dir.perms_readable",
severity: "warn",
title: "Credentials dir is readable by others",
detail: `${oauthDir} mode=${formatOctal(bits)}; credentials and allowlists can be sensitive.`,
remediation: `chmod 700 ${oauthDir}`,
detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; credentials and allowlists can be sensitive.`,
remediation: formatPermissionRemediation({
targetPath: oauthDir,
perms: oauthPerms,
isDir: true,
posixMode: 0o700,
env: params.env,
}),
});
}
}
@@ -795,40 +834,64 @@ export async function collectStateDeepFilesystemFindings(params: {
const agentDir = path.join(params.stateDir, "agents", agentId, "agent");
const authPath = path.join(agentDir, "auth-profiles.json");
// eslint-disable-next-line no-await-in-loop
const authStat = await safeStat(authPath);
if (authStat.ok) {
const bits = modeBits(authStat.mode);
if (isWorldWritable(bits) || isGroupWritable(bits)) {
const authPerms = await inspectPathPermissions(authPath, {
env: params.env,
platform: params.platform,
exec: params.execIcacls,
});
if (authPerms.ok) {
if (authPerms.worldWritable || authPerms.groupWritable) {
findings.push({
checkId: "fs.auth_profiles.perms_writable",
severity: "critical",
title: "auth-profiles.json is writable by others",
detail: `${authPath} mode=${formatOctal(bits)}; another user could inject credentials.`,
remediation: `chmod 600 ${authPath}`,
detail: `${formatPermissionDetail(authPath, authPerms)}; another user could inject credentials.`,
remediation: formatPermissionRemediation({
targetPath: authPath,
perms: authPerms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
});
} else if (isWorldReadable(bits) || isGroupReadable(bits)) {
} else if (authPerms.worldReadable || authPerms.groupReadable) {
findings.push({
checkId: "fs.auth_profiles.perms_readable",
severity: "warn",
title: "auth-profiles.json is readable by others",
detail: `${authPath} mode=${formatOctal(bits)}; auth-profiles.json contains API keys and OAuth tokens.`,
remediation: `chmod 600 ${authPath}`,
detail: `${formatPermissionDetail(authPath, authPerms)}; auth-profiles.json contains API keys and OAuth tokens.`,
remediation: formatPermissionRemediation({
targetPath: authPath,
perms: authPerms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
});
}
}
const storePath = path.join(params.stateDir, "agents", agentId, "sessions", "sessions.json");
// eslint-disable-next-line no-await-in-loop
const storeStat = await safeStat(storePath);
if (storeStat.ok) {
const bits = modeBits(storeStat.mode);
if (isWorldReadable(bits) || isGroupReadable(bits)) {
const storePerms = await inspectPathPermissions(storePath, {
env: params.env,
platform: params.platform,
exec: params.execIcacls,
});
if (storePerms.ok) {
if (storePerms.worldReadable || storePerms.groupReadable) {
findings.push({
checkId: "fs.sessions_store.perms_readable",
severity: "warn",
title: "sessions.json is readable by others",
detail: `${storePath} mode=${formatOctal(bits)}; routing and transcript metadata can be sensitive.`,
remediation: `chmod 600 ${storePath}`,
detail: `${formatPermissionDetail(storePath, storePerms)}; routing and transcript metadata can be sensitive.`,
remediation: formatPermissionRemediation({
targetPath: storePath,
perms: storePerms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
});
}
}
@@ -840,16 +903,25 @@ export async function collectStateDeepFilesystemFindings(params: {
const expanded = logFile.startsWith("~") ? expandTilde(logFile, params.env) : logFile;
if (expanded) {
const logPath = path.resolve(expanded);
const st = await safeStat(logPath);
if (st.ok) {
const bits = modeBits(st.mode);
if (isWorldReadable(bits) || isGroupReadable(bits)) {
const logPerms = await inspectPathPermissions(logPath, {
env: params.env,
platform: params.platform,
exec: params.execIcacls,
});
if (logPerms.ok) {
if (logPerms.worldReadable || logPerms.groupReadable) {
findings.push({
checkId: "fs.log_file.perms_readable",
severity: "warn",
title: "Log file is readable by others",
detail: `${logPath} mode=${formatOctal(bits)}; logs can contain private messages and tool output.`,
remediation: `chmod 600 ${logPath}`,
detail: `${formatPermissionDetail(logPath, logPerms)}; logs can contain private messages and tool output.`,
remediation: formatPermissionRemediation({
targetPath: logPath,
perms: logPerms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
});
}
}

View File

@@ -1,5 +1,33 @@
import fs from "node:fs/promises";
import {
formatIcaclsResetCommand,
formatWindowsAclSummary,
inspectWindowsAcl,
type ExecFn,
} from "./windows-acl.js";
export type PermissionCheck = {
ok: boolean;
isSymlink: boolean;
isDir: boolean;
mode: number | null;
bits: number | null;
source: "posix" | "windows-acl" | "unknown";
worldWritable: boolean;
groupWritable: boolean;
worldReadable: boolean;
groupReadable: boolean;
aclSummary?: string;
error?: string;
};
export type PermissionCheckOptions = {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
exec?: ExecFn;
};
export async function safeStat(targetPath: string): Promise<{
ok: boolean;
isSymlink: boolean;
@@ -32,6 +60,98 @@ export async function safeStat(targetPath: string): Promise<{
}
}
export async function inspectPathPermissions(
targetPath: string,
opts?: PermissionCheckOptions,
): Promise<PermissionCheck> {
const st = await safeStat(targetPath);
if (!st.ok) {
return {
ok: false,
isSymlink: false,
isDir: false,
mode: null,
bits: null,
source: "unknown",
worldWritable: false,
groupWritable: false,
worldReadable: false,
groupReadable: false,
error: st.error,
};
}
const bits = modeBits(st.mode);
const platform = opts?.platform ?? process.platform;
if (platform === "win32") {
const acl = await inspectWindowsAcl(targetPath, { env: opts?.env, exec: opts?.exec });
if (!acl.ok) {
return {
ok: true,
isSymlink: st.isSymlink,
isDir: st.isDir,
mode: st.mode,
bits,
source: "unknown",
worldWritable: false,
groupWritable: false,
worldReadable: false,
groupReadable: false,
error: acl.error,
};
}
return {
ok: true,
isSymlink: st.isSymlink,
isDir: st.isDir,
mode: st.mode,
bits,
source: "windows-acl",
worldWritable: acl.untrustedWorld.some((entry) => entry.canWrite),
groupWritable: acl.untrustedGroup.some((entry) => entry.canWrite),
worldReadable: acl.untrustedWorld.some((entry) => entry.canRead),
groupReadable: acl.untrustedGroup.some((entry) => entry.canRead),
aclSummary: formatWindowsAclSummary(acl),
};
}
return {
ok: true,
isSymlink: st.isSymlink,
isDir: st.isDir,
mode: st.mode,
bits,
source: "posix",
worldWritable: isWorldWritable(bits),
groupWritable: isGroupWritable(bits),
worldReadable: isWorldReadable(bits),
groupReadable: isGroupReadable(bits),
};
}
export function formatPermissionDetail(targetPath: string, perms: PermissionCheck): string {
if (perms.source === "windows-acl") {
const summary = perms.aclSummary ?? "unknown";
return `${targetPath} acl=${summary}`;
}
return `${targetPath} mode=${formatOctal(perms.bits)}`;
}
export function formatPermissionRemediation(params: {
targetPath: string;
perms: PermissionCheck;
isDir: boolean;
posixMode: number;
env?: NodeJS.ProcessEnv;
}): string {
if (params.perms.source === "windows-acl") {
return formatIcaclsResetCommand(params.targetPath, { isDir: params.isDir, env: params.env });
}
const mode = params.posixMode.toString(8).padStart(3, "0");
return `chmod ${mode} ${params.targetPath}`;
}
export function modeBits(mode: number | null): number | null {
if (mode == null) return null;
return mode & 0o777;

View File

@@ -82,7 +82,7 @@ describe("security audit", () => {
gateway: {
bind: "loopback",
controlUi: { enabled: true },
auth: { mode: "none" as any },
auth: {},
},
};
@@ -120,6 +120,83 @@ describe("security audit", () => {
);
});
it("treats Windows ACL-only perms as secure", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-security-audit-win-"));
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true });
const configPath = path.join(stateDir, "clawdbot.json");
await fs.writeFile(configPath, "{}\n", "utf-8");
const user = "DESKTOP-TEST\\Tester";
const execIcacls = async (_cmd: string, args: string[]) => ({
stdout: `${args[0]} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`,
stderr: "",
});
const res = await runSecurityAudit({
config: {},
includeFilesystem: true,
includeChannelSecurity: false,
stateDir,
configPath,
platform: "win32",
env: { ...process.env, USERNAME: "Tester", USERDOMAIN: "DESKTOP-TEST" },
execIcacls,
});
const forbidden = new Set([
"fs.state_dir.perms_world_writable",
"fs.state_dir.perms_group_writable",
"fs.state_dir.perms_readable",
"fs.config.perms_writable",
"fs.config.perms_world_readable",
"fs.config.perms_group_readable",
]);
for (const id of forbidden) {
expect(res.findings.some((f) => f.checkId === id)).toBe(false);
}
});
it("flags Windows ACLs when Users can read the state dir", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-security-audit-win-open-"));
const stateDir = path.join(tmp, "state");
await fs.mkdir(stateDir, { recursive: true });
const configPath = path.join(stateDir, "clawdbot.json");
await fs.writeFile(configPath, "{}\n", "utf-8");
const user = "DESKTOP-TEST\\Tester";
const execIcacls = async (_cmd: string, args: string[]) => {
const target = args[0];
if (target === stateDir) {
return {
stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n BUILTIN\\Users:(RX)\n ${user}:(F)\n`,
stderr: "",
};
}
return {
stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`,
stderr: "",
};
};
const res = await runSecurityAudit({
config: {},
includeFilesystem: true,
includeChannelSecurity: false,
stateDir,
configPath,
platform: "win32",
env: { ...process.env, USERNAME: "Tester", USERDOMAIN: "DESKTOP-TEST" },
execIcacls,
});
expect(
res.findings.some(
(f) => f.checkId === "fs.state_dir.perms_readable" && f.severity === "warn",
),
).toBe(true);
});
it("warns when small models are paired with web/browser tools", async () => {
const cfg: ClawdbotConfig = {
agents: { defaults: { model: { primary: "ollama/mistral-8b" } } },

View File

@@ -24,14 +24,11 @@ import {
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js";
import {
formatOctal,
isGroupReadable,
isGroupWritable,
isWorldReadable,
isWorldWritable,
modeBits,
safeStat,
formatPermissionDetail,
formatPermissionRemediation,
inspectPathPermissions,
} from "./audit-fs.js";
import type { ExecFn } from "./windows-acl.js";
export type SecurityAuditSeverity = "info" | "warn" | "critical";
@@ -66,6 +63,8 @@ export type SecurityAuditReport = {
export type SecurityAuditOptions = {
config: ClawdbotConfig;
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
deep?: boolean;
includeFilesystem?: boolean;
includeChannelSecurity?: boolean;
@@ -79,6 +78,8 @@ export type SecurityAuditOptions = {
plugins?: ReturnType<typeof listChannelPlugins>;
/** Dependency injection for tests. */
probeGatewayFn?: typeof probeGateway;
/** Dependency injection for tests (Windows ACL checks). */
execIcacls?: ExecFn;
};
function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary {
@@ -119,13 +120,19 @@ function classifyChannelWarningSeverity(message: string): SecurityAuditSeverity
async function collectFilesystemFindings(params: {
stateDir: string;
configPath: string;
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
execIcacls?: ExecFn;
}): Promise<SecurityAuditFinding[]> {
const findings: SecurityAuditFinding[] = [];
const stateDirStat = await safeStat(params.stateDir);
if (stateDirStat.ok) {
const bits = modeBits(stateDirStat.mode);
if (stateDirStat.isSymlink) {
const stateDirPerms = await inspectPathPermissions(params.stateDir, {
env: params.env,
platform: params.platform,
exec: params.execIcacls,
});
if (stateDirPerms.ok) {
if (stateDirPerms.isSymlink) {
findings.push({
checkId: "fs.state_dir.symlink",
severity: "warn",
@@ -133,37 +140,58 @@ async function collectFilesystemFindings(params: {
detail: `${params.stateDir} is a symlink; treat this as an extra trust boundary.`,
});
}
if (isWorldWritable(bits)) {
if (stateDirPerms.worldWritable) {
findings.push({
checkId: "fs.state_dir.perms_world_writable",
severity: "critical",
title: "State dir is world-writable",
detail: `${params.stateDir} mode=${formatOctal(bits)}; other users can write into your Clawdbot state.`,
remediation: `chmod 700 ${params.stateDir}`,
detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; other users can write into your Clawdbot state.`,
remediation: formatPermissionRemediation({
targetPath: params.stateDir,
perms: stateDirPerms,
isDir: true,
posixMode: 0o700,
env: params.env,
}),
});
} else if (isGroupWritable(bits)) {
} else if (stateDirPerms.groupWritable) {
findings.push({
checkId: "fs.state_dir.perms_group_writable",
severity: "warn",
title: "State dir is group-writable",
detail: `${params.stateDir} mode=${formatOctal(bits)}; group users can write into your Clawdbot state.`,
remediation: `chmod 700 ${params.stateDir}`,
detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; group users can write into your Clawdbot state.`,
remediation: formatPermissionRemediation({
targetPath: params.stateDir,
perms: stateDirPerms,
isDir: true,
posixMode: 0o700,
env: params.env,
}),
});
} else if (isGroupReadable(bits) || isWorldReadable(bits)) {
} else if (stateDirPerms.groupReadable || stateDirPerms.worldReadable) {
findings.push({
checkId: "fs.state_dir.perms_readable",
severity: "warn",
title: "State dir is readable by others",
detail: `${params.stateDir} mode=${formatOctal(bits)}; consider restricting to 700.`,
remediation: `chmod 700 ${params.stateDir}`,
detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; consider restricting to 700.`,
remediation: formatPermissionRemediation({
targetPath: params.stateDir,
perms: stateDirPerms,
isDir: true,
posixMode: 0o700,
env: params.env,
}),
});
}
}
const configStat = await safeStat(params.configPath);
if (configStat.ok) {
const bits = modeBits(configStat.mode);
if (configStat.isSymlink) {
const configPerms = await inspectPathPermissions(params.configPath, {
env: params.env,
platform: params.platform,
exec: params.execIcacls,
});
if (configPerms.ok) {
if (configPerms.isSymlink) {
findings.push({
checkId: "fs.config.symlink",
severity: "warn",
@@ -171,29 +199,47 @@ async function collectFilesystemFindings(params: {
detail: `${params.configPath} is a symlink; make sure you trust its target.`,
});
}
if (isWorldWritable(bits) || isGroupWritable(bits)) {
if (configPerms.worldWritable || configPerms.groupWritable) {
findings.push({
checkId: "fs.config.perms_writable",
severity: "critical",
title: "Config file is writable by others",
detail: `${params.configPath} mode=${formatOctal(bits)}; another user could change gateway/auth/tool policies.`,
remediation: `chmod 600 ${params.configPath}`,
detail: `${formatPermissionDetail(params.configPath, configPerms)}; another user could change gateway/auth/tool policies.`,
remediation: formatPermissionRemediation({
targetPath: params.configPath,
perms: configPerms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
});
} else if (isWorldReadable(bits)) {
} else if (configPerms.worldReadable) {
findings.push({
checkId: "fs.config.perms_world_readable",
severity: "critical",
title: "Config file is world-readable",
detail: `${params.configPath} mode=${formatOctal(bits)}; config can contain tokens and private settings.`,
remediation: `chmod 600 ${params.configPath}`,
detail: `${formatPermissionDetail(params.configPath, configPerms)}; config can contain tokens and private settings.`,
remediation: formatPermissionRemediation({
targetPath: params.configPath,
perms: configPerms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
});
} else if (isGroupReadable(bits)) {
} else if (configPerms.groupReadable) {
findings.push({
checkId: "fs.config.perms_group_readable",
severity: "warn",
title: "Config file is group-readable",
detail: `${params.configPath} mode=${formatOctal(bits)}; config can contain tokens and private settings.`,
remediation: `chmod 600 ${params.configPath}`,
detail: `${formatPermissionDetail(params.configPath, configPerms)}; config can contain tokens and private settings.`,
remediation: formatPermissionRemediation({
targetPath: params.configPath,
perms: configPerms,
isDir: false,
posixMode: 0o600,
env: params.env,
}),
});
}
}
@@ -850,7 +896,9 @@ async function maybeProbeGateway(params: {
export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<SecurityAuditReport> {
const findings: SecurityAuditFinding[] = [];
const cfg = opts.config;
const env = process.env;
const env = opts.env ?? process.env;
const platform = opts.platform ?? process.platform;
const execIcacls = opts.execIcacls;
const stateDir = opts.stateDir ?? resolveStateDir(env);
const configPath = opts.configPath ?? resolveConfigPath(env, stateDir);
@@ -873,11 +921,23 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
: null;
if (opts.includeFilesystem !== false) {
findings.push(...(await collectFilesystemFindings({ stateDir, configPath })));
findings.push(
...(await collectFilesystemFindings({
stateDir,
configPath,
env,
platform,
execIcacls,
})),
);
if (configSnapshot) {
findings.push(...(await collectIncludeFilePermFindings({ configSnapshot })));
findings.push(
...(await collectIncludeFilePermFindings({ configSnapshot, env, platform, execIcacls })),
);
}
findings.push(...(await collectStateDeepFilesystemFindings({ cfg, env, stateDir })));
findings.push(
...(await collectStateDeepFilesystemFindings({ cfg, env, stateDir, platform, execIcacls })),
);
findings.push(...(await collectPluginsTrustFindings({ cfg, stateDir })));
}

View File

@@ -10,6 +10,8 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
import { runExec } from "../process/exec.js";
import { createIcaclsResetCommand, formatIcaclsResetCommand, type ExecFn } from "./windows-acl.js";
export type SecurityFixChmodAction = {
kind: "chmod";
@@ -20,13 +22,24 @@ export type SecurityFixChmodAction = {
error?: string;
};
export type SecurityFixIcaclsAction = {
kind: "icacls";
path: string;
command: string;
ok: boolean;
skipped?: string;
error?: string;
};
export type SecurityFixAction = SecurityFixChmodAction | SecurityFixIcaclsAction;
export type SecurityFixResult = {
ok: boolean;
stateDir: string;
configPath: string;
configWritten: boolean;
changes: string[];
actions: SecurityFixChmodAction[];
actions: SecurityFixAction[];
errors: string[];
};
@@ -97,6 +110,82 @@ async function safeChmod(params: {
}
}
async function safeAclReset(params: {
path: string;
require: "dir" | "file";
env: NodeJS.ProcessEnv;
exec?: ExecFn;
}): Promise<SecurityFixIcaclsAction> {
const display = formatIcaclsResetCommand(params.path, {
isDir: params.require === "dir",
env: params.env,
});
try {
const st = await fs.lstat(params.path);
if (st.isSymbolicLink()) {
return {
kind: "icacls",
path: params.path,
command: display,
ok: false,
skipped: "symlink",
};
}
if (params.require === "dir" && !st.isDirectory()) {
return {
kind: "icacls",
path: params.path,
command: display,
ok: false,
skipped: "not-a-directory",
};
}
if (params.require === "file" && !st.isFile()) {
return {
kind: "icacls",
path: params.path,
command: display,
ok: false,
skipped: "not-a-file",
};
}
const cmd = createIcaclsResetCommand(params.path, {
isDir: st.isDirectory(),
env: params.env,
});
if (!cmd) {
return {
kind: "icacls",
path: params.path,
command: display,
ok: false,
skipped: "missing-user",
};
}
const exec = params.exec ?? runExec;
await exec(cmd.command, cmd.args);
return { kind: "icacls", path: params.path, command: cmd.display, ok: true };
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "ENOENT") {
return {
kind: "icacls",
path: params.path,
command: display,
ok: false,
skipped: "missing",
};
}
return {
kind: "icacls",
path: params.path,
command: display,
ok: false,
error: String(err),
};
}
}
function setGroupPolicyAllowlist(params: {
cfg: ClawdbotConfig;
channel: string;
@@ -261,7 +350,12 @@ async function chmodCredentialsAndAgentState(params: {
env: NodeJS.ProcessEnv;
stateDir: string;
cfg: ClawdbotConfig;
actions: SecurityFixChmodAction[];
actions: SecurityFixAction[];
applyPerms: (params: {
path: string;
mode: number;
require: "dir" | "file";
}) => Promise<SecurityFixAction>;
}): Promise<void> {
const credsDir = resolveOAuthDir(params.env, params.stateDir);
params.actions.push(await safeChmod({ path: credsDir, mode: 0o700, require: "dir" }));
@@ -294,18 +388,20 @@ async function chmodCredentialsAndAgentState(params: {
// eslint-disable-next-line no-await-in-loop
params.actions.push(await safeChmod({ path: agentRoot, mode: 0o700, require: "dir" }));
// eslint-disable-next-line no-await-in-loop
params.actions.push(await safeChmod({ path: agentDir, mode: 0o700, require: "dir" }));
params.actions.push(await params.applyPerms({ path: agentDir, mode: 0o700, require: "dir" }));
const authPath = path.join(agentDir, "auth-profiles.json");
// eslint-disable-next-line no-await-in-loop
params.actions.push(await safeChmod({ path: authPath, mode: 0o600, require: "file" }));
params.actions.push(await params.applyPerms({ path: authPath, mode: 0o600, require: "file" }));
// eslint-disable-next-line no-await-in-loop
params.actions.push(await safeChmod({ path: sessionsDir, mode: 0o700, require: "dir" }));
params.actions.push(
await params.applyPerms({ path: sessionsDir, mode: 0o700, require: "dir" }),
);
const storePath = path.join(sessionsDir, "sessions.json");
// eslint-disable-next-line no-await-in-loop
params.actions.push(await safeChmod({ path: storePath, mode: 0o600, require: "file" }));
params.actions.push(await params.applyPerms({ path: storePath, mode: 0o600, require: "file" }));
}
}
@@ -313,11 +409,16 @@ export async function fixSecurityFootguns(opts?: {
env?: NodeJS.ProcessEnv;
stateDir?: string;
configPath?: string;
platform?: NodeJS.Platform;
exec?: ExecFn;
}): Promise<SecurityFixResult> {
const env = opts?.env ?? process.env;
const platform = opts?.platform ?? process.platform;
const exec = opts?.exec ?? runExec;
const isWindows = platform === "win32";
const stateDir = opts?.stateDir ?? resolveStateDir(env);
const configPath = opts?.configPath ?? resolveConfigPath(env, stateDir);
const actions: SecurityFixChmodAction[] = [];
const actions: SecurityFixAction[] = [];
const errors: string[] = [];
const io = createConfigIO({ env, configPath });
@@ -352,8 +453,13 @@ export async function fixSecurityFootguns(opts?: {
}
}
actions.push(await safeChmod({ path: stateDir, mode: 0o700, require: "dir" }));
actions.push(await safeChmod({ path: configPath, mode: 0o600, require: "file" }));
const applyPerms = (params: { path: string; mode: number; require: "dir" | "file" }) =>
isWindows
? safeAclReset({ path: params.path, require: params.require, env, exec })
: safeChmod({ path: params.path, mode: params.mode, require: params.require });
actions.push(await applyPerms({ path: stateDir, mode: 0o700, require: "dir" }));
actions.push(await applyPerms({ path: configPath, mode: 0o600, require: "file" }));
if (snap.exists) {
const includePaths = await collectIncludePathsRecursive({
@@ -362,15 +468,19 @@ export async function fixSecurityFootguns(opts?: {
}).catch(() => []);
for (const p of includePaths) {
// eslint-disable-next-line no-await-in-loop
actions.push(await safeChmod({ path: p, mode: 0o600, require: "file" }));
actions.push(await applyPerms({ path: p, mode: 0o600, require: "file" }));
}
}
await chmodCredentialsAndAgentState({ env, stateDir, cfg: snap.config ?? {}, actions }).catch(
(err) => {
errors.push(`chmodCredentialsAndAgentState failed: ${String(err)}`);
},
);
await chmodCredentialsAndAgentState({
env,
stateDir,
cfg: snap.config ?? {},
actions,
applyPerms,
}).catch((err) => {
errors.push(`chmodCredentialsAndAgentState failed: ${String(err)}`);
});
return {
ok: errors.length === 0,

203
src/security/windows-acl.ts Normal file
View File

@@ -0,0 +1,203 @@
import os from "node:os";
import { runExec } from "../process/exec.js";
export type ExecFn = typeof runExec;
export type WindowsAclEntry = {
principal: string;
rights: string[];
rawRights: string;
canRead: boolean;
canWrite: boolean;
};
export type WindowsAclSummary = {
ok: boolean;
entries: WindowsAclEntry[];
untrustedWorld: WindowsAclEntry[];
untrustedGroup: WindowsAclEntry[];
trusted: WindowsAclEntry[];
error?: string;
};
const INHERIT_FLAGS = new Set(["I", "OI", "CI", "IO", "NP"]);
const WORLD_PRINCIPALS = new Set([
"everyone",
"users",
"builtin\\users",
"authenticated users",
"nt authority\\authenticated users",
]);
const TRUSTED_BASE = new Set([
"nt authority\\system",
"system",
"builtin\\administrators",
"creator owner",
]);
const WORLD_SUFFIXES = ["\\users", "\\authenticated users"];
const TRUSTED_SUFFIXES = ["\\administrators", "\\system"];
const normalize = (value: string) => value.trim().toLowerCase();
export function resolveWindowsUserPrincipal(env?: NodeJS.ProcessEnv): string | null {
const username = env?.USERNAME?.trim() || os.userInfo().username?.trim();
if (!username) return null;
const domain = env?.USERDOMAIN?.trim();
return domain ? `${domain}\\${username}` : username;
}
function buildTrustedPrincipals(env?: NodeJS.ProcessEnv): Set<string> {
const trusted = new Set<string>(TRUSTED_BASE);
const principal = resolveWindowsUserPrincipal(env);
if (principal) {
trusted.add(normalize(principal));
const parts = principal.split("\\");
const userOnly = parts.at(-1);
if (userOnly) trusted.add(normalize(userOnly));
}
return trusted;
}
function classifyPrincipal(
principal: string,
env?: NodeJS.ProcessEnv,
): "trusted" | "world" | "group" {
const normalized = normalize(principal);
const trusted = buildTrustedPrincipals(env);
if (trusted.has(normalized) || TRUSTED_SUFFIXES.some((s) => normalized.endsWith(s)))
return "trusted";
if (WORLD_PRINCIPALS.has(normalized) || WORLD_SUFFIXES.some((s) => normalized.endsWith(s)))
return "world";
return "group";
}
function rightsFromTokens(tokens: string[]): { canRead: boolean; canWrite: boolean } {
const upper = tokens.join("").toUpperCase();
const canWrite =
upper.includes("F") || upper.includes("M") || upper.includes("W") || upper.includes("D");
const canRead = upper.includes("F") || upper.includes("M") || upper.includes("R");
return { canRead, canWrite };
}
export function parseIcaclsOutput(output: string, targetPath: string): WindowsAclEntry[] {
const entries: WindowsAclEntry[] = [];
const normalizedTarget = targetPath.trim();
const lowerTarget = normalizedTarget.toLowerCase();
const quotedTarget = `"${normalizedTarget}"`;
const quotedLower = quotedTarget.toLowerCase();
for (const rawLine of output.split(/\r?\n/)) {
const line = rawLine.trimEnd();
if (!line.trim()) continue;
const trimmed = line.trim();
const lower = trimmed.toLowerCase();
if (
lower.startsWith("successfully processed") ||
lower.startsWith("processed") ||
lower.startsWith("failed processing") ||
lower.startsWith("no mapping between account names")
) {
continue;
}
let entry = trimmed;
if (lower.startsWith(lowerTarget)) {
entry = trimmed.slice(normalizedTarget.length).trim();
} else if (lower.startsWith(quotedLower)) {
entry = trimmed.slice(quotedTarget.length).trim();
}
if (!entry) continue;
const idx = entry.indexOf(":");
if (idx === -1) continue;
const principal = entry.slice(0, idx).trim();
const rawRights = entry.slice(idx + 1).trim();
const tokens =
rawRights
.match(/\(([^)]+)\)/g)
?.map((token) => token.slice(1, -1).trim())
.filter(Boolean) ?? [];
if (tokens.some((token) => token.toUpperCase() === "DENY")) continue;
const rights = tokens.filter((token) => !INHERIT_FLAGS.has(token.toUpperCase()));
if (rights.length === 0) continue;
const { canRead, canWrite } = rightsFromTokens(rights);
entries.push({ principal, rights, rawRights, canRead, canWrite });
}
return entries;
}
export function summarizeWindowsAcl(
entries: WindowsAclEntry[],
env?: NodeJS.ProcessEnv,
): Pick<WindowsAclSummary, "trusted" | "untrustedWorld" | "untrustedGroup"> {
const trusted: WindowsAclEntry[] = [];
const untrustedWorld: WindowsAclEntry[] = [];
const untrustedGroup: WindowsAclEntry[] = [];
for (const entry of entries) {
const classification = classifyPrincipal(entry.principal, env);
if (classification === "trusted") trusted.push(entry);
else if (classification === "world") untrustedWorld.push(entry);
else untrustedGroup.push(entry);
}
return { trusted, untrustedWorld, untrustedGroup };
}
export async function inspectWindowsAcl(
targetPath: string,
opts?: { env?: NodeJS.ProcessEnv; exec?: ExecFn },
): Promise<WindowsAclSummary> {
const exec = opts?.exec ?? runExec;
try {
const { stdout, stderr } = await exec("icacls", [targetPath]);
const output = `${stdout}\n${stderr}`.trim();
const entries = parseIcaclsOutput(output, targetPath);
const { trusted, untrustedWorld, untrustedGroup } = summarizeWindowsAcl(entries, opts?.env);
return { ok: true, entries, trusted, untrustedWorld, untrustedGroup };
} catch (err) {
return {
ok: false,
entries: [],
trusted: [],
untrustedWorld: [],
untrustedGroup: [],
error: String(err),
};
}
}
export function formatWindowsAclSummary(summary: WindowsAclSummary): string {
if (!summary.ok) return "unknown";
const untrusted = [...summary.untrustedWorld, ...summary.untrustedGroup];
if (untrusted.length === 0) return "trusted-only";
return untrusted.map((entry) => `${entry.principal}:${entry.rawRights}`).join(", ");
}
export function formatIcaclsResetCommand(
targetPath: string,
opts: { isDir: boolean; env?: NodeJS.ProcessEnv },
): string {
const user = resolveWindowsUserPrincipal(opts.env) ?? "%USERNAME%";
const grant = opts.isDir ? "(OI)(CI)F" : "F";
return `icacls "${targetPath}" /inheritance:r /grant:r "${user}:${grant}" /grant:r "SYSTEM:${grant}"`;
}
export function createIcaclsResetCommand(
targetPath: string,
opts: { isDir: boolean; env?: NodeJS.ProcessEnv },
): { command: string; args: string[]; display: string } | null {
const user = resolveWindowsUserPrincipal(opts.env);
if (!user) return null;
const grant = opts.isDir ? "(OI)(CI)F" : "F";
const args = [
targetPath,
"/inheritance:r",
"/grant:r",
`${user}:${grant}`,
"/grant:r",
`SYSTEM:${grant}`,
];
return { command: "icacls", args, display: formatIcaclsResetCommand(targetPath, opts) };
}

View File

@@ -360,7 +360,6 @@ export async function runOnboardingWizard(
prompter,
store: authStore,
includeSkip: true,
includeClaudeCliIfMissing: true,
}));
const authResult = await applyAuthChoice({