feat(runtime): materialize BYO subscription credentials

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Cody authored and Devin Foley committed 2026-06-24 14:49:52 -07:00
1 parent 39f14ce349
commit c29c5e6158
12 files changed
+1058 -82

No files matched your search

@@ -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 } : {}),
};
}
@@ -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<string> {
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: "{}" }],
},
},
});
});
});
@@ -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<string, string>;
/**
* Credential files keyed by managed runtime asset name, e.g. "home" for
* Codex or "config-seed" for Claude.
*/
assets?: Record<string, AdapterRuntimeCredentialAsset>;
}
export interface AdapterRuntimeCredentialMaterializationHost {
runtimeCredentialMaterialization?: AdapterRuntimeCredentialMaterialization | null;
}
function isObject(value: unknown): value is Record<string, unknown> {
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<string, AdapterRuntimeCredentialAsset> = {};
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<string, string> {
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<void>;
}
export async function materializeAdapterRuntimeCredentialAsset(input: {
baseDir?: string | null;
files: AdapterRuntimeCredentialFile[];
tempPrefix?: string;
}): Promise<MaterializedRuntimeCredentialAsset> {
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);
},
};
}
+10 -3
View File
@@ -1108,10 +1108,16 @@ export function renderPaperclipWakePrompt(
return lines.join("\n").trim();
}
export function redactEnvForLogs(env: Record<string, string>): Record<string, string> {
export function redactEnvForLogs(
env: Record<string, string>,
options: { explicitSensitiveKeys?: Iterable<string> } = {},
): Record<string, string> {
const explicitSensitiveKeys = new Set(
Array.from(options.explicitSensitiveKeys ?? [], (key) => key.trim()).filter(Boolean),
);
const redacted: Record<string, string> = {};
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<string, string>;
includeRuntimeKeys?: string[];
explicitSensitiveKeys?: Iterable<string>;
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<string, string> {
@@ -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<Cl
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget);
const runtimeCredentialEnv = adapterRuntimeCredentialEnv(executionTarget);
const runtimeCredentialEnvKeys = Object.keys(runtimeCredentialEnv);
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({
workspaceCwd: effectiveWorkspaceCwd,
@@ -268,6 +273,7 @@ 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 (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
@@ -302,6 +308,7 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME", "CLAUDE_CONFIG_DIR"],
explicitSensitiveKeys: runtimeCredentialEnvKeys,
resolvedCommand,
});
@@ -371,6 +378,9 @@ 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(
config.promptTemplate,
@@ -430,7 +440,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
0,
asNumber(config.terminalResultCleanupGraceMs, 5_000),
);
const effectiveEnv = Object.fromEntries(
let effectiveEnv = Object.fromEntries(
Object.entries({ ...process.env, ...env }).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
@@ -467,43 +477,55 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
});
const useManagedRemoteClaudeConfig =
executionTargetIsRemote &&
adapterExecutionTargetUsesManagedHome(executionTarget) &&
!hasExplicitClaudeConfigDir;
!hasExplicitClaudeConfigDir &&
(adapterExecutionTargetUsesManagedHome(executionTarget) || hasRuntimeClaudeConfigFiles);
const claudeConfigSeedDir = useManagedRemoteClaudeConfig
? await prepareClaudeConfigSeed(process.env, onLog, agent.companyId)
: null;
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,
},
...(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<AdapterExec
? preparedExecutionTargetRuntime?.runtimeRootDir ??
path.posix.join(effectiveExecutionCwd, ".paperclip-runtime", "claude")
: null;
const remoteClaudeConfigSeedDir = claudeConfigSeedDir && remoteClaudeRuntimeRoot
const remoteClaudeConfigSeedDir = runtimeClaudeConfigSeedDir && remoteClaudeRuntimeRoot
? preparedExecutionTargetRuntime?.assetDirs["config-seed"] ??
path.posix.join(remoteClaudeRuntimeRoot, "config-seed")
: null;
@@ -570,6 +592,21 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
},
);
}
if (!executionTargetIsRemote && runtimeClaudeConfigAsset.materialized && !hasExplicitClaudeConfigDir) {
env.CLAUDE_CONFIG_DIR = runtimeClaudeConfigAsset.localDir;
effectiveEnv = Object.fromEntries(
Object.entries({ ...process.env, ...env }).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
const runtimeEnv = ensurePathInEnv(effectiveEnv);
loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME", "CLAUDE_CONFIG_DIR"],
explicitSensitiveKeys: runtimeCredentialEnvKeys,
resolvedCommand,
});
}
let paperclipBridge: Awaited<ReturnType<typeof startAdapterExecutionTargetPaperclipBridge>> = null;
if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(runtimeExecutionTarget)) {
paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({
@@ -587,6 +624,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME", "CLAUDE_CONFIG_DIR"],
explicitSensitiveKeys: runtimeCredentialEnvKeys,
resolvedCommand,
});
if (remoteClaudeConfigDir) {
@@ -1070,4 +1108,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
await restoreRemoteWorkspace();
}
}
} finally {
await runtimeClaudeConfigAsset.cleanup();
}
}
@@ -3,6 +3,8 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
import {
adapterRuntimeCredentialAssetFiles,
adapterRuntimeCredentialEnv,
adapterExecutionTargetIsRemote,
adapterExecutionTargetRemoteCwd,
overrideAdapterExecutionTargetRemoteCwd,
@@ -12,6 +14,7 @@ import {
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
materializeAdapterRuntimeCredentialAsset,
prepareAdapterExecutionTargetRuntime,
readAdapterExecutionTarget,
resolveAdapterExecutionTargetTimeoutSec,
@@ -366,6 +369,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
legacyRemoteExecution: ctx.executionTransport?.remoteExecution,
});
const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget);
const runtimeCredentialEnv = adapterRuntimeCredentialEnv(executionTarget);
const runtimeCredentialEnvKeys = Object.keys(runtimeCredentialEnv);
const codexHomeCredentialFiles = adapterRuntimeCredentialAssetFiles(executionTarget, "home");
const configuredCodexHome =
typeof envConfig.CODEX_HOME === "string" && envConfig.CODEX_HOME.trim().length > 0
? path.resolve(envConfig.CODEX_HOME.trim())
@@ -397,44 +403,54 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const defaultCodexHome = resolveManagedCodexHomeDir(process.env, agent.companyId);
const effectiveCodexHome = configuredCodexHome ?? defaultCodexHome;
await fs.mkdir(effectiveCodexHome, { recursive: true });
// 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(effectiveCodexHome))
) {
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",
),
);
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<ReturnType<typeof prepareCodexRuntimeConfig>> | 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<AdapterExec
assets: [
{
key: "home",
localDir: effectiveCodexHome,
localDir: runtimeCodexHome,
followSymlinks: true,
// Transient Codex home dirs (`tmp/`, `.tmp/`) can hold symlinks
// to the host Codex binary (e.g. `tmp/arg0`). With
@@ -572,7 +588,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (runtimePrimaryUrl) {
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
}
env.CODEX_HOME = remoteCodexHome ?? effectiveCodexHome;
Object.assign(env, runtimeCredentialEnv);
env.CODEX_HOME = remoteCodexHome ?? runtimeCodexHome;
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
@@ -617,6 +634,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
explicitSensitiveKeys: runtimeCredentialEnvKeys,
resolvedCommand,
});
@@ -761,8 +779,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
"Added --skip-git-repo-check for sandbox execution because Codex requires an explicit trust bypass in headless remote workspaces.",
);
}
if (preparedRuntimeConfig.notes.length > 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<AdapterExec
// If the process dies before reaching this, the next
// prepareCodexRuntimeConfig restores the original from the pre-run backup
// written at prepare time.
await preparedRuntimeConfig.cleanup();
if (preparedRuntimeConfig) {
await preparedRuntimeConfig.cleanup();
}
await runtimeCodexHomeAsset.cleanup();
}
}
@@ -8,6 +8,10 @@ vi.mock("../services/environment-config.js", () => ({
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",
@@ -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<ByoSubscriptionCredentialStore["resolveForRuntime"]>().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();
});
});
@@ -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<string, unknown> | null;
lease?: EnvironmentLease | null;
environmentRuntime?: EnvironmentRuntimeService | null;
runtimeCredentialMaterialization?: AdapterRuntimeCredentialMaterialization | null;
}): Promise<AdapterExecutionTarget | null> {
if (input.environment.driver === "local") {
const withRuntimeCredentials = <Target extends AdapterExecutionTarget>(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(
@@ -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<EnvironmentRealizationResult> {
const {
environment,
@@ -478,6 +480,7 @@ export function environmentRunOrchestrator(
leaseMetadata: (lease.metadata as Record<string, unknown> | null) ?? null,
lease,
environmentRuntime,
runtimeCredentialMaterialization: input.runtimeCredentialMaterialization,
});
} catch (err) {
throw new EnvironmentRunError(
+100 -1
View File
@@ -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, unknown>): 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<string | null> {
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<string | null> {
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<string, unknown>;
issueContext: Awaited<ReturnType<typeof getIssueExecutionContext>> | null;
}): Promise<string | null> {
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<ReturnType<typeof getIssueExecutionContext>> | 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,
@@ -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<ByoSubscriptionCredentialMaterial | null>;
}
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<DecryptedSubscriptionCredential, "provider" | "credentialKind" | "material">,
): 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<AdapterRuntimeCredentialMaterialization | null> {
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);
}