From c29c5e6158bb37b0517b259caedfc5fba3a8b75b Mon Sep 17 00:00:00 2001 From: Cody Date: Tue, 23 Jun 2026 23:22:57 +0000 Subject: [PATCH] feat(runtime): materialize BYO subscription credentials Co-Authored-By: Paperclip --- .../adapter-utils/src/execution-target.ts | 29 +++ .../src/runtime-credentials.test.ts | 170 +++++++++++++++ .../adapter-utils/src/runtime-credentials.ts | 196 ++++++++++++++++++ packages/adapter-utils/src/server-utils.ts | 13 +- .../claude-local/src/server/execute.ts | 111 ++++++---- .../codex-local/src/server/execute.ts | 95 +++++---- .../environment-execution-target.test.ts | 48 +++++ ...runtime-credential-materialization.test.ts | 190 +++++++++++++++++ .../services/environment-execution-target.ts | 22 +- .../services/environment-run-orchestrator.ts | 3 + server/src/services/heartbeat.ts | 101 ++++++++- .../runtime-credential-materialization.ts | 162 +++++++++++++++ 12 files changed, 1058 insertions(+), 82 deletions(-) create mode 100644 packages/adapter-utils/src/runtime-credentials.test.ts create mode 100644 packages/adapter-utils/src/runtime-credentials.ts create mode 100644 server/src/__tests__/runtime-credential-materialization.test.ts create mode 100644 server/src/services/runtime-credential-materialization.ts diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 86d3f1a8bc9..41e3bb0ca02 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -1,4 +1,8 @@ import path from "node:path"; +import { + normalizeAdapterRuntimeCredentialMaterialization, + type AdapterRuntimeCredentialMaterialization, +} from "./runtime-credentials.js"; import type { SshRemoteExecutionSpec } from "./ssh.js"; import { prepareCommandManagedRuntime, @@ -31,11 +35,28 @@ import { preferredShellForSandbox, shellCommandArgs } from "./sandbox-shell.js"; import type { RuntimeProgressSink } from "./runtime-progress.js"; export type { RuntimeProgressSink } from "./runtime-progress.js"; +export { + adapterRuntimeCredentialAssetFiles, + adapterRuntimeCredentialEnv, + getAdapterRuntimeCredentialMaterialization, + hasAdapterRuntimeCredentialAssetFiles, + materializeAdapterRuntimeCredentialAsset, + normalizeAdapterRuntimeCredentialMaterialization, +} from "./runtime-credentials.js"; +export type { + AdapterRuntimeCredentialAsset, + AdapterRuntimeCredentialFile, + AdapterRuntimeCredentialMaterialization, + AdapterRuntimeCredentialMaterializationHost, + AdapterRuntimeCredentialProvider, + MaterializedRuntimeCredentialAsset, +} from "./runtime-credentials.js"; export interface AdapterLocalExecutionTarget { kind: "local"; environmentId?: string | null; leaseId?: string | null; + runtimeCredentialMaterialization?: AdapterRuntimeCredentialMaterialization | null; } export interface AdapterSshExecutionTarget { @@ -45,6 +66,7 @@ export interface AdapterSshExecutionTarget { leaseId?: string | null; remoteCwd: string; spec: SshRemoteExecutionSpec; + runtimeCredentialMaterialization?: AdapterRuntimeCredentialMaterialization | null; } export interface AdapterSandboxExecutionTarget { @@ -57,6 +79,7 @@ export interface AdapterSandboxExecutionTarget { remoteCwd: string; timeoutMs?: number | null; runner?: CommandManagedRuntimeRunner; + runtimeCredentialMaterialization?: AdapterRuntimeCredentialMaterialization | null; } export type AdapterExecutionTarget = @@ -848,12 +871,16 @@ export function adapterExecutionTargetSessionMatches( export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTarget | null { const parsed = parseObject(value); const kind = readStringMeta(parsed, "kind"); + const runtimeCredentialMaterialization = normalizeAdapterRuntimeCredentialMaterialization( + parsed.runtimeCredentialMaterialization, + ); if (kind === "local") { return { kind: "local", environmentId: readStringMeta(parsed, "environmentId"), leaseId: readStringMeta(parsed, "leaseId"), + ...(runtimeCredentialMaterialization ? { runtimeCredentialMaterialization } : {}), }; } @@ -867,6 +894,7 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar leaseId: readStringMeta(parsed, "leaseId"), remoteCwd: spec.remoteCwd, spec, + ...(runtimeCredentialMaterialization ? { runtimeCredentialMaterialization } : {}), }; } @@ -881,6 +909,7 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar leaseId: readStringMeta(parsed, "leaseId"), remoteCwd, timeoutMs: typeof parsed.timeoutMs === "number" ? parsed.timeoutMs : null, + ...(runtimeCredentialMaterialization ? { runtimeCredentialMaterialization } : {}), }; } diff --git a/packages/adapter-utils/src/runtime-credentials.test.ts b/packages/adapter-utils/src/runtime-credentials.test.ts new file mode 100644 index 00000000000..2f8ac10ca17 --- /dev/null +++ b/packages/adapter-utils/src/runtime-credentials.test.ts @@ -0,0 +1,170 @@ +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + adapterExecutionTargetSessionIdentity, + parseAdapterExecutionTarget, + adapterExecutionTargetToRemoteSpec, + materializeAdapterRuntimeCredentialAsset, + normalizeAdapterRuntimeCredentialMaterialization, + type AdapterExecutionTarget, +} from "./execution-target.js"; +import { buildInvocationEnvForLogs } from "./server-utils.js"; + +const tempRoots: string[] = []; + +async function makeTempDir(prefix: string): Promise { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempRoots.push(tempDir); + return tempDir; +} + +describe("runtime credential materialization", () => { + afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((tempRoot) => fs.rm(tempRoot, { recursive: true, force: true }))); + }); + + it("overlays credential files as regular files instead of preserving base symlinks", async () => { + const baseDir = await makeTempDir("paperclip-runtime-credential-base-"); + await fs.writeFile(path.join(baseDir, "host-auth.json"), '{"host":true}', "utf8"); + await fs.symlink("host-auth.json", path.join(baseDir, "auth.json")); + + const materialized = await materializeAdapterRuntimeCredentialAsset({ + baseDir, + files: [ + { + relativePath: "auth.json", + contents: '{"runtime":true}', + mode: 0o600, + }, + ], + tempPrefix: "paperclip-runtime-credential-test-", + }); + tempRoots.push(materialized.localDir); + + expect(materialized.materialized).toBe(true); + expect(await fs.readFile(path.join(materialized.localDir, "auth.json"), "utf8")).toBe('{"runtime":true}'); + expect((await fs.lstat(path.join(materialized.localDir, "auth.json"))).isSymbolicLink()).toBe(false); + expect(await fs.readFile(path.join(baseDir, "auth.json"), "utf8")).toBe('{"host":true}'); + }); + + it("rejects absolute and escaping credential file paths", async () => { + await expect( + materializeAdapterRuntimeCredentialAsset({ + baseDir: null, + files: [{ relativePath: "../auth.json", contents: "{}" }], + }), + ).rejects.toThrow(/Invalid runtime credential relative path/); + + await expect( + materializeAdapterRuntimeCredentialAsset({ + baseDir: null, + files: [{ relativePath: "/auth.json", contents: "{}" }], + }), + ).rejects.toThrow(/Invalid runtime credential relative path/); + }); + + it("redacts explicitly sensitive runtime credential env keys in invocation metadata", () => { + expect( + buildInvocationEnvForLogs( + { + CUSTOM_RUNTIME_TOKEN: "redacted-by-key-pattern", + PLAIN_CREDENTIAL: "secret-value", + }, + { + explicitSensitiveKeys: ["PLAIN_CREDENTIAL"], + }, + ), + ).toMatchObject({ + CUSTOM_RUNTIME_TOKEN: "***REDACTED***", + PLAIN_CREDENTIAL: "***REDACTED***", + }); + }); + + it("keeps runtime credential material out of remote specs and session identity", () => { + const target: AdapterExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "fake", + remoteCwd: "/workspace", + runtimeCredentialMaterialization: { + provider: "codex", + assets: { + home: { + files: [{ relativePath: "auth.json", contents: "runtime-secret" }], + }, + }, + }, + }; + + expect(JSON.stringify(adapterExecutionTargetToRemoteSpec(target))).not.toContain("runtime-secret"); + expect(JSON.stringify(adapterExecutionTargetSessionIdentity(target))).not.toContain("runtime-secret"); + }); + + it("preserves normalized credential envelopes when execution targets are serialized", () => { + expect( + parseAdapterExecutionTarget({ + kind: "remote", + transport: "sandbox", + providerKey: "fake", + environmentId: "env-1", + leaseId: "lease-1", + remoteCwd: "/workspace", + runtimeCredentialMaterialization: { + provider: "codex", + env: { + IGNORED_NON_STRING: 123, + CODEX_TOKEN: "runtime-secret", + }, + assets: { + home: { + files: [{ relativePath: "auth.json", contents: "{}" }], + }, + }, + }, + }), + ).toMatchObject({ + kind: "remote", + transport: "sandbox", + runtimeCredentialMaterialization: { + provider: "codex", + env: { + CODEX_TOKEN: "runtime-secret", + }, + assets: { + home: { + files: [{ relativePath: "auth.json", contents: "{}" }], + }, + }, + }, + }); + }); + + it("normalizes valid credential materialization envelopes", () => { + expect( + normalizeAdapterRuntimeCredentialMaterialization({ + provider: "codex", + env: { + CODEX_TOKEN: "secret", + IGNORED: 42, + }, + assets: { + home: { + files: [{ relativePath: "auth.json", contents: "{}" }], + }, + }, + }), + ).toEqual({ + provider: "codex", + env: { + CODEX_TOKEN: "secret", + }, + assets: { + home: { + files: [{ relativePath: "auth.json", contents: "{}" }], + }, + }, + }); + }); +}); diff --git a/packages/adapter-utils/src/runtime-credentials.ts b/packages/adapter-utils/src/runtime-credentials.ts new file mode 100644 index 00000000000..9f5d2fa394a --- /dev/null +++ b/packages/adapter-utils/src/runtime-credentials.ts @@ -0,0 +1,196 @@ +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export interface AdapterRuntimeCredentialFile { + /** + * POSIX-style path relative to the runtime asset root. + */ + relativePath: string; + contents: string; + /** + * File mode for credential material. Defaults to 0600. + */ + mode?: number; +} + +export interface AdapterRuntimeCredentialAsset { + files: AdapterRuntimeCredentialFile[]; +} + +export type AdapterRuntimeCredentialProvider = "claude" | "codex" | (string & {}); + +export interface AdapterRuntimeCredentialMaterialization { + provider?: AdapterRuntimeCredentialProvider | null; + /** + * Environment variables injected only into the adapter invocation process. + */ + env?: Record; + /** + * Credential files keyed by managed runtime asset name, e.g. "home" for + * Codex or "config-seed" for Claude. + */ + assets?: Record; +} + +export interface AdapterRuntimeCredentialMaterializationHost { + runtimeCredentialMaterialization?: AdapterRuntimeCredentialMaterialization | null; +} + +function isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function normalizeRelativePath(relativePath: string): string { + const raw = relativePath.trim().replaceAll("\\", "/"); + if (path.posix.isAbsolute(raw)) { + throw new Error(`Invalid runtime credential relative path: ${relativePath}`); + } + const normalized = path.posix.normalize(raw); + const parts = normalized.split("/").filter(Boolean); + if ( + parts.length === 0 || + normalized === "." || + normalized === ".." || + normalized.startsWith("../") || + parts.some((part) => part === "." || part === "..") + ) { + throw new Error(`Invalid runtime credential relative path: ${relativePath}`); + } + return parts.join("/"); +} + +function parseRuntimeCredentialFile(value: unknown): AdapterRuntimeCredentialFile | null { + if (!isObject(value) || typeof value.relativePath !== "string" || typeof value.contents !== "string") { + return null; + } + const relativePath = normalizeRelativePath(value.relativePath); + const mode = typeof value.mode === "number" && Number.isInteger(value.mode) && value.mode > 0 ? value.mode : undefined; + return { + relativePath, + contents: value.contents, + ...(mode ? { mode } : {}), + }; +} + +export function normalizeAdapterRuntimeCredentialMaterialization(value: unknown): AdapterRuntimeCredentialMaterialization | null { + if (!isObject(value)) return null; + + const provider = typeof value.provider === "string" && value.provider.trim().length > 0 ? value.provider.trim() : null; + const env = isObject(value.env) + ? Object.fromEntries(Object.entries(value.env).filter((entry): entry is [string, string] => typeof entry[1] === "string")) + : {}; + + const assets: Record = {}; + if (isObject(value.assets)) { + for (const [assetKey, assetValue] of Object.entries(value.assets)) { + if (!/^[a-zA-Z0-9_-]+$/.test(assetKey) || !isObject(assetValue) || !Array.isArray(assetValue.files)) { + continue; + } + const files = assetValue.files.map(parseRuntimeCredentialFile).filter((file): file is AdapterRuntimeCredentialFile => file !== null); + if (files.length > 0) { + assets[assetKey] = { files }; + } + } + } + + return Object.keys(env).length > 0 || Object.keys(assets).length > 0 + ? { + ...(provider ? { provider } : {}), + ...(Object.keys(env).length > 0 ? { env } : {}), + ...(Object.keys(assets).length > 0 ? { assets } : {}), + } + : null; +} + +export function getAdapterRuntimeCredentialMaterialization( + target: AdapterRuntimeCredentialMaterializationHost | null | undefined, +): AdapterRuntimeCredentialMaterialization | null { + return normalizeAdapterRuntimeCredentialMaterialization(target?.runtimeCredentialMaterialization); +} + +export function adapterRuntimeCredentialEnv( + target: AdapterRuntimeCredentialMaterializationHost | null | undefined, +): Record { + return getAdapterRuntimeCredentialMaterialization(target)?.env ?? {}; +} + +export function adapterRuntimeCredentialAssetFiles( + target: AdapterRuntimeCredentialMaterializationHost | null | undefined, + assetKey: string, +): AdapterRuntimeCredentialFile[] { + return getAdapterRuntimeCredentialMaterialization(target)?.assets?.[assetKey]?.files ?? []; +} + +export function hasAdapterRuntimeCredentialAssetFiles( + target: AdapterRuntimeCredentialMaterializationHost | null | undefined, + assetKey: string, +): boolean { + return adapterRuntimeCredentialAssetFiles(target, assetKey).length > 0; +} + +async function copyDirectoryForRuntimeCredentialOverlay(input: { sourceDir: string | null | undefined; targetDir: string }) { + await fs.mkdir(input.targetDir, { recursive: true }); + if (!input.sourceDir) return; + const stat = await fs.stat(input.sourceDir).catch(() => null); + if (!stat?.isDirectory()) return; + await fs.cp(input.sourceDir, input.targetDir, { + recursive: true, + force: true, + preserveTimestamps: true, + verbatimSymlinks: true, + }); +} + +async function writeCredentialFiles(rootDir: string, files: AdapterRuntimeCredentialFile[]) { + for (const file of files) { + const relativePath = normalizeRelativePath(file.relativePath); + const target = path.join(rootDir, ...relativePath.split("/")); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.rm(target, { force: true, recursive: true }).catch(() => undefined); + await fs.writeFile(target, file.contents, { + encoding: "utf8", + mode: file.mode ?? 0o600, + }); + await fs.chmod(target, file.mode ?? 0o600).catch(() => undefined); + } +} + +export interface MaterializedRuntimeCredentialAsset { + localDir: string; + materialized: boolean; + cleanup(): Promise; +} + +export async function materializeAdapterRuntimeCredentialAsset(input: { + baseDir?: string | null; + files: AdapterRuntimeCredentialFile[]; + tempPrefix?: string; +}): Promise { + if (input.files.length === 0) { + return { + localDir: input.baseDir ?? "", + materialized: false, + cleanup: async () => {}, + }; + } + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), input.tempPrefix ?? "paperclip-runtime-credentials-")); + try { + await copyDirectoryForRuntimeCredentialOverlay({ + sourceDir: input.baseDir, + targetDir: tempDir, + }); + await writeCredentialFiles(tempDir, input.files); + } catch (err) { + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + throw err; + } + return { + localDir: tempDir, + materialized: true, + cleanup: async () => { + await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + }, + }; +} diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index d90260cea3c..893a5fa5d9a 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -1108,10 +1108,16 @@ export function renderPaperclipWakePrompt( return lines.join("\n").trim(); } -export function redactEnvForLogs(env: Record): Record { +export function redactEnvForLogs( + env: Record, + options: { explicitSensitiveKeys?: Iterable } = {}, +): Record { + const explicitSensitiveKeys = new Set( + Array.from(options.explicitSensitiveKeys ?? [], (key) => key.trim()).filter(Boolean), + ); const redacted: Record = {}; for (const [key, value] of Object.entries(env)) { - redacted[key] = SENSITIVE_ENV_KEY.test(key) ? REDACTED_LOG_VALUE : value; + redacted[key] = SENSITIVE_ENV_KEY.test(key) || explicitSensitiveKeys.has(key) ? REDACTED_LOG_VALUE : value; } return redacted; } @@ -1125,6 +1131,7 @@ export function buildInvocationEnvForLogs( options: { runtimeEnv?: NodeJS.ProcessEnv | Record; includeRuntimeKeys?: string[]; + explicitSensitiveKeys?: Iterable; resolvedCommand?: string | null; resolvedCommandEnvKey?: string; } = {}, @@ -1144,7 +1151,7 @@ export function buildInvocationEnvForLogs( merged[options.resolvedCommandEnvKey ?? "PAPERCLIP_RESOLVED_COMMAND"] = redactCommandTextForLogs(resolvedCommand); } - return redactEnvForLogs(merged); + return redactEnvForLogs(merged, { explicitSensitiveKeys: options.explicitSensitiveKeys }); } export function buildPaperclipEnv(agent: { id: string; companyId: string }): Record { diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 73666f8ff6a..1c3de4c87ad 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -4,6 +4,8 @@ import { fileURLToPath } from "node:url"; import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; import type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils"; import { + adapterRuntimeCredentialAssetFiles, + adapterRuntimeCredentialEnv, adapterExecutionTargetIsRemote, adapterExecutionTargetRemoteCwd, overrideAdapterExecutionTargetRemoteCwd, @@ -14,6 +16,7 @@ import { describeAdapterExecutionTarget, ensureAdapterExecutionTargetCommandResolvable, ensureAdapterExecutionTargetRuntimeCommandInstalled, + materializeAdapterRuntimeCredentialAsset, prepareAdapterExecutionTargetRuntime, readAdapterExecutionTarget, resolveAdapterExecutionTargetTimeoutSec, @@ -170,6 +173,8 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise 0; + const runtimeCredentialEnvKeys = Object.keys(adapterRuntimeCredentialEnv(executionTarget)); const promptTemplate = asString( config.promptTemplate, @@ -430,7 +440,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", ), @@ -467,43 +477,55 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - await onLog( - "stdout", - `[paperclip] Syncing workspace and Claude runtime assets to ${describeAdapterExecutionTarget(executionTarget)}.\n`, - ); - return await prepareAdapterExecutionTargetRuntime({ - runId, - target: executionTarget, - adapterKey: "claude", - timeoutSec, - workspaceLocalDir: cwd, - installCommand: SANDBOX_INSTALL_COMMAND, - detectCommand: command, - onProgress: (line) => onLog("stdout", line), - assets: [ - { - key: "skills", - localDir: promptBundle.addDir, - followSymlinks: true, - }, - ...(claudeConfigSeedDir - ? [{ - key: "config-seed", - localDir: claudeConfigSeedDir, + const runtimeClaudeConfigAsset = await materializeAdapterRuntimeCredentialAsset({ + baseDir: claudeConfigSeedDir ?? + (!executionTargetIsRemote && !hasExplicitClaudeConfigDir && hasRuntimeClaudeConfigFiles + ? resolveSharedClaudeConfigDir(effectiveEnv) + : null), + files: !hasExplicitClaudeConfigDir ? claudeConfigCredentialFiles : [], + tempPrefix: "paperclip-claude-runtime-config-", + }); + const runtimeClaudeConfigSeedDir = runtimeClaudeConfigAsset.materialized + ? runtimeClaudeConfigAsset.localDir + : claudeConfigSeedDir; + try { + const preparedExecutionTargetRuntime = executionTargetIsRemote + ? await (async () => { + await onLog( + "stdout", + `[paperclip] Syncing workspace and Claude runtime assets to ${describeAdapterExecutionTarget(executionTarget)}.\n`, + ); + return await prepareAdapterExecutionTargetRuntime({ + runId, + target: executionTarget, + adapterKey: "claude", + timeoutSec, + workspaceLocalDir: cwd, + installCommand: SANDBOX_INSTALL_COMMAND, + detectCommand: command, + onProgress: (line) => onLog("stdout", line), + assets: [ + { + key: "skills", + localDir: promptBundle.addDir, followSymlinks: true, - }] - : []), - ], - }); - })() - : null; + }, + ...(runtimeClaudeConfigSeedDir + ? [{ + key: "config-seed", + localDir: runtimeClaudeConfigSeedDir, + followSymlinks: true, + }] + : []), + ], + }); + })() + : null; if (preparedExecutionTargetRuntime?.workspaceRemoteDir) { effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir; } @@ -540,7 +562,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", + ), + ); + const runtimeEnv = ensurePathInEnv(effectiveEnv); + loggedEnv = buildInvocationEnvForLogs(env, { + runtimeEnv, + includeRuntimeKeys: ["HOME", "CLAUDE_CONFIG_DIR"], + explicitSensitiveKeys: runtimeCredentialEnvKeys, + resolvedCommand, + }); + } let paperclipBridge: Awaited> = null; if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(runtimeExecutionTarget)) { paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({ @@ -587,6 +624,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? path.resolve(envConfig.CODEX_HOME.trim()) @@ -397,44 +403,54 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", - ), - ); - const preparedRuntimeConfig = await prepareCodexRuntimeConfig({ - env: envConfigStrings, - codexHome: configuredCodexHome ? null : effectiveCodexHome, + const runtimeCodexHomeAsset = await materializeAdapterRuntimeCredentialAsset({ + baseDir: effectiveCodexHome, + files: codexHomeCredentialFiles, + tempPrefix: "paperclip-codex-runtime-home-", }); + const runtimeCodexHome = runtimeCodexHomeAsset.materialized + ? runtimeCodexHomeAsset.localDir + : effectiveCodexHome; + + let preparedRuntimeConfig: Awaited> | null = null; try { - for (const note of preparedRuntimeConfig.notes) { + // Never launch a managed CODEX_HOME with no credentials. Without auth.json and + // with OPENAI_API_KEY="" the provider rejects every request with + // "401 Missing bearer"; fail fast with a clear adapter error instead of + // emitting unauthenticated calls. External overrides manage their own auth. + const effectiveHomeIsManaged = configuredCodexHome == null || configuredHomeIsManaged; + if ( + effectiveHomeIsManaged && + !configuredOpenAiApiKey && + !(await codexHomeHasUsableAuth(runtimeCodexHome)) + ) { + throw new Error( + `no Codex credentials provisioned for managed home "${effectiveCodexHome}" ` + + `(no usable auth.json and OPENAI_API_KEY is empty). ` + + `Sign in to Codex on the host with a ChatGPT subscription, or configure a per-agent ` + + `OPENAI_API_KEY.`, + ); + } + // Merge custom model providers (PAPERCLIP_CODEX_PROVIDERS) into the managed + // CODEX_HOME's config.toml BEFORE the home is shipped to a remote execution + // target, so both local and sandboxed Codex processes pick up the routing. + // An explicit env.CODEX_HOME override is treated as user-managed and skipped. + const envConfigStrings = Object.fromEntries( + Object.entries(envConfig).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + preparedRuntimeConfig = await prepareCodexRuntimeConfig({ + env: envConfigStrings, + codexHome: configuredCodexHome && !runtimeCodexHomeAsset.materialized ? null : runtimeCodexHome, + }); + const activeRuntimeConfig = preparedRuntimeConfig; + for (const note of activeRuntimeConfig.notes) { await onLog("stdout", `[paperclip] ${note}\n`); } // Inject skills into the same CODEX_HOME that Codex will actually run with // (managed home in the default case, or an explicit override from adapter config). - const codexSkillsDir = resolveCodexSkillsDir(effectiveCodexHome); + const codexSkillsDir = resolveCodexSkillsDir(runtimeCodexHome); await ensureCodexSkillsInjected( onLog, { @@ -467,7 +483,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { - commandNotes.unshift(...preparedRuntimeConfig.notes); + if (activeRuntimeConfig.notes.length > 0) { + commandNotes.unshift(...activeRuntimeConfig.notes); } const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); @@ -1083,6 +1101,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise ({ resolveEnvironmentDriverConfigForRuntime: mockResolveEnvironmentDriverConfigForRuntime, })); +import { + adapterExecutionTargetToRemoteSpec, + type AdapterRuntimeCredentialMaterialization, +} from "@paperclipai/adapter-utils/execution-target"; import { DEFAULT_SANDBOX_REMOTE_CWD, resolveEnvironmentExecutionTarget, @@ -133,6 +137,50 @@ describe("resolveEnvironmentExecutionTarget", () => { }); }); + it("carries runtime credentials on sandbox targets without serializing them into remote specs", async () => { + const runtimeCredentialMaterialization: AdapterRuntimeCredentialMaterialization = { + provider: "codex", + assets: { + home: { + files: [{ relativePath: "auth.json", contents: "runtime-secret" }], + }, + }, + }; + mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({ + driver: "sandbox", + config: { + provider: "fake-plugin", + reuseLease: false, + timeoutMs: 30_000, + }, + }); + + const target = await resolveEnvironmentExecutionTarget({ + db: {} as never, + companyId: "company-1", + adapterType: "codex_local", + environment: { + id: "env-1", + driver: "sandbox", + config: { + provider: "fake-plugin", + }, + }, + leaseId: "lease-1", + leaseMetadata: {}, + lease: null, + environmentRuntime: null, + runtimeCredentialMaterialization, + }); + + expect(target).toMatchObject({ + kind: "remote", + transport: "sandbox", + runtimeCredentialMaterialization, + }); + expect(JSON.stringify(adapterExecutionTargetToRemoteSpec(target))).not.toContain("runtime-secret"); + }); + it("resolves SSH execution targets in bridge mode", async () => { mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({ driver: "ssh", diff --git a/server/src/__tests__/runtime-credential-materialization.test.ts b/server/src/__tests__/runtime-credential-materialization.test.ts new file mode 100644 index 00000000000..8b0822a8ccf --- /dev/null +++ b/server/src/__tests__/runtime-credential-materialization.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildByoSubscriptionRuntimeCredentialMaterialization, + byoSubscriptionCredentialMaterialFromDecrypted, + providerForSubscriptionCredentialAdapter, + resolveByoSubscriptionRuntimeCredentialMaterialization, + type ByoSubscriptionCredentialStore, +} from "../services/runtime-credential-materialization.js"; + +describe("runtime credential materialization service", () => { + it("maps Claude OAuth tokens to invocation-only env", () => { + expect( + buildByoSubscriptionRuntimeCredentialMaterialization({ + provider: "claude", + kind: "oauth_token", + value: "test-oauth-token", + }), + ).toEqual({ + provider: "claude", + env: { + CLAUDE_CODE_OAUTH_TOKEN: "test-oauth-token", + }, + }); + }); + + it("maps Claude credentials JSON to the managed config seed asset", () => { + expect( + buildByoSubscriptionRuntimeCredentialMaterialization({ + provider: "claude", + kind: "credentials_json", + value: '{"claude":true}', + }), + ).toEqual({ + provider: "claude", + assets: { + "config-seed": { + files: [ + { + relativePath: ".credentials.json", + contents: '{"claude":true}', + mode: 0o600, + }, + ], + }, + }, + }); + }); + + it("maps Codex auth JSON to the managed home asset", () => { + expect( + buildByoSubscriptionRuntimeCredentialMaterialization({ + provider: "codex", + kind: "auth_json", + value: '{"codex":true}', + }), + ).toEqual({ + provider: "codex", + assets: { + home: { + files: [ + { + relativePath: "auth.json", + contents: '{"codex":true}', + mode: 0o600, + }, + ], + }, + }, + }); + }); + + it("resolves user-scoped material through the narrow store interface", async () => { + const resolveForRuntime = vi.fn().mockResolvedValue({ + provider: "codex", + kind: "auth_json", + value: "{}", + }); + + await expect( + resolveByoSubscriptionRuntimeCredentialMaterialization({ + store: { resolveForRuntime }, + companyId: "company-1", + userId: "user-1", + provider: "codex", + agentId: "agent-1", + issueId: "issue-1", + heartbeatRunId: "run-1", + }), + ).resolves.toEqual({ + provider: "codex", + assets: { + home: { + files: [ + { + relativePath: "auth.json", + contents: "{}", + mode: 0o600, + }, + ], + }, + }, + }); + + expect(resolveForRuntime).toHaveBeenCalledWith({ + companyId: "company-1", + userId: "user-1", + provider: "codex", + agentId: "agent-1", + issueId: "issue-1", + heartbeatRunId: "run-1", + }); + }); + + it("maps decrypted stored credential records to provider-neutral material", () => { + expect( + byoSubscriptionCredentialMaterialFromDecrypted({ + provider: "claude", + credentialKind: "claude_oauth_token", + material: "oauth-token", + }), + ).toEqual({ + provider: "claude", + kind: "oauth_token", + value: "oauth-token", + }); + + expect( + byoSubscriptionCredentialMaterialFromDecrypted({ + provider: "claude", + credentialKind: "claude_credentials_json", + material: "{}", + }), + ).toEqual({ + provider: "claude", + kind: "credentials_json", + value: "{}", + }); + + expect( + byoSubscriptionCredentialMaterialFromDecrypted({ + provider: "codex", + credentialKind: "codex_auth_json", + material: "{}", + }), + ).toEqual({ + provider: "codex", + kind: "auth_json", + value: "{}", + }); + }); + + it("does not resolve without a user, store, supported adapter, or matching provider", async () => { + const store: ByoSubscriptionCredentialStore = { + resolveForRuntime: vi.fn().mockResolvedValue({ + provider: "claude", + kind: "oauth_token", + value: "token", + }), + }; + + await expect( + resolveByoSubscriptionRuntimeCredentialMaterialization({ + store, + companyId: "company-1", + userId: null, + provider: "claude", + }), + ).resolves.toBeNull(); + await expect( + resolveByoSubscriptionRuntimeCredentialMaterialization({ + store: null, + companyId: "company-1", + userId: "user-1", + provider: "claude", + }), + ).resolves.toBeNull(); + await expect( + resolveByoSubscriptionRuntimeCredentialMaterialization({ + store, + companyId: "company-1", + userId: "user-1", + provider: "codex", + }), + ).resolves.toBeNull(); + + expect(providerForSubscriptionCredentialAdapter("claude_local")).toBe("claude"); + expect(providerForSubscriptionCredentialAdapter("codex_local")).toBe("codex"); + expect(providerForSubscriptionCredentialAdapter("gemini_local")).toBeNull(); + }); +}); diff --git a/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts index 0e50bd7b6a8..88358fd7f4a 100644 --- a/server/src/services/environment-execution-target.ts +++ b/server/src/services/environment-execution-target.ts @@ -3,6 +3,7 @@ import type { Environment, EnvironmentLease } from "@paperclipai/shared"; import { adapterExecutionTargetToRemoteSpec, type AdapterExecutionTarget, + type AdapterRuntimeCredentialMaterialization, } from "@paperclipai/adapter-utils/execution-target"; import { parseObject } from "../adapters/utils.js"; import { resolveEnvironmentDriverConfigForRuntime } from "./environment-config.js"; @@ -23,13 +24,22 @@ export async function resolveEnvironmentExecutionTarget(input: { leaseMetadata: Record | null; lease?: EnvironmentLease | null; environmentRuntime?: EnvironmentRuntimeService | null; + runtimeCredentialMaterialization?: AdapterRuntimeCredentialMaterialization | null; }): Promise { - if (input.environment.driver === "local") { + const withRuntimeCredentials = (target: Target): Target => { + if (!input.runtimeCredentialMaterialization) return target; return { + ...target, + runtimeCredentialMaterialization: input.runtimeCredentialMaterialization, + }; + }; + + if (input.environment.driver === "local") { + return withRuntimeCredentials({ kind: "local", environmentId: input.environment.id ?? null, leaseId: input.leaseId ?? null, - }; + }); } if (input.environment.driver === "sandbox") { @@ -64,7 +74,7 @@ export async function resolveEnvironmentExecutionTarget(input: { ? input.leaseMetadata.shellCommand : null; - return { + return withRuntimeCredentials({ kind: "remote", transport: "sandbox", providerKey: parsed.config.provider, @@ -102,7 +112,7 @@ export async function resolveEnvironmentExecutionTarget(input: { }, } : undefined, - }; + }); } if ( @@ -134,7 +144,7 @@ export async function resolveEnvironmentExecutionTarget(input: { ? input.leaseMetadata.remoteCwd.trim() : parsed.config.remoteWorkspacePath; - return { + return withRuntimeCredentials({ kind: "remote", transport: "ssh", environmentId: input.environment.id ?? null, @@ -150,7 +160,7 @@ export async function resolveEnvironmentExecutionTarget(input: { strictHostKeyChecking: parsed.config.strictHostKeyChecking, remoteCwd, }, - }; + }); } export async function resolveEnvironmentExecutionTransport( diff --git a/server/src/services/environment-run-orchestrator.ts b/server/src/services/environment-run-orchestrator.ts index 1add1a2b24b..b91224a04c3 100644 --- a/server/src/services/environment-run-orchestrator.ts +++ b/server/src/services/environment-run-orchestrator.ts @@ -39,6 +39,7 @@ import { adapterExecutionTargetToRemoteSpec, type AdapterExecutionTarget, type AdapterRemoteExecutionSpec, + type AdapterRuntimeCredentialMaterialization, } from "@paperclipai/adapter-utils/execution-target"; import { buildWorkspaceRealizationRequest } from "./workspace-realization.js"; import { executionWorkspaceService } from "./execution-workspaces.js"; @@ -339,6 +340,7 @@ export function environmentRunOrchestrator( executionWorkspace: RealizedExecutionWorkspace; effectiveExecutionWorkspaceMode: string | null; persistedExecutionWorkspace: ExecutionWorkspace | null; + runtimeCredentialMaterialization?: AdapterRuntimeCredentialMaterialization | null; }): Promise { const { environment, @@ -478,6 +480,7 @@ export function environmentRunOrchestrator( leaseMetadata: (lease.metadata as Record | null) ?? null, lease, environmentRuntime, + runtimeCredentialMaterialization: input.runtimeCredentialMaterialization, }); } catch (err) { throw new EnvironmentRunError( diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index ce89bc96521..e2fb4f77d52 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; import { randomUUID } from "node:crypto"; -import { and, asc, desc, eq, getTableColumns, gt, gte, inArray, isNull, lt, lte, notInArray, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, getTableColumns, gt, gte, inArray, isNotNull, isNull, lt, lte, notInArray, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, @@ -25,6 +25,7 @@ import { } from "@paperclipai/shared"; import { agents, + agentConfigRevisions, agentRuntimeState, agentTaskSessions, agentWakeupRequests, @@ -193,6 +194,11 @@ import { parseExecutionPolicyBootstrapEnv } from "./execution-policy-bootstrap.j import { environmentRuntimeService } from "./environment-runtime.js"; import { skillVersionSelectionMap } from "./runtime-skill-selections.js"; import { environmentRunOrchestrator } from "./environment-run-orchestrator.js"; +import { + createSubscriptionCredentialRuntimeStore, + providerForSubscriptionCredentialAdapter, + resolveByoSubscriptionRuntimeCredentialMaterialization, +} from "./runtime-credential-materialization.js"; import { isUnsafeSessionWorkspaceCwd } from "./session-workspace-cwd.js"; import { assertLowTrustRuntimeServicesAllowed, @@ -1724,6 +1730,16 @@ function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } +function readRequestedUserIdFromContext(contextSnapshot: Record): string | null { + const explicitRuntimeUserId = + readNonEmptyString(contextSnapshot.runtimeCredentialUserId) ?? + readNonEmptyString(contextSnapshot.subscriptionCredentialUserId); + if (explicitRuntimeUserId) return explicitRuntimeUserId; + return readNonEmptyString(contextSnapshot.requestedByActorType) === "user" + ? readNonEmptyString(contextSnapshot.requestedByActorId) + : null; +} + function readModelProfileKey(value: unknown): ModelProfileKey | null { return MODEL_PROFILE_KEYS.includes(value as ModelProfileKey) ? (value as ModelProfileKey) @@ -3395,6 +3411,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const treeControlSvc = issueTreeControlService(db); const executionWorkspacesSvc = executionWorkspaceService(db); const environmentsSvc = environmentService(db); + const subscriptionCredentialRuntimeStore = createSubscriptionCredentialRuntimeStore(db); const environmentRuntime = options.environmentRuntime ?? environmentRuntimeService(db, { pluginWorkerManager: options.pluginWorkerManager, }); @@ -3548,6 +3565,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) executionWorkspaceId: issues.executionWorkspaceId, executionWorkspacePreference: issues.executionWorkspacePreference, assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + createdByUserId: issues.createdByUserId, assigneeAdapterOverrides: issues.assigneeAdapterOverrides, executionPolicy: issues.executionPolicy, executionWorkspaceSettings: issues.executionWorkspaceSettings, @@ -3560,6 +3579,66 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .then((rows) => rows[0] ?? null); } + async function resolveAgentCredentialOwnerUserId(companyId: string, agentId: string): Promise { + return db + .select({ createdByUserId: agentConfigRevisions.createdByUserId }) + .from(agentConfigRevisions) + .where( + and( + eq(agentConfigRevisions.companyId, companyId), + eq(agentConfigRevisions.agentId, agentId), + isNotNull(agentConfigRevisions.createdByUserId), + ), + ) + .orderBy(asc(agentConfigRevisions.createdAt)) + .limit(1) + .then((rows) => readNonEmptyString(rows[0]?.createdByUserId)); + } + + async function resolveRunRequestedByUserId(run: typeof heartbeatRuns.$inferSelect): Promise { + if (!run.wakeupRequestId) return null; + return db + .select({ + requestedByActorType: agentWakeupRequests.requestedByActorType, + requestedByActorId: agentWakeupRequests.requestedByActorId, + }) + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.id, run.wakeupRequestId), + eq(agentWakeupRequests.companyId, run.companyId), + ), + ) + .limit(1) + .then((rows) => { + const row = rows[0] ?? null; + return row?.requestedByActorType === "user" + ? readNonEmptyString(row.requestedByActorId) + : null; + }); + } + + async function resolveRuntimeCredentialUserId(input: { + companyId: string; + agentId: string; + run: typeof heartbeatRuns.$inferSelect; + contextSnapshot: Record; + issueContext: Awaited> | null; + }): Promise { + const contextUserId = readRequestedUserIdFromContext(input.contextSnapshot); + if (contextUserId) return contextUserId; + + const runRequestedByUserId = await resolveRunRequestedByUserId(input.run); + if (runRequestedByUserId) return runRequestedByUserId; + + const issueUserId = + readNonEmptyString(input.issueContext?.assigneeUserId) ?? + readNonEmptyString(input.issueContext?.createdByUserId); + if (issueUserId) return issueUserId; + + return await resolveAgentCredentialOwnerUserId(input.companyId, input.agentId); + } + async function getRoutineEnvForExecutionIssue( companyId: string, issueContext: Awaited> | null, @@ -9080,6 +9159,25 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) lease: acquiredEnvironment.lease, leaseContext: acquiredEnvironment.leaseContext, }; + const runtimeCredentialProvider = providerForSubscriptionCredentialAdapter(agent.adapterType); + const runtimeCredentialUserId = runtimeCredentialProvider + ? await resolveRuntimeCredentialUserId({ + companyId: agent.companyId, + agentId: agent.id, + run, + contextSnapshot: context, + issueContext, + }) + : null; + const runtimeCredentialMaterialization = await resolveByoSubscriptionRuntimeCredentialMaterialization({ + store: subscriptionCredentialRuntimeStore, + companyId: agent.companyId, + userId: runtimeCredentialUserId, + provider: runtimeCredentialProvider, + agentId: agent.id, + issueId: issueId ?? null, + heartbeatRunId: run.id, + }); const realizationResult = await envOrchestrator.realizeForRun({ environment: selectedEnvironment, lease: activeEnvironmentLease.lease, @@ -9090,6 +9188,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) executionWorkspace, effectiveExecutionWorkspaceMode, persistedExecutionWorkspace, + runtimeCredentialMaterialization, }); activeEnvironmentLease = { ...activeEnvironmentLease, diff --git a/server/src/services/runtime-credential-materialization.ts b/server/src/services/runtime-credential-materialization.ts new file mode 100644 index 00000000000..74f8eb11d23 --- /dev/null +++ b/server/src/services/runtime-credential-materialization.ts @@ -0,0 +1,162 @@ +import type { AdapterRuntimeCredentialFile, AdapterRuntimeCredentialMaterialization } from "@paperclipai/adapter-utils/execution-target"; +import type { Db } from "@paperclipai/db"; +import type { SubscriptionCredentialKind } from "@paperclipai/shared"; +import { HttpError } from "../errors.js"; +import { + subscriptionCredentialService, + type DecryptedSubscriptionCredential, +} from "./subscription-credentials.js"; + +export type ByoSubscriptionCredentialProvider = "claude" | "codex"; + +export type ByoSubscriptionCredentialMaterial = + | { + provider: "claude"; + kind: "oauth_token"; + value: string; + } + | { + provider: "claude"; + kind: "credentials_json"; + value: string; + } + | { + provider: "codex"; + kind: "auth_json"; + value: string; + }; + +export interface ByoSubscriptionCredentialStore { + resolveForRuntime(input: { + companyId: string; + userId: string; + provider: ByoSubscriptionCredentialProvider; + agentId?: string | null; + issueId?: string | null; + heartbeatRunId?: string | null; + }): Promise; +} + +function materialKindFromStoredCredential( + provider: ByoSubscriptionCredentialProvider, + credentialKind: SubscriptionCredentialKind, +): ByoSubscriptionCredentialMaterial["kind"] | null { + switch (credentialKind) { + case "claude_oauth_token": + return provider === "claude" ? "oauth_token" : null; + case "claude_credentials_json": + return provider === "claude" ? "credentials_json" : null; + case "codex_auth_json": + return provider === "codex" ? "auth_json" : null; + } +} + +export function byoSubscriptionCredentialMaterialFromDecrypted( + credential: Pick, +): ByoSubscriptionCredentialMaterial | null { + const provider = providerForSubscriptionCredentialAdapter(`${credential.provider}_local`); + if (!provider || provider !== credential.provider) return null; + const kind = materialKindFromStoredCredential(provider, credential.credentialKind as SubscriptionCredentialKind); + if (!kind) return null; + return { + provider, + kind, + value: credential.material, + } as ByoSubscriptionCredentialMaterial; +} + +export function createSubscriptionCredentialRuntimeStore(db: Db): ByoSubscriptionCredentialStore { + const credentials = subscriptionCredentialService(db); + return { + async resolveForRuntime(input) { + try { + const credential = await credentials.resolveDecryptedMaterial( + input.companyId, + input.userId, + input.provider, + ); + return byoSubscriptionCredentialMaterialFromDecrypted(credential); + } catch (err) { + if (err instanceof HttpError && err.status === 404) { + return null; + } + throw err; + } + }, + }; +} + +function runtimeCredentialFile(relativePath: string, contents: string): AdapterRuntimeCredentialFile { + return { + relativePath, + contents, + mode: 0o600, + }; +} + +export function providerForSubscriptionCredentialAdapter(adapterType: string | null | undefined): ByoSubscriptionCredentialProvider | null { + switch (adapterType) { + case "claude_local": + return "claude"; + case "codex_local": + return "codex"; + default: + return null; + } +} + +export function buildByoSubscriptionRuntimeCredentialMaterialization( + material: ByoSubscriptionCredentialMaterial, +): AdapterRuntimeCredentialMaterialization { + switch (material.kind) { + case "oauth_token": + return { + provider: material.provider, + env: { + CLAUDE_CODE_OAUTH_TOKEN: material.value, + }, + }; + case "credentials_json": + return { + provider: material.provider, + assets: { + "config-seed": { + files: [runtimeCredentialFile(".credentials.json", material.value)], + }, + }, + }; + case "auth_json": + return { + provider: material.provider, + assets: { + home: { + files: [runtimeCredentialFile("auth.json", material.value)], + }, + }, + }; + } +} + +export async function resolveByoSubscriptionRuntimeCredentialMaterialization(input: { + store?: ByoSubscriptionCredentialStore | null; + companyId: string; + userId?: string | null; + provider: ByoSubscriptionCredentialProvider | null; + agentId?: string | null; + issueId?: string | null; + heartbeatRunId?: string | null; +}): Promise { + if (!input.store || !input.provider || !input.userId) return null; + + const material = await input.store.resolveForRuntime({ + companyId: input.companyId, + userId: input.userId, + provider: input.provider, + agentId: input.agentId ?? null, + issueId: input.issueId ?? null, + heartbeatRunId: input.heartbeatRunId ?? null, + }); + if (!material || material.provider !== input.provider) return null; + + return buildByoSubscriptionRuntimeCredentialMaterialization(material); +}