mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
feat(runtime): materialize BYO subscription credentials
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user