diff --git a/packages/adapters/claude-local/src/server/execute.remote.test.ts b/packages/adapters/claude-local/src/server/execute.remote.test.ts index fad8dc2bb4e..a38ee07340c 100644 --- a/packages/adapters/claude-local/src/server/execute.remote.test.ts +++ b/packages/adapters/claude-local/src/server/execute.remote.test.ts @@ -11,6 +11,7 @@ const { restoreWorkspaceFromSshExecution, syncDirectoryToSsh, startAdapterExecutionTargetPaperclipBridge, + readAdapterExecutionTargetTextFile, } = vi.hoisted(() => ({ runChildProcess: vi.fn(async () => ({ exitCode: 0, @@ -38,6 +39,7 @@ const { }, stop: async () => {}, })), + readAdapterExecutionTargetTextFile: vi.fn(async (): Promise => null), })); vi.mock("@paperclipai/adapter-utils/server-utils", async () => { @@ -71,6 +73,7 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async () => { return { ...actual, startAdapterExecutionTargetPaperclipBridge, + readAdapterExecutionTargetTextFile, }; }); @@ -81,6 +84,7 @@ describe("claude remote execution", () => { afterEach(async () => { vi.clearAllMocks(); + readAdapterExecutionTargetTextFile.mockResolvedValue(null); while (cleanupDirs.length > 0) { const dir = cleanupDirs.pop(); if (!dir) continue; @@ -333,4 +337,159 @@ describe("claude remote execution", () => { expect(call?.[2]).toContain("12345678-1234-4abc-9def-123456789012"); }); + it("returns refreshed Claude subscription .credentials.json after a remote run", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-auth-refresh-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-auth-refresh/workspace"; + await mkdir(workspaceDir, { recursive: true }); + readAdapterExecutionTargetTextFile.mockResolvedValue('{"oauthAccount":{"email":"user@example.com"}}'); + + const result = await execute({ + runId: "run-auth-refresh", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Claude Coder", + adapterType: "claude_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: "claude", + }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + }, + }, + executionTarget: { + kind: "remote", + transport: "ssh", + remoteCwd: "/remote/workspace", + runtimeCredentialMaterialization: { + provider: "claude", + assets: { + "config-seed": { + files: [ + { + relativePath: ".credentials.json", + contents: '{"oauthAccount":{"email":"stored@example.com"}}', + }, + ], + }, + }, + }, + spec: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }, + onLog: async () => {}, + }); + + expect(readAdapterExecutionTargetTextFile).toHaveBeenCalledWith( + "run-auth-refresh", + expect.objectContaining({ kind: "remote", transport: "ssh" }), + `${managedRemoteWorkspace}/.paperclip-runtime/claude/config/.credentials.json`, + expect.objectContaining({ + maxBytes: 32_768, + }), + ); + expect(result.runtimeCredentialUpdates).toEqual({ + provider: "claude", + assets: { + "config-seed": { + files: [ + { + relativePath: ".credentials.json", + contents: '{"oauthAccount":{"email":"user@example.com"}}', + mode: 0o600, + }, + ], + }, + }, + }); + }); + + it("does not let subscription credentials override explicit Claude API-key mode", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-api-key-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + await mkdir(workspaceDir, { recursive: true }); + + const result = await execute({ + runId: "run-api-key", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Claude Coder", + adapterType: "claude_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: "claude", + env: { + ANTHROPIC_API_KEY: "sk-ant-test", + }, + }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + }, + }, + executionTarget: { + kind: "remote", + transport: "ssh", + remoteCwd: "/remote/workspace", + runtimeCredentialMaterialization: { + provider: "claude", + assets: { + "config-seed": { + files: [ + { + relativePath: ".credentials.json", + contents: '{"oauthAccount":{"email":"subscription@example.com"}}', + }, + ], + }, + }, + }, + spec: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }, + onLog: async () => {}, + }); + + expect(readAdapterExecutionTargetTextFile).not.toHaveBeenCalled(); + expect(result.runtimeCredentialUpdates).toBeUndefined(); + }); + }); diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 1c3de4c87ad..cf012322591 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -18,6 +18,7 @@ import { ensureAdapterExecutionTargetRuntimeCommandInstalled, materializeAdapterRuntimeCredentialAsset, prepareAdapterExecutionTargetRuntime, + readAdapterExecutionTargetTextFile, readAdapterExecutionTarget, resolveAdapterExecutionTargetTimeoutSec, resolveAdapterExecutionTargetCommandForLogs, @@ -132,11 +133,30 @@ function isBedrockAuth(env: Record): boolean { ); } +function hasExplicitClaudeApiOrBedrockAuth(env: Record): boolean { + return hasNonEmptyEnvValue(env, "ANTHROPIC_API_KEY") || isBedrockAuth(env); +} + +function suppressInheritedClaudeApiOrBedrockAuth(env: Record) { + env.ANTHROPIC_API_KEY = ""; + env.CLAUDE_CODE_USE_BEDROCK = ""; + env.ANTHROPIC_BEDROCK_BASE_URL = ""; +} + function resolveClaudeBillingType(env: Record): "api" | "subscription" | "metered_api" { if (isBedrockAuth(env)) return "metered_api"; return hasNonEmptyEnvValue(env, "ANTHROPIC_API_KEY") ? "api" : "subscription"; } +function claudeCredentialsJsonHasUsableAuth(contents: string): boolean { + try { + const parsed = JSON.parse(contents); + return Boolean(parsed) && typeof parsed === "object" && !Array.isArray(parsed) && Object.keys(parsed).length > 0; + } catch { + return false; + } +} + async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise { const { runId, agent, config, context, runtimeCommandSpec, executionTarget, authToken } = input; const onLog = input.onLog ?? (async () => {}); @@ -175,6 +195,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise 0 || + adapterRuntimeCredentialAssetFiles(executionTarget, "config-seed").length > 0; let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({ workspaceCwd: effectiveWorkspaceCwd, @@ -273,7 +296,12 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise 0; const runtimeCredentialEnvKeys = Object.keys(adapterRuntimeCredentialEnv(executionTarget)); const promptTemplate = asString( @@ -392,6 +418,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", + ), + ), + ); + const claudeConfigCredentialFiles = hasExplicitClaudeRuntimeAuth + ? [] + : adapterRuntimeCredentialAssetFiles(executionTarget, "config-seed"); + const hasRuntimeClaudeConfigFiles = claudeConfigCredentialFiles.length > 0; const workspaceContext = parseObject(context.paperclipWorkspace); const workspaceCwd = asString(workspaceContext.cwd, ""); const workspaceSource = asString(workspaceContext.source, ""); @@ -632,6 +669,66 @@ export async function execute(ctx: AdapterExecutionContext): Promise => { + const usesExplicitApiAuth = hasExplicitClaudeRuntimeAuth; + if (!hasRuntimeClaudeConfigFiles || hasExplicitClaudeConfigDir || usesExplicitApiAuth) return null; + + const claudeConfigDir = remoteClaudeConfigDir ?? (!executionTargetIsRemote ? env.CLAUDE_CONFIG_DIR : null); + if (!claudeConfigDir) return null; + + const credentialsPath = executionTargetIsRemote + ? path.posix.join(claudeConfigDir, ".credentials.json") + : path.join(claudeConfigDir, ".credentials.json"); + const contents = await readAdapterExecutionTargetTextFile( + runId, + runtimeExecutionTarget, + credentialsPath, + { + cwd: effectiveExecutionCwd, + env: Object.fromEntries( + Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ), + timeoutSec: 15, + graceSec: 5, + maxBytes: 32_768, + }, + ); + if (contents === null) { + throw new Error( + "Claude subscription .credentials.json was missing after the run; refusing to leave stored subscription credentials stale.", + ); + } + if (!claudeCredentialsJsonHasUsableAuth(contents)) { + throw new Error( + "Claude subscription .credentials.json was not valid credential material after the run; refusing to overwrite stored credentials.", + ); + } + return { + provider: "claude", + assets: { + "config-seed": { + files: [ + { + relativePath: ".credentials.json", + contents, + mode: 0o600, + }, + ], + }, + }, + }; + }; + + const withRuntimeCredentialUpdates = async ( + result: AdapterExecutionResult, + ): Promise => { + const runtimeCredentialUpdates = await readRefreshedClaudeCredentialUpdates(); + return runtimeCredentialUpdates ? { ...result, runtimeCredentialUpdates } : result; + }; + let effectiveEffort = effort; if (executionTargetIsSandbox && effort) { const supportsEffort = await claudeCommandSupportsEffortFlag({ @@ -1092,10 +1189,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise { } }); + it("injects Claude OAuth runtime credentials into the CLI env while redacting invocation metadata", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-oauth-")); + const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root); + + let loggedCommand: string | null = null; + let loggedEnv: Record = {}; + try { + const result = await execute({ + runId: "run-oauth-runtime-credential", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Claude Coder", + adapterType: "claude_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: {}, + executionTarget: { + kind: "local", + runtimeCredentialMaterialization: { + provider: "claude", + env: { + CLAUDE_CODE_OAUTH_TOKEN: "oauth-secret", + }, + }, + }, + authToken: "run-jwt-token", + onLog: async () => {}, + onMeta: async (meta) => { + loggedCommand = meta.command; + loggedEnv = meta.env ?? {}; + }, + }); + + expect(result.exitCode).toBe(0); + expect(loggedCommand).toBe(commandPath); + expect(loggedEnv.CLAUDE_CODE_OAUTH_TOKEN).toBe("***REDACTED***"); + const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; + expect(capture.claudeOauthToken).toBe("oauth-secret"); + expect(capture.argv.slice(0, 4)).toEqual(["--print", "-", "--output-format", "stream-json"]); + } finally { + restore(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("injects bridge env into sandbox-managed remote runs", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-sandbox-")); const localWorkspace = path.join(root, "workspace"); @@ -781,6 +843,19 @@ describe("claude execute", () => { remoteCwd: remoteWorkspace, timeoutMs: 30_000, runner: createLocalSandboxRunner(), + runtimeCredentialMaterialization: { + provider: "claude", + assets: { + "config-seed": { + files: [ + { + relativePath: ".credentials.json", + contents: '{"oauthAccount":{"email":"stored@example.com"}}', + }, + ], + }, + }, + }, }, authToken: "run-jwt-token", onLog: async () => {}, @@ -804,10 +879,25 @@ describe("claude execute", () => { ); expect(capture.argv).not.toContain("--dangerously-skip-permissions"); expect(capture.claudeConfigDir).toBe(path.join(remoteWorkspace, ".paperclip-runtime", "claude", "config")); + expect(capture.claudeConfigEntries).toContain(".credentials.json"); expect(capture.claudeConfigEntries).toContain("settings.json"); expect(capture.paperclipApiUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); expect(capture.paperclipApiKey).not.toBe("run-jwt-token"); expect(capture.paperclipApiBridgeMode).toBe("queue_v1"); + expect(result.runtimeCredentialUpdates).toEqual({ + provider: "claude", + assets: { + "config-seed": { + files: [ + { + relativePath: ".credentials.json", + contents: '{"oauthAccount":{"email":"stored@example.com"}}', + mode: 0o600, + }, + ], + }, + }, + }); } finally { if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome; diff --git a/server/src/__tests__/runtime-credential-materialization.test.ts b/server/src/__tests__/runtime-credential-materialization.test.ts index 3e4a0142f64..99115fee312 100644 --- a/server/src/__tests__/runtime-credential-materialization.test.ts +++ b/server/src/__tests__/runtime-credential-materialization.test.ts @@ -236,6 +236,67 @@ describe("runtime credential materialization service", () => { ).toBeNull(); }); + it("extracts Claude credentials JSON runtime updates without accepting other material", () => { + expect( + byoSubscriptionCredentialMaterialFromRuntimeUpdate("claude", { + provider: "claude", + assets: { + "config-seed": { + files: [ + { + relativePath: ".credentials.json", + contents: '{"oauthAccount":{"email":"user@example.com"}}', + }, + ], + }, + }, + }), + ).toEqual({ + provider: "claude", + kind: "credentials_json", + value: '{"oauthAccount":{"email":"user@example.com"}}', + }); + + expect( + byoSubscriptionCredentialMaterialFromRuntimeUpdate("claude", { + provider: "codex", + assets: { + "config-seed": { + files: [ + { + relativePath: ".credentials.json", + contents: '{"refresh":"wrong-provider"}', + }, + ], + }, + }, + }), + ).toBeNull(); + expect( + byoSubscriptionCredentialMaterialFromRuntimeUpdate("claude", { + provider: "claude", + assets: { + "config-seed": { + files: [ + { + relativePath: "credentials.json", + contents: '{"refresh":"wrong-file"}', + }, + ], + }, + }, + }), + ).toBeNull(); + expect( + byoSubscriptionCredentialMaterialFromRuntimeUpdate("claude", { + provider: "claude", + env: { + CLAUDE_CODE_OAUTH_TOKEN: "oauth-token", + }, + }), + ).toBeNull(); + }); + it("writes refreshed Codex runtime auth material back through the user-scoped store", async () => { const writeBackFromRuntime = vi.fn>( async () => undefined, @@ -283,4 +344,52 @@ describe("runtime credential materialization service", () => { }, }); }); + + it("writes refreshed Claude credentials JSON material back through the user-scoped store", async () => { + const writeBackFromRuntime = vi.fn>( + async () => undefined, + ); + + await expect( + writeBackByoSubscriptionRuntimeCredentialMaterialization({ + store: { + resolveForRuntime: vi.fn(), + writeBackFromRuntime, + }, + companyId: "company-1", + userId: "user-1", + provider: "claude", + agentId: "agent-1", + issueId: "issue-1", + heartbeatRunId: "run-1", + runtimeCredentialUpdates: { + provider: "claude", + assets: { + "config-seed": { + files: [ + { + relativePath: ".credentials.json", + contents: '{"oauthAccount":{"email":"user@example.com"}}', + }, + ], + }, + }, + }, + }), + ).resolves.toBe(true); + + expect(writeBackFromRuntime).toHaveBeenCalledWith({ + companyId: "company-1", + userId: "user-1", + provider: "claude", + agentId: "agent-1", + issueId: "issue-1", + heartbeatRunId: "run-1", + material: { + provider: "claude", + kind: "credentials_json", + value: '{"oauthAccount":{"email":"user@example.com"}}', + }, + }); + }); }); diff --git a/server/src/__tests__/subscription-credentials-service.test.ts b/server/src/__tests__/subscription-credentials-service.test.ts index eb0392dd5d9..a1be4e02295 100644 --- a/server/src/__tests__/subscription-credentials-service.test.ts +++ b/server/src/__tests__/subscription-credentials-service.test.ts @@ -274,6 +274,30 @@ describe("subscriptionCredentialService", () => { expect(result).not.toHaveProperty("material"); }); + it("rejects runtime material when the expected credential kind does not match the stored row", async () => { + const row = makeCredentialRow({ + provider: "claude", + credentialKind: "claude_oauth_token", + }); + const { db } = makeFakeDb({ findFirstRows: [row] }); + const svc = subscriptionCredentialService(db); + + await expect( + svc.updateMaterialFromRuntime( + row.companyId as string, + row.userId as string, + "claude", + '{"oauthAccount":{"email":"user@example.com"}}', + "claude_credentials_json", + ), + ).rejects.toMatchObject({ + status: 422, + details: { code: "credential_kind_mismatch" }, + }); + + expect(mockSecretProvider.createSecret).not.toHaveBeenCalled(); + }); + it("rejects decrypted material that does not match the stored integrity hash", async () => { const row = makeCredentialRow({ valueSha256: sha256Hex("different-token") }); const { db } = makeFakeDb({ findFirstRows: [row] }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 533a93080a7..2a55ff168e3 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1731,6 +1731,16 @@ function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } +function hasExplicitClaudeApiOrBedrockRuntimeAuth(env: Record): boolean { + const useBedrock = readNonEmptyString(env.CLAUDE_CODE_USE_BEDROCK); + return Boolean( + readNonEmptyString(env.ANTHROPIC_API_KEY) || + useBedrock === "1" || + useBedrock === "true" || + readNonEmptyString(env.ANTHROPIC_BEDROCK_BASE_URL), + ); +} + function readRequestedUserIdFromContext(contextSnapshot: Record): string | null { const explicitRuntimeUserId = readNonEmptyString(contextSnapshot.runtimeCredentialUserId) ?? @@ -9162,10 +9172,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }; const adapterRuntimeCredentialProvider = providerForSubscriptionCredentialAdapter(agent.adapterType); const resolvedRuntimeEnv = parseObject(effectiveResolvedConfig.env); - const runtimeCredentialProvider = - adapterRuntimeCredentialProvider === "codex" && readNonEmptyString(resolvedRuntimeEnv.OPENAI_API_KEY) - ? null - : adapterRuntimeCredentialProvider; + const runtimeCredentialProvider = (() => { + if (adapterRuntimeCredentialProvider === "codex" && readNonEmptyString(resolvedRuntimeEnv.OPENAI_API_KEY)) { + return null; + } + if (adapterRuntimeCredentialProvider === "claude" && hasExplicitClaudeApiOrBedrockRuntimeAuth(resolvedRuntimeEnv)) { + return null; + } + return adapterRuntimeCredentialProvider; + })(); const runtimeCredentialUserId = runtimeCredentialProvider ? await resolveRuntimeCredentialUserId({ companyId: agent.companyId, diff --git a/server/src/services/runtime-credential-materialization.ts b/server/src/services/runtime-credential-materialization.ts index 016328bb0fa..37f04a15a1b 100644 --- a/server/src/services/runtime-credential-materialization.ts +++ b/server/src/services/runtime-credential-materialization.ts @@ -96,16 +96,25 @@ export function createSubscriptionCredentialRuntimeStore(db: Db): ByoSubscriptio async writeBackFromRuntime(input) { if (input.material.provider !== input.provider) return; switch (input.material.kind) { + case "credentials_json": + await credentials.updateMaterialFromRuntime( + input.companyId, + input.userId, + input.provider, + input.material.value, + "claude_credentials_json", + ); + break; case "auth_json": await credentials.updateMaterialFromRuntime( input.companyId, input.userId, input.provider, input.material.value, + "codex_auth_json", ); break; case "oauth_token": - case "credentials_json": break; } }, @@ -182,6 +191,18 @@ export function byoSubscriptionCredentialMaterialFromRuntimeUpdate( }; } + if (provider === "claude") { + const credentialsJson = normalized.assets?.["config-seed"]?.files.find( + (file) => file.relativePath === ".credentials.json" && typeof file.contents === "string", + ); + if (!credentialsJson) return null; + return { + provider: "claude", + kind: "credentials_json", + value: credentialsJson.contents, + }; + } + return null; } diff --git a/server/src/services/subscription-credentials.ts b/server/src/services/subscription-credentials.ts index f00909a5846..6816d98f5c7 100644 --- a/server/src/services/subscription-credentials.ts +++ b/server/src/services/subscription-credentials.ts @@ -198,6 +198,7 @@ export function subscriptionCredentialService(db: Db) { userId: string, provider: SubscriptionCredentialProvider, material: string, + expectedCredentialKind?: SubscriptionCredentialKind, ): Promise { const row = await findRow(companyId, userId, provider); if (!row) throw notFound("Subscription credential not found"); @@ -205,6 +206,11 @@ export function subscriptionCredentialService(db: Db) { throw unprocessable("Subscription credential is not active", { code: "credential_inactive" }); } const credentialKind = row.credentialKind as SubscriptionCredentialKind; + if (expectedCredentialKind && credentialKind !== expectedCredentialKind) { + throw unprocessable("Runtime credential update kind does not match stored credential", { + code: "credential_kind_mismatch", + }); + } assertKindMatchesProvider(provider, credentialKind); assertMaterialMatchesKind(credentialKind, material);