mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
feat: write back claude subscription credentials
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
490f921a3f
commit
c2e3b9bf0f
8 files changed
+535
-10
No files matched your search
@@ -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<string | null> => 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();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ensureAdapterExecutionTargetRuntimeCommandInstalled,
|
||||
materializeAdapterRuntimeCredentialAsset,
|
||||
prepareAdapterExecutionTargetRuntime,
|
||||
readAdapterExecutionTargetTextFile,
|
||||
readAdapterExecutionTarget,
|
||||
resolveAdapterExecutionTargetTimeoutSec,
|
||||
resolveAdapterExecutionTargetCommandForLogs,
|
||||
@@ -132,11 +133,30 @@ function isBedrockAuth(env: Record<string, string>): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function hasExplicitClaudeApiOrBedrockAuth(env: Record<string, string>): boolean {
|
||||
return hasNonEmptyEnvValue(env, "ANTHROPIC_API_KEY") || isBedrockAuth(env);
|
||||
}
|
||||
|
||||
function suppressInheritedClaudeApiOrBedrockAuth(env: Record<string, string>) {
|
||||
env.ANTHROPIC_API_KEY = "";
|
||||
env.CLAUDE_CODE_USE_BEDROCK = "";
|
||||
env.ANTHROPIC_BEDROCK_BASE_URL = "";
|
||||
}
|
||||
|
||||
function resolveClaudeBillingType(env: Record<string, string>): "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<ClaudeRuntimeConfig> {
|
||||
const { runId, agent, config, context, runtimeCommandSpec, executionTarget, authToken } = input;
|
||||
const onLog = input.onLog ?? (async () => {});
|
||||
@@ -175,6 +195,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget);
|
||||
const runtimeCredentialEnv = adapterRuntimeCredentialEnv(executionTarget);
|
||||
const runtimeCredentialEnvKeys = Object.keys(runtimeCredentialEnv);
|
||||
const hasRuntimeCredentialMaterial =
|
||||
runtimeCredentialEnvKeys.length > 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<Cl
|
||||
for (const [key, value] of Object.entries(shapedEnvConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
Object.assign(env, runtimeCredentialEnv);
|
||||
if (!hasExplicitClaudeApiOrBedrockAuth(env)) {
|
||||
Object.assign(env, runtimeCredentialEnv);
|
||||
if (hasRuntimeCredentialMaterial) {
|
||||
suppressInheritedClaudeApiOrBedrockAuth(env);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
@@ -378,8 +406,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
});
|
||||
const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget);
|
||||
const executionTargetIsSandbox = executionTarget?.kind === "remote" && executionTarget.transport === "sandbox";
|
||||
const claudeConfigCredentialFiles = adapterRuntimeCredentialAssetFiles(executionTarget, "config-seed");
|
||||
const hasRuntimeClaudeConfigFiles = claudeConfigCredentialFiles.length > 0;
|
||||
const runtimeCredentialEnvKeys = Object.keys(adapterRuntimeCredentialEnv(executionTarget));
|
||||
|
||||
const promptTemplate = asString(
|
||||
@@ -392,6 +418,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
|
||||
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, true);
|
||||
const configEnv = parseObject(config.env);
|
||||
const hasExplicitClaudeRuntimeAuth = hasExplicitClaudeApiOrBedrockAuth(
|
||||
Object.fromEntries(
|
||||
Object.entries(configEnv).filter(
|
||||
(entry): entry is [string, string] => 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<AdapterExec
|
||||
}
|
||||
}
|
||||
}
|
||||
const readRefreshedClaudeCredentialUpdates =
|
||||
async (): Promise<AdapterExecutionResult["runtimeCredentialUpdates"]> => {
|
||||
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<AdapterExecutionResult> => {
|
||||
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<AdapterExec
|
||||
}
|
||||
}
|
||||
const retry = await runAttempt(null);
|
||||
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
|
||||
return await withRuntimeCredentialUpdates(
|
||||
toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true }),
|
||||
);
|
||||
}
|
||||
|
||||
return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId });
|
||||
return await withRuntimeCredentialUpdates(
|
||||
toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId }),
|
||||
);
|
||||
} finally {
|
||||
if (paperclipBridge) {
|
||||
await paperclipBridge.stop();
|
||||
|
||||
@@ -67,6 +67,7 @@ const payload = {
|
||||
paperclipApiUrl: process.env.PAPERCLIP_API_URL || null,
|
||||
paperclipApiKey: process.env.PAPERCLIP_API_KEY || null,
|
||||
paperclipApiBridgeMode: process.env.PAPERCLIP_API_BRIDGE_MODE || null,
|
||||
claudeOauthToken: process.env.CLAUDE_CODE_OAUTH_TOKEN || null,
|
||||
};
|
||||
if (capturePath) {
|
||||
fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8");
|
||||
@@ -168,6 +169,7 @@ type CapturePayload = {
|
||||
paperclipApiUrl?: string | null;
|
||||
paperclipApiKey?: string | null;
|
||||
paperclipApiBridgeMode?: string | null;
|
||||
claudeOauthToken?: string | null;
|
||||
appendedSystemPromptFilePath?: string | null;
|
||||
appendedSystemPromptFileContents?: string | null;
|
||||
};
|
||||
@@ -726,6 +728,66 @@ describe("claude execute", () => {
|
||||
}
|
||||
});
|
||||
|
||||
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<string, string> = {};
|
||||
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;
|
||||
|
||||
@@ -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<NonNullable<ByoSubscriptionCredentialStore["writeBackFromRuntime"]>>(
|
||||
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<NonNullable<ByoSubscriptionCredentialStore["writeBackFromRuntime"]>>(
|
||||
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"}}',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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] });
|
||||
|
||||
@@ -1731,6 +1731,16 @@ function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function hasExplicitClaudeApiOrBedrockRuntimeAuth(env: Record<string, unknown>): 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, unknown>): 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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -198,6 +198,7 @@ export function subscriptionCredentialService(db: Db) {
|
||||
userId: string,
|
||||
provider: SubscriptionCredentialProvider,
|
||||
material: string,
|
||||
expectedCredentialKind?: SubscriptionCredentialKind,
|
||||
): Promise<SubscriptionCredentialReadModel> {
|
||||
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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user