Include full contents of all nested repositories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 16:25:02 +01:00
parent 14ff8fd54c
commit 2401ed446f
7271 changed files with 1310112 additions and 6 deletions

View File

@@ -0,0 +1,447 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { runSecretsApply } from "./apply.js";
import type { SecretsApplyPlan } from "./plan.js";
function stripVolatileConfigMeta(input: string): Record<string, unknown> {
const parsed = JSON.parse(input) as Record<string, unknown>;
const meta =
parsed.meta && typeof parsed.meta === "object" && !Array.isArray(parsed.meta)
? { ...(parsed.meta as Record<string, unknown>) }
: undefined;
if (meta && "lastTouchedAt" in meta) {
delete meta.lastTouchedAt;
}
if (meta) {
parsed.meta = meta;
}
return parsed;
}
describe("secrets apply", () => {
let rootDir = "";
let stateDir = "";
let configPath = "";
let authStorePath = "";
let authJsonPath = "";
let envPath = "";
let env: NodeJS.ProcessEnv;
beforeEach(async () => {
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-apply-"));
stateDir = path.join(rootDir, ".openclaw");
configPath = path.join(stateDir, "openclaw.json");
authStorePath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json");
authJsonPath = path.join(stateDir, "agents", "main", "agent", "auth.json");
envPath = path.join(stateDir, ".env");
env = {
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: configPath,
OPENAI_API_KEY: "sk-live-env",
};
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.mkdir(path.dirname(authStorePath), { recursive: true });
await fs.writeFile(
configPath,
`${JSON.stringify(
{
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
api: "openai-completions",
apiKey: "sk-openai-plaintext",
models: [{ id: "gpt-5", name: "gpt-5" }],
},
},
},
},
null,
2,
)}\n`,
"utf8",
);
await fs.writeFile(
authStorePath,
`${JSON.stringify(
{
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk-openai-plaintext",
},
},
},
null,
2,
)}\n`,
"utf8",
);
await fs.writeFile(
authJsonPath,
`${JSON.stringify(
{
openai: {
type: "api_key",
key: "sk-openai-plaintext",
},
},
null,
2,
)}\n`,
"utf8",
);
await fs.writeFile(envPath, "OPENAI_API_KEY=sk-openai-plaintext\nUNRELATED=value\n", "utf8");
});
afterEach(async () => {
await fs.rm(rootDir, { recursive: true, force: true });
});
it("preflights and applies one-way scrub without plaintext backups", async () => {
const plan: SecretsApplyPlan = {
version: 1,
protocolVersion: 1,
generatedAt: new Date().toISOString(),
generatedBy: "manual",
targets: [
{
type: "models.providers.apiKey",
path: "models.providers.openai.apiKey",
providerId: "openai",
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
],
options: {
scrubEnv: true,
scrubAuthProfilesForProviderTargets: true,
scrubLegacyAuthJson: true,
},
};
const dryRun = await runSecretsApply({ plan, env, write: false });
expect(dryRun.mode).toBe("dry-run");
expect(dryRun.changed).toBe(true);
const applied = await runSecretsApply({ plan, env, write: true });
expect(applied.mode).toBe("write");
expect(applied.changed).toBe(true);
const nextConfig = JSON.parse(await fs.readFile(configPath, "utf8")) as {
models: { providers: { openai: { apiKey: unknown } } };
};
expect(nextConfig.models.providers.openai.apiKey).toEqual({
source: "env",
provider: "default",
id: "OPENAI_API_KEY",
});
const nextAuthStore = JSON.parse(await fs.readFile(authStorePath, "utf8")) as {
profiles: { "openai:default": { key?: string; keyRef?: unknown } };
};
expect(nextAuthStore.profiles["openai:default"].key).toBeUndefined();
expect(nextAuthStore.profiles["openai:default"].keyRef).toBeUndefined();
const nextAuthJson = JSON.parse(await fs.readFile(authJsonPath, "utf8")) as Record<
string,
unknown
>;
expect(nextAuthJson.openai).toBeUndefined();
const nextEnv = await fs.readFile(envPath, "utf8");
expect(nextEnv).not.toContain("sk-openai-plaintext");
expect(nextEnv).toContain("UNRELATED=value");
});
it("is idempotent on repeated write applies", async () => {
const plan: SecretsApplyPlan = {
version: 1,
protocolVersion: 1,
generatedAt: new Date().toISOString(),
generatedBy: "manual",
targets: [
{
type: "models.providers.apiKey",
path: "models.providers.openai.apiKey",
providerId: "openai",
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
],
options: {
scrubEnv: true,
scrubAuthProfilesForProviderTargets: true,
scrubLegacyAuthJson: true,
},
};
const first = await runSecretsApply({ plan, env, write: true });
expect(first.changed).toBe(true);
const configAfterFirst = await fs.readFile(configPath, "utf8");
const authStoreAfterFirst = await fs.readFile(authStorePath, "utf8");
const authJsonAfterFirst = await fs.readFile(authJsonPath, "utf8");
const envAfterFirst = await fs.readFile(envPath, "utf8");
// Second apply should be a true no-op and avoid file writes entirely.
await fs.chmod(configPath, 0o400);
await fs.chmod(authStorePath, 0o400);
const second = await runSecretsApply({ plan, env, write: true });
expect(second.mode).toBe("write");
const configAfterSecond = await fs.readFile(configPath, "utf8");
expect(stripVolatileConfigMeta(configAfterSecond)).toEqual(
stripVolatileConfigMeta(configAfterFirst),
);
await expect(fs.readFile(authStorePath, "utf8")).resolves.toBe(authStoreAfterFirst);
await expect(fs.readFile(authJsonPath, "utf8")).resolves.toBe(authJsonAfterFirst);
await expect(fs.readFile(envPath, "utf8")).resolves.toBe(envAfterFirst);
});
it("applies targets safely when map keys contain dots", async () => {
await fs.writeFile(
configPath,
`${JSON.stringify(
{
models: {
providers: {
"openai.dev": {
baseUrl: "https://api.openai.com/v1",
api: "openai-completions",
apiKey: "sk-openai-plaintext",
models: [{ id: "gpt-5", name: "gpt-5" }],
},
},
},
},
null,
2,
)}\n`,
"utf8",
);
const plan: SecretsApplyPlan = {
version: 1,
protocolVersion: 1,
generatedAt: new Date().toISOString(),
generatedBy: "manual",
targets: [
{
type: "models.providers.apiKey",
path: "models.providers.openai.dev.apiKey",
pathSegments: ["models", "providers", "openai.dev", "apiKey"],
providerId: "openai.dev",
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
],
options: {
scrubEnv: false,
scrubAuthProfilesForProviderTargets: false,
scrubLegacyAuthJson: false,
},
};
const result = await runSecretsApply({ plan, env, write: true });
expect(result.changed).toBe(true);
const nextConfig = JSON.parse(await fs.readFile(configPath, "utf8")) as {
models?: {
providers?: Record<string, { apiKey?: unknown }>;
};
};
expect(nextConfig.models?.providers?.["openai.dev"]?.apiKey).toEqual({
source: "env",
provider: "default",
id: "OPENAI_API_KEY",
});
expect(nextConfig.models?.providers?.openai).toBeUndefined();
});
it("migrates skills entries apiKey targets alongside provider api keys", async () => {
await fs.writeFile(
configPath,
`${JSON.stringify(
{
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
api: "openai-completions",
apiKey: "sk-openai-plaintext",
models: [{ id: "gpt-5", name: "gpt-5" }],
},
},
},
skills: {
entries: {
"qa-secret-test": {
enabled: true,
apiKey: "sk-skill-plaintext",
},
},
},
},
null,
2,
)}\n`,
"utf8",
);
const plan: SecretsApplyPlan = {
version: 1,
protocolVersion: 1,
generatedAt: new Date().toISOString(),
generatedBy: "manual",
targets: [
{
type: "models.providers.apiKey",
path: "models.providers.openai.apiKey",
pathSegments: ["models", "providers", "openai", "apiKey"],
providerId: "openai",
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
{
type: "skills.entries.apiKey",
path: "skills.entries.qa-secret-test.apiKey",
pathSegments: ["skills", "entries", "qa-secret-test", "apiKey"],
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
],
options: {
scrubEnv: true,
scrubAuthProfilesForProviderTargets: true,
scrubLegacyAuthJson: true,
},
};
const result = await runSecretsApply({ plan, env, write: true });
expect(result.changed).toBe(true);
const nextConfig = JSON.parse(await fs.readFile(configPath, "utf8")) as {
models: { providers: { openai: { apiKey: unknown } } };
skills: { entries: { "qa-secret-test": { apiKey: unknown } } };
};
expect(nextConfig.models.providers.openai.apiKey).toEqual({
source: "env",
provider: "default",
id: "OPENAI_API_KEY",
});
expect(nextConfig.skills.entries["qa-secret-test"].apiKey).toEqual({
source: "env",
provider: "default",
id: "OPENAI_API_KEY",
});
const rawConfig = await fs.readFile(configPath, "utf8");
expect(rawConfig).not.toContain("sk-openai-plaintext");
expect(rawConfig).not.toContain("sk-skill-plaintext");
});
it("rejects plan targets that do not match allowed secret-bearing paths", async () => {
const plan: SecretsApplyPlan = {
version: 1,
protocolVersion: 1,
generatedAt: new Date().toISOString(),
generatedBy: "manual",
targets: [
{
type: "models.providers.apiKey",
path: "models.providers.openai.baseUrl",
pathSegments: ["models", "providers", "openai", "baseUrl"],
providerId: "openai",
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
],
};
await expect(runSecretsApply({ plan, env, write: false })).rejects.toThrow(
"Invalid plan target path",
);
});
it("rejects plan targets with forbidden prototype-like path segments", async () => {
const plan: SecretsApplyPlan = {
version: 1,
protocolVersion: 1,
generatedAt: new Date().toISOString(),
generatedBy: "manual",
targets: [
{
type: "skills.entries.apiKey",
path: "skills.entries.__proto__.apiKey",
pathSegments: ["skills", "entries", "__proto__", "apiKey"],
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
],
};
await expect(runSecretsApply({ plan, env, write: false })).rejects.toThrow(
"Invalid plan target path",
);
});
it("applies provider upserts and deletes from plan", async () => {
await fs.writeFile(
configPath,
`${JSON.stringify(
{
secrets: {
providers: {
envmain: { source: "env" },
fileold: { source: "file", path: "/tmp/old-secrets.json", mode: "json" },
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
api: "openai-completions",
models: [{ id: "gpt-5", name: "gpt-5" }],
},
},
},
},
null,
2,
)}\n`,
"utf8",
);
const plan: SecretsApplyPlan = {
version: 1,
protocolVersion: 1,
generatedAt: new Date().toISOString(),
generatedBy: "manual",
providerUpserts: {
filemain: {
source: "file",
path: "/tmp/new-secrets.json",
mode: "json",
},
},
providerDeletes: ["fileold"],
targets: [],
};
const result = await runSecretsApply({ plan, env, write: true });
expect(result.changed).toBe(true);
const nextConfig = JSON.parse(await fs.readFile(configPath, "utf8")) as {
secrets?: {
providers?: Record<string, unknown>;
};
};
expect(nextConfig.secrets?.providers?.fileold).toBeUndefined();
expect(nextConfig.secrets?.providers?.filemain).toEqual({
source: "file",
path: "/tmp/new-secrets.json",
mode: "json",
});
});
});

View File

@@ -0,0 +1,593 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { listAgentIds, resolveAgentDir } from "../agents/agent-scope.js";
import { loadAuthProfileStoreForSecretsRuntime } from "../agents/auth-profiles.js";
import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js";
import { normalizeProviderId } from "../agents/model-selection.js";
import { resolveStateDir, type OpenClawConfig } from "../config/config.js";
import type { ConfigWriteOptions } from "../config/io.js";
import type { SecretProviderConfig } from "../config/types.secrets.js";
import { resolveConfigDir, resolveUserPath } from "../utils.js";
import { createSecretsConfigIO } from "./config-io.js";
import {
type SecretsApplyPlan,
type SecretsPlanTarget,
normalizeSecretsPlanOptions,
resolveValidatedTargetPathSegments,
} from "./plan.js";
import { listKnownSecretEnvVarNames } from "./provider-env-vars.js";
import { resolveSecretRefValue } from "./resolve.js";
import { prepareSecretsRuntimeSnapshot } from "./runtime.js";
import { isNonEmptyString, isRecord, writeTextFileAtomic } from "./shared.js";
type FileSnapshot = {
existed: boolean;
content: string;
mode: number;
};
type ApplyWrite = {
path: string;
content: string;
mode: number;
};
type ProjectedState = {
nextConfig: OpenClawConfig;
configPath: string;
configWriteOptions: ConfigWriteOptions;
authStoreByPath: Map<string, Record<string, unknown>>;
authJsonByPath: Map<string, Record<string, unknown>>;
envRawByPath: Map<string, string>;
changedFiles: Set<string>;
warnings: string[];
};
export type SecretsApplyResult = {
mode: "dry-run" | "write";
changed: boolean;
changedFiles: string[];
warningCount: number;
warnings: string[];
};
function getByPathSegments(root: unknown, segments: string[]): unknown {
if (segments.length === 0) {
return undefined;
}
let cursor: unknown = root;
for (const segment of segments) {
if (!isRecord(cursor)) {
return undefined;
}
cursor = cursor[segment];
}
return cursor;
}
function setByPathSegments(root: OpenClawConfig, segments: string[], value: unknown): boolean {
if (segments.length === 0) {
throw new Error("Target path is empty.");
}
let cursor: Record<string, unknown> = root as unknown as Record<string, unknown>;
let changed = false;
for (const segment of segments.slice(0, -1)) {
const existing = cursor[segment];
if (!isRecord(existing)) {
cursor[segment] = {};
changed = true;
}
cursor = cursor[segment] as Record<string, unknown>;
}
const leaf = segments[segments.length - 1] ?? "";
const previous = cursor[leaf];
if (!isDeepStrictEqual(previous, value)) {
cursor[leaf] = value;
changed = true;
}
return changed;
}
function deleteByPathSegments(root: OpenClawConfig, segments: string[]): boolean {
if (segments.length === 0) {
return false;
}
let cursor: Record<string, unknown> = root as unknown as Record<string, unknown>;
for (const segment of segments.slice(0, -1)) {
const existing = cursor[segment];
if (!isRecord(existing)) {
return false;
}
cursor = existing;
}
const leaf = segments[segments.length - 1] ?? "";
if (!Object.prototype.hasOwnProperty.call(cursor, leaf)) {
return false;
}
delete cursor[leaf];
return true;
}
function resolveTargetPathSegments(target: SecretsPlanTarget): string[] {
const resolved = resolveValidatedTargetPathSegments(target);
if (!resolved) {
throw new Error(`Invalid plan target path for ${target.type}: ${target.path}`);
}
return resolved;
}
function parseEnvValue(raw: string): string {
const trimmed = raw.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function scrubEnvRaw(
raw: string,
migratedValues: Set<string>,
allowedEnvKeys: Set<string>,
): {
nextRaw: string;
removed: number;
} {
if (migratedValues.size === 0 || allowedEnvKeys.size === 0) {
return { nextRaw: raw, removed: 0 };
}
const lines = raw.split(/\r?\n/);
const nextLines: string[] = [];
let removed = 0;
for (const line of lines) {
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
if (!match) {
nextLines.push(line);
continue;
}
const envKey = match[1] ?? "";
if (!allowedEnvKeys.has(envKey)) {
nextLines.push(line);
continue;
}
const parsedValue = parseEnvValue(match[2] ?? "");
if (migratedValues.has(parsedValue)) {
removed += 1;
continue;
}
nextLines.push(line);
}
const hadTrailingNewline = raw.endsWith("\n");
const joined = nextLines.join("\n");
return {
nextRaw:
hadTrailingNewline || joined.length === 0
? `${joined}${joined.endsWith("\n") ? "" : "\n"}`
: joined,
removed,
};
}
function collectAuthStorePaths(config: OpenClawConfig, stateDir: string): string[] {
const paths = new Set<string>();
// Scope default auth store discovery to the provided stateDir instead of
// ambient process env, so apply does not touch unrelated host-global stores.
paths.add(path.join(resolveUserPath(stateDir), "agents", "main", "agent", "auth-profiles.json"));
const agentsRoot = path.join(resolveUserPath(stateDir), "agents");
if (fs.existsSync(agentsRoot)) {
for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
paths.add(path.join(agentsRoot, entry.name, "agent", "auth-profiles.json"));
}
}
for (const agentId of listAgentIds(config)) {
if (agentId === "main") {
paths.add(
path.join(resolveUserPath(stateDir), "agents", "main", "agent", "auth-profiles.json"),
);
continue;
}
const agentDir = resolveAgentDir(config, agentId);
paths.add(resolveUserPath(resolveAuthStorePath(agentDir)));
}
return [...paths];
}
function collectAuthJsonPaths(stateDir: string): string[] {
const out: string[] = [];
const agentsRoot = path.join(resolveUserPath(stateDir), "agents");
if (!fs.existsSync(agentsRoot)) {
return out;
}
for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
const candidate = path.join(agentsRoot, entry.name, "agent", "auth.json");
if (fs.existsSync(candidate)) {
out.push(candidate);
}
}
return out;
}
function resolveGoogleChatRefPathSegments(pathSegments: string[]): string[] {
if (pathSegments.at(-1) === "serviceAccount") {
return [...pathSegments.slice(0, -1), "serviceAccountRef"];
}
throw new Error(
`Google Chat target path must end with "serviceAccount": ${pathSegments.join(".")}`,
);
}
function applyProviderPlanMutations(params: {
config: OpenClawConfig;
upserts: Record<string, SecretProviderConfig> | undefined;
deletes: string[] | undefined;
}): boolean {
const currentProviders = isRecord(params.config.secrets?.providers)
? structuredClone(params.config.secrets?.providers)
: {};
let changed = false;
for (const providerAlias of params.deletes ?? []) {
if (!Object.prototype.hasOwnProperty.call(currentProviders, providerAlias)) {
continue;
}
delete currentProviders[providerAlias];
changed = true;
}
for (const [providerAlias, providerConfig] of Object.entries(params.upserts ?? {})) {
const previous = currentProviders[providerAlias];
if (isDeepStrictEqual(previous, providerConfig)) {
continue;
}
currentProviders[providerAlias] = structuredClone(providerConfig);
changed = true;
}
if (!changed) {
return false;
}
params.config.secrets ??= {};
if (Object.keys(currentProviders).length === 0) {
if ("providers" in params.config.secrets) {
delete params.config.secrets.providers;
}
return true;
}
params.config.secrets.providers = currentProviders;
return true;
}
async function projectPlanState(params: {
plan: SecretsApplyPlan;
env: NodeJS.ProcessEnv;
}): Promise<ProjectedState> {
const io = createSecretsConfigIO({ env: params.env });
const { snapshot, writeOptions } = await io.readConfigFileSnapshotForWrite();
if (!snapshot.valid) {
throw new Error("Cannot apply secrets plan: config is invalid.");
}
const options = normalizeSecretsPlanOptions(params.plan.options);
const nextConfig = structuredClone(snapshot.config);
const stateDir = resolveStateDir(params.env, os.homedir);
const changedFiles = new Set<string>();
const warnings: string[] = [];
const scrubbedValues = new Set<string>();
const providerTargets = new Set<string>();
const configPath = resolveUserPath(snapshot.path);
const providerConfigChanged = applyProviderPlanMutations({
config: nextConfig,
upserts: params.plan.providerUpserts,
deletes: params.plan.providerDeletes,
});
if (providerConfigChanged) {
changedFiles.add(configPath);
}
for (const target of params.plan.targets) {
const targetPathSegments = resolveTargetPathSegments(target);
if (target.type === "channels.googlechat.serviceAccount") {
const previous = getByPathSegments(nextConfig, targetPathSegments);
if (isNonEmptyString(previous)) {
scrubbedValues.add(previous.trim());
}
const refPathSegments = resolveGoogleChatRefPathSegments(targetPathSegments);
const wroteRef = setByPathSegments(nextConfig, refPathSegments, target.ref);
const deletedLegacy = deleteByPathSegments(nextConfig, targetPathSegments);
if (wroteRef || deletedLegacy) {
changedFiles.add(configPath);
}
continue;
}
const previous = getByPathSegments(nextConfig, targetPathSegments);
if (isNonEmptyString(previous)) {
scrubbedValues.add(previous.trim());
}
const wroteRef = setByPathSegments(nextConfig, targetPathSegments, target.ref);
if (wroteRef) {
changedFiles.add(configPath);
}
if (target.type === "models.providers.apiKey" && target.providerId) {
providerTargets.add(normalizeProviderId(target.providerId));
}
}
const authStoreByPath = new Map<string, Record<string, unknown>>();
if (options.scrubAuthProfilesForProviderTargets && providerTargets.size > 0) {
for (const authStorePath of collectAuthStorePaths(nextConfig, stateDir)) {
if (!fs.existsSync(authStorePath)) {
continue;
}
const raw = fs.readFileSync(authStorePath, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (!isRecord(parsed) || !isRecord(parsed.profiles)) {
continue;
}
const nextStore = structuredClone(parsed) as Record<string, unknown> & {
profiles: Record<string, unknown>;
};
let mutated = false;
for (const profileValue of Object.values(nextStore.profiles)) {
if (!isRecord(profileValue) || !isNonEmptyString(profileValue.provider)) {
continue;
}
const provider = normalizeProviderId(String(profileValue.provider));
if (!providerTargets.has(provider)) {
continue;
}
if (profileValue.type === "api_key") {
if (isNonEmptyString(profileValue.key)) {
scrubbedValues.add(profileValue.key.trim());
}
if ("key" in profileValue) {
delete profileValue.key;
mutated = true;
}
if ("keyRef" in profileValue) {
delete profileValue.keyRef;
mutated = true;
}
continue;
}
if (profileValue.type === "token") {
if (isNonEmptyString(profileValue.token)) {
scrubbedValues.add(profileValue.token.trim());
}
if ("token" in profileValue) {
delete profileValue.token;
mutated = true;
}
if ("tokenRef" in profileValue) {
delete profileValue.tokenRef;
mutated = true;
}
continue;
}
if (profileValue.type === "oauth") {
warnings.push(
`Provider "${provider}" has OAuth credentials in ${authStorePath}; those still take precedence and are out of scope for static SecretRef migration.`,
);
}
}
if (mutated) {
authStoreByPath.set(authStorePath, nextStore);
changedFiles.add(authStorePath);
}
}
}
const authJsonByPath = new Map<string, Record<string, unknown>>();
if (options.scrubLegacyAuthJson) {
for (const authJsonPath of collectAuthJsonPaths(stateDir)) {
const raw = fs.readFileSync(authJsonPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (!isRecord(parsed)) {
continue;
}
let mutated = false;
const nextParsed = structuredClone(parsed);
for (const [providerId, value] of Object.entries(nextParsed)) {
if (!isRecord(value)) {
continue;
}
if (value.type === "api_key" && isNonEmptyString(value.key)) {
delete nextParsed[providerId];
mutated = true;
}
}
if (mutated) {
authJsonByPath.set(authJsonPath, nextParsed);
changedFiles.add(authJsonPath);
}
}
}
const envRawByPath = new Map<string, string>();
if (options.scrubEnv && scrubbedValues.size > 0) {
const envPath = path.join(resolveConfigDir(params.env, os.homedir), ".env");
if (fs.existsSync(envPath)) {
const current = fs.readFileSync(envPath, "utf8");
const scrubbed = scrubEnvRaw(current, scrubbedValues, new Set(listKnownSecretEnvVarNames()));
if (scrubbed.removed > 0 && scrubbed.nextRaw !== current) {
envRawByPath.set(envPath, scrubbed.nextRaw);
changedFiles.add(envPath);
}
}
}
const cache = {};
for (const target of params.plan.targets) {
const resolved = await resolveSecretRefValue(target.ref, {
config: nextConfig,
env: params.env,
cache,
});
if (target.type === "channels.googlechat.serviceAccount") {
if (!(isNonEmptyString(resolved) || isRecord(resolved))) {
throw new Error(
`Ref ${target.ref.source}:${target.ref.provider}:${target.ref.id} is not string/object.`,
);
}
continue;
}
if (!isNonEmptyString(resolved)) {
throw new Error(
`Ref ${target.ref.source}:${target.ref.provider}:${target.ref.id} is not a non-empty string.`,
);
}
}
const authStoreLookup = new Map<string, Record<string, unknown>>();
for (const [authStorePath, store] of authStoreByPath.entries()) {
authStoreLookup.set(resolveUserPath(authStorePath), store);
}
await prepareSecretsRuntimeSnapshot({
config: nextConfig,
env: params.env,
loadAuthStore: (agentDir?: string) => {
const storePath = resolveUserPath(resolveAuthStorePath(agentDir));
const override = authStoreLookup.get(storePath);
if (override) {
return structuredClone(override) as unknown as ReturnType<
typeof loadAuthProfileStoreForSecretsRuntime
>;
}
return loadAuthProfileStoreForSecretsRuntime(agentDir);
},
});
return {
nextConfig,
configPath,
configWriteOptions: writeOptions,
authStoreByPath,
authJsonByPath,
envRawByPath,
changedFiles,
warnings,
};
}
function captureFileSnapshot(pathname: string): FileSnapshot {
if (!fs.existsSync(pathname)) {
return { existed: false, content: "", mode: 0o600 };
}
const stat = fs.statSync(pathname);
return {
existed: true,
content: fs.readFileSync(pathname, "utf8"),
mode: stat.mode & 0o777,
};
}
function restoreFileSnapshot(pathname: string, snapshot: FileSnapshot): void {
if (!snapshot.existed) {
if (fs.existsSync(pathname)) {
fs.rmSync(pathname, { force: true });
}
return;
}
writeTextFileAtomic(pathname, snapshot.content, snapshot.mode || 0o600);
}
function toJsonWrite(pathname: string, value: Record<string, unknown>): ApplyWrite {
return {
path: pathname,
content: `${JSON.stringify(value, null, 2)}\n`,
mode: 0o600,
};
}
export async function runSecretsApply(params: {
plan: SecretsApplyPlan;
env?: NodeJS.ProcessEnv;
write?: boolean;
}): Promise<SecretsApplyResult> {
const env = params.env ?? process.env;
const projected = await projectPlanState({ plan: params.plan, env });
const changedFiles = [...projected.changedFiles].toSorted();
if (!params.write) {
return {
mode: "dry-run",
changed: changedFiles.length > 0,
changedFiles,
warningCount: projected.warnings.length,
warnings: projected.warnings,
};
}
if (changedFiles.length === 0) {
return {
mode: "write",
changed: false,
changedFiles: [],
warningCount: projected.warnings.length,
warnings: projected.warnings,
};
}
const io = createSecretsConfigIO({ env });
const snapshots = new Map<string, FileSnapshot>();
const capture = (pathname: string) => {
if (!snapshots.has(pathname)) {
snapshots.set(pathname, captureFileSnapshot(pathname));
}
};
capture(projected.configPath);
const writes: ApplyWrite[] = [];
for (const [pathname, value] of projected.authStoreByPath.entries()) {
capture(pathname);
writes.push(toJsonWrite(pathname, value));
}
for (const [pathname, value] of projected.authJsonByPath.entries()) {
capture(pathname);
writes.push(toJsonWrite(pathname, value));
}
for (const [pathname, raw] of projected.envRawByPath.entries()) {
capture(pathname);
writes.push({
path: pathname,
content: raw,
mode: 0o600,
});
}
try {
await io.writeConfigFile(projected.nextConfig, projected.configWriteOptions);
for (const write of writes) {
writeTextFileAtomic(write.path, write.content, write.mode);
}
} catch (err) {
for (const [pathname, snapshot] of snapshots.entries()) {
try {
restoreFileSnapshot(pathname, snapshot);
} catch {
// Best effort only; preserve original error.
}
}
throw new Error(`Secrets apply failed: ${String(err)}`, { cause: err });
}
return {
mode: "write",
changed: changedFiles.length > 0,
changedFiles,
warningCount: projected.warnings.length,
warnings: projected.warnings,
};
}

View File

@@ -0,0 +1,186 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { runSecretsAudit } from "./audit.js";
describe("secrets audit", () => {
let rootDir = "";
let stateDir = "";
let configPath = "";
let authStorePath = "";
let authJsonPath = "";
let envPath = "";
let env: NodeJS.ProcessEnv;
beforeEach(async () => {
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-audit-"));
stateDir = path.join(rootDir, ".openclaw");
configPath = path.join(stateDir, "openclaw.json");
authStorePath = path.join(stateDir, "agents", "main", "agent", "auth-profiles.json");
authJsonPath = path.join(stateDir, "agents", "main", "agent", "auth.json");
envPath = path.join(stateDir, ".env");
env = {
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: configPath,
OPENAI_API_KEY: "env-openai-key",
...(typeof process.env.PATH === "string" && process.env.PATH.trim().length > 0
? { PATH: process.env.PATH }
: { PATH: "/usr/bin:/bin" }),
};
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.mkdir(path.dirname(authStorePath), { recursive: true });
await fs.writeFile(
configPath,
`${JSON.stringify(
{
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
api: "openai-completions",
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
models: [{ id: "gpt-5", name: "gpt-5" }],
},
},
},
},
null,
2,
)}\n`,
"utf8",
);
await fs.writeFile(
authStorePath,
`${JSON.stringify(
{
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk-openai-plaintext",
},
},
},
null,
2,
)}\n`,
"utf8",
);
await fs.writeFile(envPath, "OPENAI_API_KEY=sk-openai-plaintext\n", "utf8");
});
afterEach(async () => {
await fs.rm(rootDir, { recursive: true, force: true });
});
it("reports plaintext + shadowing findings", async () => {
const report = await runSecretsAudit({ env });
expect(report.status).toBe("findings");
expect(report.summary.plaintextCount).toBeGreaterThan(0);
expect(report.summary.shadowedRefCount).toBeGreaterThan(0);
expect(report.findings.some((entry) => entry.code === "REF_SHADOWED")).toBe(true);
expect(report.findings.some((entry) => entry.code === "PLAINTEXT_FOUND")).toBe(true);
});
it("does not mutate legacy auth.json during audit", async () => {
await fs.rm(authStorePath, { force: true });
await fs.writeFile(
authJsonPath,
`${JSON.stringify(
{
openai: {
type: "api_key",
key: "sk-legacy-auth-json",
},
},
null,
2,
)}\n`,
"utf8",
);
const report = await runSecretsAudit({ env });
expect(report.findings.some((entry) => entry.code === "LEGACY_RESIDUE")).toBe(true);
await expect(fs.stat(authJsonPath)).resolves.toBeTruthy();
await expect(fs.stat(authStorePath)).rejects.toMatchObject({ code: "ENOENT" });
});
it("reports malformed sidecar JSON as findings instead of crashing", async () => {
await fs.writeFile(authStorePath, "{invalid-json", "utf8");
await fs.writeFile(authJsonPath, "{invalid-json", "utf8");
const report = await runSecretsAudit({ env });
expect(report.findings.some((entry) => entry.file === authStorePath)).toBe(true);
expect(report.findings.some((entry) => entry.file === authJsonPath)).toBe(true);
expect(report.findings.some((entry) => entry.code === "REF_UNRESOLVED")).toBe(true);
});
it("batches ref resolution per provider during audit", async () => {
if (process.platform === "win32") {
return;
}
const execLogPath = path.join(rootDir, "exec-calls.log");
const execScriptPath = path.join(rootDir, "resolver.mjs");
await fs.writeFile(
execScriptPath,
[
"#!/usr/bin/env node",
"import fs from 'node:fs';",
"const req = JSON.parse(fs.readFileSync(0, 'utf8'));",
`fs.appendFileSync(${JSON.stringify(execLogPath)}, 'x\\n');`,
"const values = Object.fromEntries((req.ids ?? []).map((id) => [id, `value:${id}`]));",
"process.stdout.write(JSON.stringify({ protocolVersion: 1, values }));",
].join("\n"),
{ encoding: "utf8", mode: 0o700 },
);
await fs.writeFile(
configPath,
`${JSON.stringify(
{
secrets: {
providers: {
execmain: {
source: "exec",
command: execScriptPath,
jsonOnly: true,
passEnv: ["PATH"],
},
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
api: "openai-completions",
apiKey: { source: "exec", provider: "execmain", id: "providers/openai/apiKey" },
models: [{ id: "gpt-5", name: "gpt-5" }],
},
moonshot: {
baseUrl: "https://api.moonshot.cn/v1",
api: "openai-completions",
apiKey: { source: "exec", provider: "execmain", id: "providers/moonshot/apiKey" },
models: [{ id: "moonshot-v1-8k", name: "moonshot-v1-8k" }],
},
},
},
},
null,
2,
)}\n`,
"utf8",
);
await fs.rm(authStorePath, { force: true });
await fs.writeFile(envPath, "", "utf8");
const report = await runSecretsAudit({ env });
expect(report.summary.unresolvedRefCount).toBe(0);
const callLog = await fs.readFile(execLogPath, "utf8");
const callCount = callLog.split("\n").filter((line) => line.trim().length > 0).length;
expect(callCount).toBe(1);
});
});

View File

@@ -0,0 +1,755 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { listAgentIds, resolveAgentDir } from "../agents/agent-scope.js";
import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js";
import { normalizeProviderId } from "../agents/model-selection.js";
import { resolveStateDir, type OpenClawConfig } from "../config/config.js";
import { coerceSecretRef, type SecretRef } from "../config/types.secrets.js";
import { resolveConfigDir, resolveUserPath } from "../utils.js";
import { createSecretsConfigIO } from "./config-io.js";
import { listKnownSecretEnvVarNames } from "./provider-env-vars.js";
import { secretRefKey } from "./ref-contract.js";
import {
resolveSecretRefValue,
resolveSecretRefValues,
type SecretRefResolveCache,
} from "./resolve.js";
import { isNonEmptyString, isRecord } from "./shared.js";
export type SecretsAuditCode =
| "PLAINTEXT_FOUND"
| "REF_UNRESOLVED"
| "REF_SHADOWED"
| "LEGACY_RESIDUE";
export type SecretsAuditSeverity = "info" | "warn" | "error";
export type SecretsAuditFinding = {
code: SecretsAuditCode;
severity: SecretsAuditSeverity;
file: string;
jsonPath: string;
message: string;
provider?: string;
profileId?: string;
};
export type SecretsAuditStatus = "clean" | "findings" | "unresolved";
export type SecretsAuditReport = {
version: 1;
status: SecretsAuditStatus;
filesScanned: string[];
summary: {
plaintextCount: number;
unresolvedRefCount: number;
shadowedRefCount: number;
legacyResidueCount: number;
};
findings: SecretsAuditFinding[];
};
type RefAssignment = {
file: string;
path: string;
ref: SecretRef;
expected: "string" | "string-or-object";
provider?: string;
};
type ProviderAuthState = {
hasUsableStaticOrOAuth: boolean;
modes: Set<"api_key" | "token" | "oauth">;
};
type SecretDefaults = {
env?: string;
file?: string;
exec?: string;
};
type AuditCollector = {
findings: SecretsAuditFinding[];
refAssignments: RefAssignment[];
configProviderRefPaths: Map<string, string[]>;
authProviderState: Map<string, ProviderAuthState>;
filesScanned: Set<string>;
};
function addFinding(collector: AuditCollector, finding: SecretsAuditFinding): void {
collector.findings.push(finding);
}
function collectProviderRefPath(
collector: AuditCollector,
providerId: string,
configPath: string,
): void {
const key = normalizeProviderId(providerId);
const existing = collector.configProviderRefPaths.get(key);
if (existing) {
existing.push(configPath);
return;
}
collector.configProviderRefPaths.set(key, [configPath]);
}
function trackAuthProviderState(
collector: AuditCollector,
provider: string,
mode: "api_key" | "token" | "oauth",
): void {
const key = normalizeProviderId(provider);
const existing = collector.authProviderState.get(key);
if (existing) {
existing.hasUsableStaticOrOAuth = true;
existing.modes.add(mode);
return;
}
collector.authProviderState.set(key, {
hasUsableStaticOrOAuth: true,
modes: new Set([mode]),
});
}
function parseDotPath(pathname: string): string[] {
return pathname.split(".").filter(Boolean);
}
function parseEnvValue(raw: string): string {
const trimmed = raw.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function collectEnvPlaintext(params: { envPath: string; collector: AuditCollector }): void {
if (!fs.existsSync(params.envPath)) {
return;
}
params.collector.filesScanned.add(params.envPath);
const knownKeys = new Set(listKnownSecretEnvVarNames());
const raw = fs.readFileSync(params.envPath, "utf8");
const lines = raw.split(/\r?\n/);
for (const line of lines) {
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
if (!match) {
continue;
}
const key = match[1] ?? "";
if (!knownKeys.has(key)) {
continue;
}
const value = parseEnvValue(match[2] ?? "");
if (!value) {
continue;
}
addFinding(params.collector, {
code: "PLAINTEXT_FOUND",
severity: "warn",
file: params.envPath,
jsonPath: `$env.${key}`,
message: `Potential secret found in .env (${key}).`,
});
}
}
function readJsonObject(filePath: string): {
value: Record<string, unknown> | null;
error?: string;
} {
if (!fs.existsSync(filePath)) {
return { value: null };
}
try {
const raw = fs.readFileSync(filePath, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (!isRecord(parsed)) {
return { value: null };
}
return { value: parsed };
} catch (err) {
return {
value: null,
error: err instanceof Error ? err.message : String(err),
};
}
}
function collectConfigSecrets(params: {
config: OpenClawConfig;
configPath: string;
collector: AuditCollector;
}): void {
const defaults = params.config.secrets?.defaults;
const providers = params.config.models?.providers as
| Record<string, { apiKey?: unknown }>
| undefined;
if (providers) {
for (const [providerId, provider] of Object.entries(providers)) {
const pathLabel = `models.providers.${providerId}.apiKey`;
const ref = coerceSecretRef(provider.apiKey, defaults);
if (ref) {
params.collector.refAssignments.push({
file: params.configPath,
path: pathLabel,
ref,
expected: "string",
provider: providerId,
});
collectProviderRefPath(params.collector, providerId, pathLabel);
continue;
}
if (isNonEmptyString(provider.apiKey)) {
addFinding(params.collector, {
code: "PLAINTEXT_FOUND",
severity: "warn",
file: params.configPath,
jsonPath: pathLabel,
message: "Provider apiKey is stored as plaintext.",
provider: providerId,
});
}
}
}
const entries = params.config.skills?.entries as Record<string, { apiKey?: unknown }> | undefined;
if (entries) {
for (const [entryId, entry] of Object.entries(entries)) {
const pathLabel = `skills.entries.${entryId}.apiKey`;
const ref = coerceSecretRef(entry.apiKey, defaults);
if (ref) {
params.collector.refAssignments.push({
file: params.configPath,
path: pathLabel,
ref,
expected: "string",
});
continue;
}
if (isNonEmptyString(entry.apiKey)) {
addFinding(params.collector, {
code: "PLAINTEXT_FOUND",
severity: "warn",
file: params.configPath,
jsonPath: pathLabel,
message: "Skill apiKey is stored as plaintext.",
});
}
}
}
const googlechat = params.config.channels?.googlechat as
| {
serviceAccount?: unknown;
serviceAccountRef?: unknown;
accounts?: Record<string, unknown>;
}
| undefined;
if (!googlechat) {
return;
}
const collectGoogleChatValue = (
value: unknown,
refValue: unknown,
pathLabel: string,
accountId?: string,
) => {
const explicitRef = coerceSecretRef(refValue, defaults);
const inlineRef = explicitRef ? null : coerceSecretRef(value, defaults);
const ref = explicitRef ?? inlineRef;
if (ref) {
params.collector.refAssignments.push({
file: params.configPath,
path: pathLabel,
ref,
expected: "string-or-object",
provider: accountId ? "googlechat" : undefined,
});
return;
}
if (isNonEmptyString(value) || (isRecord(value) && Object.keys(value).length > 0)) {
addFinding(params.collector, {
code: "PLAINTEXT_FOUND",
severity: "warn",
file: params.configPath,
jsonPath: pathLabel,
message: "Google Chat serviceAccount is stored as plaintext.",
});
}
};
collectGoogleChatValue(
googlechat.serviceAccount,
googlechat.serviceAccountRef,
"channels.googlechat.serviceAccount",
);
if (!isRecord(googlechat.accounts)) {
return;
}
for (const [accountId, accountValue] of Object.entries(googlechat.accounts)) {
if (!isRecord(accountValue)) {
continue;
}
collectGoogleChatValue(
accountValue.serviceAccount,
accountValue.serviceAccountRef,
`channels.googlechat.accounts.${accountId}.serviceAccount`,
accountId,
);
}
}
function collectAuthStorePaths(config: OpenClawConfig, stateDir: string): string[] {
const paths = new Set<string>();
// Scope default auth store discovery to the provided stateDir instead of
// ambient process env, so audits do not include unrelated host-global stores.
paths.add(path.join(resolveUserPath(stateDir), "agents", "main", "agent", "auth-profiles.json"));
const agentsRoot = path.join(resolveUserPath(stateDir), "agents");
if (fs.existsSync(agentsRoot)) {
for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
paths.add(path.join(agentsRoot, entry.name, "agent", "auth-profiles.json"));
}
}
for (const agentId of listAgentIds(config)) {
if (agentId === "main") {
paths.add(
path.join(resolveUserPath(stateDir), "agents", "main", "agent", "auth-profiles.json"),
);
continue;
}
const agentDir = resolveAgentDir(config, agentId);
paths.add(resolveUserPath(resolveAuthStorePath(agentDir)));
}
return [...paths];
}
function collectAuthStoreSecrets(params: {
authStorePath: string;
collector: AuditCollector;
defaults?: SecretDefaults;
}): void {
if (!fs.existsSync(params.authStorePath)) {
return;
}
params.collector.filesScanned.add(params.authStorePath);
const parsedResult = readJsonObject(params.authStorePath);
if (parsedResult.error) {
addFinding(params.collector, {
code: "REF_UNRESOLVED",
severity: "error",
file: params.authStorePath,
jsonPath: "<root>",
message: `Invalid JSON in auth-profiles store: ${parsedResult.error}`,
});
return;
}
const parsed = parsedResult.value;
if (!parsed || !isRecord(parsed.profiles)) {
return;
}
for (const [profileId, profileValue] of Object.entries(parsed.profiles)) {
if (!isRecord(profileValue) || !isNonEmptyString(profileValue.provider)) {
continue;
}
const provider = String(profileValue.provider);
if (profileValue.type === "api_key") {
const keyRef = coerceSecretRef(profileValue.keyRef, params.defaults);
const inlineRef = keyRef ? null : coerceSecretRef(profileValue.key, params.defaults);
const ref = keyRef ?? inlineRef;
if (ref) {
params.collector.refAssignments.push({
file: params.authStorePath,
path: `profiles.${profileId}.key`,
ref,
expected: "string",
provider,
});
trackAuthProviderState(params.collector, provider, "api_key");
}
if (isNonEmptyString(profileValue.key)) {
addFinding(params.collector, {
code: "PLAINTEXT_FOUND",
severity: "warn",
file: params.authStorePath,
jsonPath: `profiles.${profileId}.key`,
message: "Auth profile API key is stored as plaintext.",
provider,
profileId,
});
trackAuthProviderState(params.collector, provider, "api_key");
}
continue;
}
if (profileValue.type === "token") {
const tokenRef = coerceSecretRef(profileValue.tokenRef, params.defaults);
const inlineRef = tokenRef ? null : coerceSecretRef(profileValue.token, params.defaults);
const ref = tokenRef ?? inlineRef;
if (ref) {
params.collector.refAssignments.push({
file: params.authStorePath,
path: `profiles.${profileId}.token`,
ref,
expected: "string",
provider,
});
trackAuthProviderState(params.collector, provider, "token");
}
if (isNonEmptyString(profileValue.token)) {
addFinding(params.collector, {
code: "PLAINTEXT_FOUND",
severity: "warn",
file: params.authStorePath,
jsonPath: `profiles.${profileId}.token`,
message: "Auth profile token is stored as plaintext.",
provider,
profileId,
});
trackAuthProviderState(params.collector, provider, "token");
}
continue;
}
if (profileValue.type === "oauth") {
const hasAccess = isNonEmptyString(profileValue.access);
const hasRefresh = isNonEmptyString(profileValue.refresh);
if (hasAccess || hasRefresh) {
addFinding(params.collector, {
code: "LEGACY_RESIDUE",
severity: "info",
file: params.authStorePath,
jsonPath: `profiles.${profileId}`,
message: "OAuth credentials are present (out of scope for static SecretRef migration).",
provider,
profileId,
});
trackAuthProviderState(params.collector, provider, "oauth");
}
}
}
}
function collectAuthJsonResidue(params: { stateDir: string; collector: AuditCollector }): void {
const agentsRoot = path.join(resolveUserPath(params.stateDir), "agents");
if (!fs.existsSync(agentsRoot)) {
return;
}
for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
const authJsonPath = path.join(agentsRoot, entry.name, "agent", "auth.json");
if (!fs.existsSync(authJsonPath)) {
continue;
}
params.collector.filesScanned.add(authJsonPath);
const parsedResult = readJsonObject(authJsonPath);
if (parsedResult.error) {
addFinding(params.collector, {
code: "REF_UNRESOLVED",
severity: "error",
file: authJsonPath,
jsonPath: "<root>",
message: `Invalid JSON in legacy auth.json: ${parsedResult.error}`,
});
continue;
}
const parsed = parsedResult.value;
if (!parsed) {
continue;
}
for (const [providerId, value] of Object.entries(parsed)) {
if (!isRecord(value)) {
continue;
}
if (value.type === "api_key" && isNonEmptyString(value.key)) {
addFinding(params.collector, {
code: "LEGACY_RESIDUE",
severity: "warn",
file: authJsonPath,
jsonPath: providerId,
message: "Legacy auth.json contains static api_key credentials.",
provider: providerId,
});
}
}
}
}
async function collectUnresolvedRefFindings(params: {
collector: AuditCollector;
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): Promise<void> {
const cache: SecretRefResolveCache = {};
const refsByProvider = new Map<string, Map<string, SecretRef>>();
for (const assignment of params.collector.refAssignments) {
const providerKey = `${assignment.ref.source}:${assignment.ref.provider}`;
let refsForProvider = refsByProvider.get(providerKey);
if (!refsForProvider) {
refsForProvider = new Map<string, SecretRef>();
refsByProvider.set(providerKey, refsForProvider);
}
refsForProvider.set(secretRefKey(assignment.ref), assignment.ref);
}
const resolvedByRefKey = new Map<string, unknown>();
const errorsByRefKey = new Map<string, unknown>();
for (const refsForProvider of refsByProvider.values()) {
const refs = [...refsForProvider.values()];
try {
const resolved = await resolveSecretRefValues(refs, {
config: params.config,
env: params.env,
cache,
});
for (const [key, value] of resolved.entries()) {
resolvedByRefKey.set(key, value);
}
continue;
} catch {
// Fall back to per-ref resolution for provider-specific pinpoint errors.
}
for (const ref of refs) {
const key = secretRefKey(ref);
try {
const resolved = await resolveSecretRefValue(ref, {
config: params.config,
env: params.env,
cache,
});
resolvedByRefKey.set(key, resolved);
} catch (err) {
errorsByRefKey.set(key, err);
}
}
}
for (const assignment of params.collector.refAssignments) {
const key = secretRefKey(assignment.ref);
const resolveErr = errorsByRefKey.get(key);
if (resolveErr) {
addFinding(params.collector, {
code: "REF_UNRESOLVED",
severity: "error",
file: assignment.file,
jsonPath: assignment.path,
message: `Failed to resolve ${assignment.ref.source}:${assignment.ref.provider}:${assignment.ref.id} (${describeUnknownError(resolveErr)}).`,
provider: assignment.provider,
});
continue;
}
if (!resolvedByRefKey.has(key)) {
addFinding(params.collector, {
code: "REF_UNRESOLVED",
severity: "error",
file: assignment.file,
jsonPath: assignment.path,
message: `Failed to resolve ${assignment.ref.source}:${assignment.ref.provider}:${assignment.ref.id} (resolved value is missing).`,
provider: assignment.provider,
});
continue;
}
const resolved = resolvedByRefKey.get(key);
if (assignment.expected === "string") {
if (!isNonEmptyString(resolved)) {
addFinding(params.collector, {
code: "REF_UNRESOLVED",
severity: "error",
file: assignment.file,
jsonPath: assignment.path,
message: `Failed to resolve ${assignment.ref.source}:${assignment.ref.provider}:${assignment.ref.id} (resolved value is not a non-empty string).`,
provider: assignment.provider,
});
}
continue;
}
if (!(isNonEmptyString(resolved) || isRecord(resolved))) {
addFinding(params.collector, {
code: "REF_UNRESOLVED",
severity: "error",
file: assignment.file,
jsonPath: assignment.path,
message: `Failed to resolve ${assignment.ref.source}:${assignment.ref.provider}:${assignment.ref.id} (resolved value is not a string/object).`,
provider: assignment.provider,
});
}
}
}
function collectShadowingFindings(collector: AuditCollector): void {
for (const [provider, paths] of collector.configProviderRefPaths.entries()) {
const authState = collector.authProviderState.get(provider);
if (!authState?.hasUsableStaticOrOAuth) {
continue;
}
const modeText = [...authState.modes].join("/");
for (const configPath of paths) {
addFinding(collector, {
code: "REF_SHADOWED",
severity: "warn",
file: "openclaw.json",
jsonPath: configPath,
message: `Auth profile credentials (${modeText}) take precedence for provider "${provider}", so this config ref may never be used.`,
provider,
});
}
}
}
function describeUnknownError(err: unknown): string {
if (err instanceof Error && err.message.trim().length > 0) {
return err.message;
}
if (typeof err === "string" && err.trim().length > 0) {
return err;
}
try {
const serialized = JSON.stringify(err);
return serialized ?? "unknown error";
} catch {
return "unknown error";
}
}
function summarizeFindings(findings: SecretsAuditFinding[]): SecretsAuditReport["summary"] {
return {
plaintextCount: findings.filter((entry) => entry.code === "PLAINTEXT_FOUND").length,
unresolvedRefCount: findings.filter((entry) => entry.code === "REF_UNRESOLVED").length,
shadowedRefCount: findings.filter((entry) => entry.code === "REF_SHADOWED").length,
legacyResidueCount: findings.filter((entry) => entry.code === "LEGACY_RESIDUE").length,
};
}
export async function runSecretsAudit(
params: {
env?: NodeJS.ProcessEnv;
} = {},
): Promise<SecretsAuditReport> {
const env = params.env ?? process.env;
const previousAuthStoreReadOnly = process.env.OPENCLAW_AUTH_STORE_READONLY;
process.env.OPENCLAW_AUTH_STORE_READONLY = "1";
try {
const io = createSecretsConfigIO({ env });
const snapshot = await io.readConfigFileSnapshot();
const configPath = resolveUserPath(snapshot.path);
const defaults = snapshot.valid ? snapshot.config.secrets?.defaults : undefined;
const collector: AuditCollector = {
findings: [],
refAssignments: [],
configProviderRefPaths: new Map(),
authProviderState: new Map(),
filesScanned: new Set([configPath]),
};
const stateDir = resolveStateDir(env, os.homedir);
const envPath = path.join(resolveConfigDir(env, os.homedir), ".env");
const config = snapshot.valid ? snapshot.config : ({} as OpenClawConfig);
if (snapshot.valid) {
collectConfigSecrets({
config,
configPath,
collector,
});
for (const authStorePath of collectAuthStorePaths(config, stateDir)) {
collectAuthStoreSecrets({
authStorePath,
collector,
defaults,
});
}
await collectUnresolvedRefFindings({
collector,
config,
env,
});
collectShadowingFindings(collector);
} else {
addFinding(collector, {
code: "REF_UNRESOLVED",
severity: "error",
file: configPath,
jsonPath: "<root>",
message: "Config is invalid; cannot validate secret references reliably.",
});
}
collectEnvPlaintext({
envPath,
collector,
});
collectAuthJsonResidue({
stateDir,
collector,
});
const summary = summarizeFindings(collector.findings);
const status: SecretsAuditStatus =
summary.unresolvedRefCount > 0
? "unresolved"
: collector.findings.length > 0
? "findings"
: "clean";
return {
version: 1,
status,
filesScanned: [...collector.filesScanned].toSorted(),
summary,
findings: collector.findings,
};
} finally {
if (previousAuthStoreReadOnly === undefined) {
delete process.env.OPENCLAW_AUTH_STORE_READONLY;
} else {
process.env.OPENCLAW_AUTH_STORE_READONLY = previousAuthStoreReadOnly;
}
}
}
export function resolveSecretsAuditExitCode(report: SecretsAuditReport, check: boolean): number {
if (report.summary.unresolvedRefCount > 0) {
return 2;
}
if (check && report.findings.length > 0) {
return 1;
}
return 0;
}
export function applySecretsPlanTarget(
config: OpenClawConfig,
pathLabel: string,
value: unknown,
): void {
const segments = parseDotPath(pathLabel);
if (segments.length === 0) {
throw new Error("Invalid target path.");
}
let cursor: Record<string, unknown> = config as unknown as Record<string, unknown>;
for (const segment of segments.slice(0, -1)) {
const existing = cursor[segment];
if (!isRecord(existing)) {
cursor[segment] = {};
}
cursor = cursor[segment] as Record<string, unknown>;
}
cursor[segments[segments.length - 1]] = value;
}

View File

@@ -0,0 +1,14 @@
import { createConfigIO } from "../config/config.js";
const silentConfigIoLogger = {
error: () => {},
warn: () => {},
} as const;
export function createSecretsConfigIO(params: { env: NodeJS.ProcessEnv }) {
// Secrets command output is owned by the CLI command so --json stays machine-parseable.
return createConfigIO({
env: params.env,
logger: silentConfigIoLogger,
});
}

View File

@@ -0,0 +1,876 @@
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { confirm, select, text } from "@clack/prompts";
import type { OpenClawConfig } from "../config/config.js";
import type { SecretProviderConfig, SecretRef, SecretRefSource } from "../config/types.secrets.js";
import { isSafeExecutableValue } from "../infra/exec-safety.js";
import { runSecretsApply, type SecretsApplyResult } from "./apply.js";
import { createSecretsConfigIO } from "./config-io.js";
import { type SecretsApplyPlan } from "./plan.js";
import { resolveDefaultSecretProviderAlias } from "./ref-contract.js";
import { isRecord } from "./shared.js";
type ConfigureCandidate = {
type: "models.providers.apiKey" | "skills.entries.apiKey" | "channels.googlechat.serviceAccount";
path: string;
pathSegments: string[];
label: string;
providerId?: string;
accountId?: string;
};
export type SecretsConfigureResult = {
plan: SecretsApplyPlan;
preflight: SecretsApplyResult;
};
const PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,127}$/;
const WINDOWS_ABS_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/;
function isAbsolutePathValue(value: string): boolean {
return (
path.isAbsolute(value) ||
WINDOWS_ABS_PATH_PATTERN.test(value) ||
WINDOWS_UNC_PATH_PATTERN.test(value)
);
}
function parseCsv(value: string): string[] {
return value
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function parseOptionalPositiveInt(value: string, max: number): number | undefined {
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
if (!/^\d+$/.test(trimmed)) {
return undefined;
}
const parsed = Number.parseInt(trimmed, 10);
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > max) {
return undefined;
}
return parsed;
}
function getSecretProviders(config: OpenClawConfig): Record<string, SecretProviderConfig> {
if (!isRecord(config.secrets?.providers)) {
return {};
}
return config.secrets.providers;
}
function setSecretProvider(
config: OpenClawConfig,
providerAlias: string,
providerConfig: SecretProviderConfig,
): void {
config.secrets ??= {};
if (!isRecord(config.secrets.providers)) {
config.secrets.providers = {};
}
config.secrets.providers[providerAlias] = providerConfig;
}
function removeSecretProvider(config: OpenClawConfig, providerAlias: string): boolean {
if (!isRecord(config.secrets?.providers)) {
return false;
}
const providers = config.secrets.providers;
if (!Object.prototype.hasOwnProperty.call(providers, providerAlias)) {
return false;
}
delete providers[providerAlias];
if (Object.keys(providers).length === 0) {
delete config.secrets?.providers;
}
if (isRecord(config.secrets?.defaults)) {
const defaults = config.secrets.defaults;
if (defaults?.env === providerAlias) {
delete defaults.env;
}
if (defaults?.file === providerAlias) {
delete defaults.file;
}
if (defaults?.exec === providerAlias) {
delete defaults.exec;
}
if (
defaults &&
defaults.env === undefined &&
defaults.file === undefined &&
defaults.exec === undefined
) {
delete config.secrets?.defaults;
}
}
return true;
}
function providerHint(provider: SecretProviderConfig): string {
if (provider.source === "env") {
return provider.allowlist?.length ? `env (${provider.allowlist.length} allowlisted)` : "env";
}
if (provider.source === "file") {
return `file (${provider.mode ?? "json"})`;
}
return `exec (${provider.jsonOnly === false ? "json+text" : "json"})`;
}
function buildCandidates(config: OpenClawConfig): ConfigureCandidate[] {
const out: ConfigureCandidate[] = [];
const providers = config.models?.providers as Record<string, unknown> | undefined;
if (providers) {
for (const [providerId, providerValue] of Object.entries(providers)) {
if (!isRecord(providerValue)) {
continue;
}
out.push({
type: "models.providers.apiKey",
path: `models.providers.${providerId}.apiKey`,
pathSegments: ["models", "providers", providerId, "apiKey"],
label: `Provider API key: ${providerId}`,
providerId,
});
}
}
const entries = config.skills?.entries as Record<string, unknown> | undefined;
if (entries) {
for (const [entryId, entryValue] of Object.entries(entries)) {
if (!isRecord(entryValue)) {
continue;
}
out.push({
type: "skills.entries.apiKey",
path: `skills.entries.${entryId}.apiKey`,
pathSegments: ["skills", "entries", entryId, "apiKey"],
label: `Skill API key: ${entryId}`,
});
}
}
const googlechat = config.channels?.googlechat;
if (isRecord(googlechat)) {
out.push({
type: "channels.googlechat.serviceAccount",
path: "channels.googlechat.serviceAccount",
pathSegments: ["channels", "googlechat", "serviceAccount"],
label: "Google Chat serviceAccount (default)",
});
const accounts = googlechat.accounts;
if (isRecord(accounts)) {
for (const [accountId, value] of Object.entries(accounts)) {
if (!isRecord(value)) {
continue;
}
out.push({
type: "channels.googlechat.serviceAccount",
path: `channels.googlechat.accounts.${accountId}.serviceAccount`,
pathSegments: ["channels", "googlechat", "accounts", accountId, "serviceAccount"],
label: `Google Chat serviceAccount (${accountId})`,
accountId,
});
}
}
}
return out;
}
function toSourceChoices(config: OpenClawConfig): Array<{ value: SecretRefSource; label: string }> {
const hasSource = (source: SecretRefSource) =>
Object.values(config.secrets?.providers ?? {}).some((provider) => provider?.source === source);
const choices: Array<{ value: SecretRefSource; label: string }> = [
{
value: "env",
label: "env",
},
];
if (hasSource("file")) {
choices.push({ value: "file", label: "file" });
}
if (hasSource("exec")) {
choices.push({ value: "exec", label: "exec" });
}
return choices;
}
function assertNoCancel<T>(value: T | symbol, message: string): T {
if (typeof value === "symbol") {
throw new Error(message);
}
return value;
}
async function promptProviderAlias(params: { existingAliases: Set<string> }): Promise<string> {
const alias = assertNoCancel(
await text({
message: "Provider alias",
initialValue: "default",
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return "Required";
}
if (!PROVIDER_ALIAS_PATTERN.test(trimmed)) {
return "Must match /^[a-z][a-z0-9_-]{0,63}$/";
}
if (params.existingAliases.has(trimmed)) {
return "Alias already exists";
}
return undefined;
},
}),
"Secrets configure cancelled.",
);
return String(alias).trim();
}
async function promptProviderSource(initial?: SecretRefSource): Promise<SecretRefSource> {
const source = assertNoCancel(
await select({
message: "Provider source",
options: [
{ value: "env", label: "env" },
{ value: "file", label: "file" },
{ value: "exec", label: "exec" },
],
initialValue: initial,
}),
"Secrets configure cancelled.",
);
return source as SecretRefSource;
}
async function promptEnvProvider(
base?: Extract<SecretProviderConfig, { source: "env" }>,
): Promise<Extract<SecretProviderConfig, { source: "env" }>> {
const allowlistRaw = assertNoCancel(
await text({
message: "Env allowlist (comma-separated, blank for unrestricted)",
initialValue: base?.allowlist?.join(",") ?? "",
validate: (value) => {
const entries = parseCsv(String(value ?? ""));
for (const entry of entries) {
if (!ENV_NAME_PATTERN.test(entry)) {
return `Invalid env name: ${entry}`;
}
}
return undefined;
},
}),
"Secrets configure cancelled.",
);
const allowlist = parseCsv(String(allowlistRaw ?? ""));
return {
source: "env",
...(allowlist.length > 0 ? { allowlist } : {}),
};
}
async function promptFileProvider(
base?: Extract<SecretProviderConfig, { source: "file" }>,
): Promise<Extract<SecretProviderConfig, { source: "file" }>> {
const filePath = assertNoCancel(
await text({
message: "File path (absolute)",
initialValue: base?.path ?? "",
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return "Required";
}
if (!isAbsolutePathValue(trimmed)) {
return "Must be an absolute path";
}
return undefined;
},
}),
"Secrets configure cancelled.",
);
const mode = assertNoCancel(
await select({
message: "File mode",
options: [
{ value: "json", label: "json" },
{ value: "singleValue", label: "singleValue" },
],
initialValue: base?.mode ?? "json",
}),
"Secrets configure cancelled.",
);
const timeoutMsRaw = assertNoCancel(
await text({
message: "Timeout ms (blank for default)",
initialValue: base?.timeoutMs ? String(base.timeoutMs) : "",
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return undefined;
}
if (parseOptionalPositiveInt(trimmed, 120000) === undefined) {
return "Must be an integer between 1 and 120000";
}
return undefined;
},
}),
"Secrets configure cancelled.",
);
const maxBytesRaw = assertNoCancel(
await text({
message: "Max bytes (blank for default)",
initialValue: base?.maxBytes ? String(base.maxBytes) : "",
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return undefined;
}
if (parseOptionalPositiveInt(trimmed, 20 * 1024 * 1024) === undefined) {
return "Must be an integer between 1 and 20971520";
}
return undefined;
},
}),
"Secrets configure cancelled.",
);
const timeoutMs = parseOptionalPositiveInt(String(timeoutMsRaw ?? ""), 120000);
const maxBytes = parseOptionalPositiveInt(String(maxBytesRaw ?? ""), 20 * 1024 * 1024);
return {
source: "file",
path: String(filePath).trim(),
mode,
...(timeoutMs ? { timeoutMs } : {}),
...(maxBytes ? { maxBytes } : {}),
};
}
async function parseArgsInput(rawValue: string): Promise<string[] | undefined> {
const trimmed = rawValue.trim();
if (!trimmed) {
return undefined;
}
const parsed = JSON.parse(trimmed) as unknown;
if (!Array.isArray(parsed) || !parsed.every((entry) => typeof entry === "string")) {
throw new Error("args must be a JSON array of strings");
}
return parsed;
}
async function promptExecProvider(
base?: Extract<SecretProviderConfig, { source: "exec" }>,
): Promise<Extract<SecretProviderConfig, { source: "exec" }>> {
const command = assertNoCancel(
await text({
message: "Command path (absolute)",
initialValue: base?.command ?? "",
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return "Required";
}
if (!isAbsolutePathValue(trimmed)) {
return "Must be an absolute path";
}
if (!isSafeExecutableValue(trimmed)) {
return "Command value is not allowed";
}
return undefined;
},
}),
"Secrets configure cancelled.",
);
const argsRaw = assertNoCancel(
await text({
message: "Args JSON array (blank for none)",
initialValue: JSON.stringify(base?.args ?? []),
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return undefined;
}
try {
const parsed = JSON.parse(trimmed) as unknown;
if (!Array.isArray(parsed) || !parsed.every((entry) => typeof entry === "string")) {
return "Must be a JSON array of strings";
}
return undefined;
} catch {
return "Must be valid JSON";
}
},
}),
"Secrets configure cancelled.",
);
const timeoutMsRaw = assertNoCancel(
await text({
message: "Timeout ms (blank for default)",
initialValue: base?.timeoutMs ? String(base.timeoutMs) : "",
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return undefined;
}
if (parseOptionalPositiveInt(trimmed, 120000) === undefined) {
return "Must be an integer between 1 and 120000";
}
return undefined;
},
}),
"Secrets configure cancelled.",
);
const noOutputTimeoutMsRaw = assertNoCancel(
await text({
message: "No-output timeout ms (blank for default)",
initialValue: base?.noOutputTimeoutMs ? String(base.noOutputTimeoutMs) : "",
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return undefined;
}
if (parseOptionalPositiveInt(trimmed, 120000) === undefined) {
return "Must be an integer between 1 and 120000";
}
return undefined;
},
}),
"Secrets configure cancelled.",
);
const maxOutputBytesRaw = assertNoCancel(
await text({
message: "Max output bytes (blank for default)",
initialValue: base?.maxOutputBytes ? String(base.maxOutputBytes) : "",
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return undefined;
}
if (parseOptionalPositiveInt(trimmed, 20 * 1024 * 1024) === undefined) {
return "Must be an integer between 1 and 20971520";
}
return undefined;
},
}),
"Secrets configure cancelled.",
);
const jsonOnly = assertNoCancel(
await confirm({
message: "Require JSON-only response?",
initialValue: base?.jsonOnly ?? true,
}),
"Secrets configure cancelled.",
);
const passEnvRaw = assertNoCancel(
await text({
message: "Pass-through env vars (comma-separated, blank for none)",
initialValue: base?.passEnv?.join(",") ?? "",
validate: (value) => {
const entries = parseCsv(String(value ?? ""));
for (const entry of entries) {
if (!ENV_NAME_PATTERN.test(entry)) {
return `Invalid env name: ${entry}`;
}
}
return undefined;
},
}),
"Secrets configure cancelled.",
);
const trustedDirsRaw = assertNoCancel(
await text({
message: "Trusted dirs (comma-separated absolute paths, blank for none)",
initialValue: base?.trustedDirs?.join(",") ?? "",
validate: (value) => {
const entries = parseCsv(String(value ?? ""));
for (const entry of entries) {
if (!isAbsolutePathValue(entry)) {
return `Trusted dir must be absolute: ${entry}`;
}
}
return undefined;
},
}),
"Secrets configure cancelled.",
);
const allowInsecurePath = assertNoCancel(
await confirm({
message: "Allow insecure command path checks?",
initialValue: base?.allowInsecurePath ?? false,
}),
"Secrets configure cancelled.",
);
const allowSymlinkCommand = assertNoCancel(
await confirm({
message: "Allow symlink command path?",
initialValue: base?.allowSymlinkCommand ?? false,
}),
"Secrets configure cancelled.",
);
const args = await parseArgsInput(String(argsRaw ?? ""));
const timeoutMs = parseOptionalPositiveInt(String(timeoutMsRaw ?? ""), 120000);
const noOutputTimeoutMs = parseOptionalPositiveInt(String(noOutputTimeoutMsRaw ?? ""), 120000);
const maxOutputBytes = parseOptionalPositiveInt(
String(maxOutputBytesRaw ?? ""),
20 * 1024 * 1024,
);
const passEnv = parseCsv(String(passEnvRaw ?? ""));
const trustedDirs = parseCsv(String(trustedDirsRaw ?? ""));
return {
source: "exec",
command: String(command).trim(),
...(args && args.length > 0 ? { args } : {}),
...(timeoutMs ? { timeoutMs } : {}),
...(noOutputTimeoutMs ? { noOutputTimeoutMs } : {}),
...(maxOutputBytes ? { maxOutputBytes } : {}),
...(jsonOnly ? { jsonOnly } : { jsonOnly: false }),
...(passEnv.length > 0 ? { passEnv } : {}),
...(trustedDirs.length > 0 ? { trustedDirs } : {}),
...(allowInsecurePath ? { allowInsecurePath: true } : {}),
...(allowSymlinkCommand ? { allowSymlinkCommand: true } : {}),
...(isRecord(base?.env) ? { env: base.env } : {}),
};
}
async function promptProviderConfig(
source: SecretRefSource,
current?: SecretProviderConfig,
): Promise<SecretProviderConfig> {
if (source === "env") {
return await promptEnvProvider(current?.source === "env" ? current : undefined);
}
if (source === "file") {
return await promptFileProvider(current?.source === "file" ? current : undefined);
}
return await promptExecProvider(current?.source === "exec" ? current : undefined);
}
async function configureProvidersInteractive(config: OpenClawConfig): Promise<void> {
while (true) {
const providers = getSecretProviders(config);
const providerEntries = Object.entries(providers).toSorted(([left], [right]) =>
left.localeCompare(right),
);
const actionOptions: Array<{ value: string; label: string; hint?: string }> = [
{
value: "add",
label: "Add provider",
hint: "Define a new env/file/exec provider",
},
];
if (providerEntries.length > 0) {
actionOptions.push({
value: "edit",
label: "Edit provider",
hint: "Update an existing provider",
});
actionOptions.push({
value: "remove",
label: "Remove provider",
hint: "Delete a provider alias",
});
}
actionOptions.push({
value: "continue",
label: "Continue",
hint: "Move to credential mapping",
});
const action = assertNoCancel(
await select({
message:
providerEntries.length > 0
? "Configure secret providers"
: "Configure secret providers (only env refs are available until file/exec providers are added)",
options: actionOptions,
}),
"Secrets configure cancelled.",
);
if (action === "continue") {
return;
}
if (action === "add") {
const source = await promptProviderSource();
const alias = await promptProviderAlias({
existingAliases: new Set(providerEntries.map(([providerAlias]) => providerAlias)),
});
const providerConfig = await promptProviderConfig(source);
setSecretProvider(config, alias, providerConfig);
continue;
}
if (action === "edit") {
const alias = assertNoCancel(
await select({
message: "Select provider to edit",
options: providerEntries.map(([providerAlias, providerConfig]) => ({
value: providerAlias,
label: providerAlias,
hint: providerHint(providerConfig),
})),
}),
"Secrets configure cancelled.",
);
const current = providers[alias];
if (!current) {
continue;
}
const source = await promptProviderSource(current.source);
const nextProviderConfig = await promptProviderConfig(source, current);
if (!isDeepStrictEqual(current, nextProviderConfig)) {
setSecretProvider(config, alias, nextProviderConfig);
}
continue;
}
if (action === "remove") {
const alias = assertNoCancel(
await select({
message: "Select provider to remove",
options: providerEntries.map(([providerAlias, providerConfig]) => ({
value: providerAlias,
label: providerAlias,
hint: providerHint(providerConfig),
})),
}),
"Secrets configure cancelled.",
);
const shouldRemove = assertNoCancel(
await confirm({
message: `Remove provider "${alias}"?`,
initialValue: false,
}),
"Secrets configure cancelled.",
);
if (shouldRemove) {
removeSecretProvider(config, alias);
}
}
}
}
function collectProviderPlanChanges(params: { original: OpenClawConfig; next: OpenClawConfig }): {
upserts: Record<string, SecretProviderConfig>;
deletes: string[];
} {
const originalProviders = getSecretProviders(params.original);
const nextProviders = getSecretProviders(params.next);
const upserts: Record<string, SecretProviderConfig> = {};
const deletes: string[] = [];
for (const [providerAlias, nextProviderConfig] of Object.entries(nextProviders)) {
const current = originalProviders[providerAlias];
if (isDeepStrictEqual(current, nextProviderConfig)) {
continue;
}
upserts[providerAlias] = structuredClone(nextProviderConfig);
}
for (const providerAlias of Object.keys(originalProviders)) {
if (!Object.prototype.hasOwnProperty.call(nextProviders, providerAlias)) {
deletes.push(providerAlias);
}
}
return {
upserts,
deletes: deletes.toSorted(),
};
}
export async function runSecretsConfigureInteractive(
params: {
env?: NodeJS.ProcessEnv;
providersOnly?: boolean;
skipProviderSetup?: boolean;
} = {},
): Promise<SecretsConfigureResult> {
if (!process.stdin.isTTY) {
throw new Error("secrets configure requires an interactive TTY.");
}
if (params.providersOnly && params.skipProviderSetup) {
throw new Error("Cannot combine --providers-only with --skip-provider-setup.");
}
const env = params.env ?? process.env;
const io = createSecretsConfigIO({ env });
const { snapshot } = await io.readConfigFileSnapshotForWrite();
if (!snapshot.valid) {
throw new Error("Cannot run interactive secrets configure because config is invalid.");
}
const stagedConfig = structuredClone(snapshot.config);
if (!params.skipProviderSetup) {
await configureProvidersInteractive(stagedConfig);
}
const providerChanges = collectProviderPlanChanges({
original: snapshot.config,
next: stagedConfig,
});
const selectedByPath = new Map<string, ConfigureCandidate & { ref: SecretRef }>();
if (!params.providersOnly) {
const candidates = buildCandidates(stagedConfig);
if (candidates.length === 0) {
throw new Error("No configurable secret-bearing fields found in openclaw.json.");
}
const sourceChoices = toSourceChoices(stagedConfig);
while (true) {
const options = candidates.map((candidate) => ({
value: candidate.path,
label: candidate.label,
hint: candidate.path,
}));
if (selectedByPath.size > 0) {
options.unshift({
value: "__done__",
label: "Done",
hint: "Finish and run preflight",
});
}
const selectedPath = assertNoCancel(
await select({
message: "Select credential field",
options,
}),
"Secrets configure cancelled.",
);
if (selectedPath === "__done__") {
break;
}
const candidate = candidates.find((entry) => entry.path === selectedPath);
if (!candidate) {
throw new Error(`Unknown configure target: ${selectedPath}`);
}
const source = assertNoCancel(
await select({
message: "Secret source",
options: sourceChoices,
}),
"Secrets configure cancelled.",
) as SecretRefSource;
const defaultAlias = resolveDefaultSecretProviderAlias(stagedConfig, source, {
preferFirstProviderForSource: true,
});
const provider = assertNoCancel(
await text({
message: "Provider alias",
initialValue: defaultAlias,
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return "Required";
}
if (!PROVIDER_ALIAS_PATTERN.test(trimmed)) {
return "Must match /^[a-z][a-z0-9_-]{0,63}$/";
}
return undefined;
},
}),
"Secrets configure cancelled.",
);
const id = assertNoCancel(
await text({
message: "Secret id",
validate: (value) => (String(value ?? "").trim().length > 0 ? undefined : "Required"),
}),
"Secrets configure cancelled.",
);
const ref: SecretRef = {
source,
provider: String(provider).trim(),
id: String(id).trim(),
};
const next = {
...candidate,
ref,
};
selectedByPath.set(candidate.path, next);
const addMore = assertNoCancel(
await confirm({
message: "Configure another credential?",
initialValue: true,
}),
"Secrets configure cancelled.",
);
if (!addMore) {
break;
}
}
}
if (
selectedByPath.size === 0 &&
Object.keys(providerChanges.upserts).length === 0 &&
providerChanges.deletes.length === 0
) {
throw new Error("No secrets changes were selected.");
}
const plan: SecretsApplyPlan = {
version: 1,
protocolVersion: 1,
generatedAt: new Date().toISOString(),
generatedBy: "openclaw secrets configure",
targets: [...selectedByPath.values()].map((entry) => ({
type: entry.type,
path: entry.path,
pathSegments: [...entry.pathSegments],
ref: entry.ref,
...(entry.providerId ? { providerId: entry.providerId } : {}),
...(entry.accountId ? { accountId: entry.accountId } : {}),
})),
...(Object.keys(providerChanges.upserts).length > 0
? { providerUpserts: providerChanges.upserts }
: {}),
...(providerChanges.deletes.length > 0 ? { providerDeletes: providerChanges.deletes } : {}),
options: {
scrubEnv: true,
scrubAuthProfilesForProviderTargets: true,
scrubLegacyAuthJson: true,
},
};
const preflight = await runSecretsApply({
plan,
env,
write: false,
});
return { plan, preflight };
}

View File

@@ -0,0 +1,94 @@
function failOrUndefined(params: { onMissing: "throw" | "undefined"; message: string }): undefined {
if (params.onMissing === "throw") {
throw new Error(params.message);
}
return undefined;
}
export function decodeJsonPointerToken(token: string): string {
return token.replace(/~1/g, "/").replace(/~0/g, "~");
}
export function encodeJsonPointerToken(token: string): string {
return token.replace(/~/g, "~0").replace(/\//g, "~1");
}
export function readJsonPointer(
root: unknown,
pointer: string,
options: { onMissing?: "throw" | "undefined" } = {},
): unknown {
const onMissing = options.onMissing ?? "throw";
if (!pointer.startsWith("/")) {
return failOrUndefined({
onMissing,
message:
'File-backed secret ids must be absolute JSON pointers (for example: "/providers/openai/apiKey").',
});
}
const tokens = pointer
.slice(1)
.split("/")
.map((token) => decodeJsonPointerToken(token));
let current: unknown = root;
for (const token of tokens) {
if (Array.isArray(current)) {
const index = Number.parseInt(token, 10);
if (!Number.isFinite(index) || index < 0 || index >= current.length) {
return failOrUndefined({
onMissing,
message: `JSON pointer segment "${token}" is out of bounds.`,
});
}
current = current[index];
continue;
}
if (typeof current !== "object" || current === null || Array.isArray(current)) {
return failOrUndefined({
onMissing,
message: `JSON pointer segment "${token}" does not exist.`,
});
}
const record = current as Record<string, unknown>;
if (!Object.hasOwn(record, token)) {
return failOrUndefined({
onMissing,
message: `JSON pointer segment "${token}" does not exist.`,
});
}
current = record[token];
}
return current;
}
export function setJsonPointer(
root: Record<string, unknown>,
pointer: string,
value: unknown,
): void {
if (!pointer.startsWith("/")) {
throw new Error(`Invalid JSON pointer "${pointer}".`);
}
const tokens = pointer
.slice(1)
.split("/")
.map((token) => decodeJsonPointerToken(token));
let current: Record<string, unknown> = root;
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
const isLast = index === tokens.length - 1;
if (isLast) {
current[token] = value;
return;
}
const child = current[token];
if (typeof child !== "object" || child === null || Array.isArray(child)) {
current[token] = {};
}
current = current[token] as Record<string, unknown>;
}
}

View File

@@ -0,0 +1,238 @@
import type { SecretProviderConfig, SecretRef } from "../config/types.secrets.js";
import { SecretProviderSchema } from "../config/zod-schema.core.js";
export type SecretsPlanTargetType =
| "models.providers.apiKey"
| "skills.entries.apiKey"
| "channels.googlechat.serviceAccount";
export type SecretsPlanTarget = {
type: SecretsPlanTargetType;
/**
* Dot path in openclaw.json for operator readability.
* Example: "models.providers.openai.apiKey"
*/
path: string;
/**
* Canonical path segments used for safe mutation.
* Example: ["models", "providers", "openai", "apiKey"]
*/
pathSegments?: string[];
ref: SecretRef;
/**
* For provider targets, used to scrub auth-profile/static residues.
*/
providerId?: string;
/**
* For googlechat account-scoped targets.
*/
accountId?: string;
};
export type SecretsApplyPlan = {
version: 1;
protocolVersion: 1;
generatedAt: string;
generatedBy: "openclaw secrets configure" | "manual";
providerUpserts?: Record<string, SecretProviderConfig>;
providerDeletes?: string[];
targets: SecretsPlanTarget[];
options?: {
scrubEnv?: boolean;
scrubAuthProfilesForProviderTargets?: boolean;
scrubLegacyAuthJson?: boolean;
};
};
const PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
const FORBIDDEN_PATH_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
function isSecretsPlanTargetType(value: unknown): value is SecretsPlanTargetType {
return (
value === "models.providers.apiKey" ||
value === "skills.entries.apiKey" ||
value === "channels.googlechat.serviceAccount"
);
}
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isSecretProviderConfigShape(value: unknown): value is SecretProviderConfig {
return SecretProviderSchema.safeParse(value).success;
}
function parseDotPath(pathname: string): string[] {
return pathname
.split(".")
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0);
}
function hasForbiddenPathSegment(segments: string[]): boolean {
return segments.some((segment) => FORBIDDEN_PATH_SEGMENTS.has(segment));
}
function hasMatchingPathShape(
candidate: Pick<SecretsPlanTarget, "type" | "providerId" | "accountId">,
segments: string[],
): boolean {
if (candidate.type === "models.providers.apiKey") {
if (
segments.length !== 4 ||
segments[0] !== "models" ||
segments[1] !== "providers" ||
segments[3] !== "apiKey"
) {
return false;
}
return (
candidate.providerId === undefined ||
candidate.providerId.trim().length === 0 ||
candidate.providerId === segments[2]
);
}
if (candidate.type === "skills.entries.apiKey") {
return (
segments.length === 4 &&
segments[0] === "skills" &&
segments[1] === "entries" &&
segments[3] === "apiKey"
);
}
if (
segments.length === 3 &&
segments[0] === "channels" &&
segments[1] === "googlechat" &&
segments[2] === "serviceAccount"
) {
return candidate.accountId === undefined || candidate.accountId.trim().length === 0;
}
if (
segments.length === 5 &&
segments[0] === "channels" &&
segments[1] === "googlechat" &&
segments[2] === "accounts" &&
segments[4] === "serviceAccount"
) {
return (
candidate.accountId === undefined ||
candidate.accountId.trim().length === 0 ||
candidate.accountId === segments[3]
);
}
return false;
}
export function resolveValidatedTargetPathSegments(candidate: {
type?: SecretsPlanTargetType;
path?: string;
pathSegments?: string[];
providerId?: string;
accountId?: string;
}): string[] | null {
if (!isSecretsPlanTargetType(candidate.type)) {
return null;
}
const path = typeof candidate.path === "string" ? candidate.path.trim() : "";
if (!path) {
return null;
}
const segments =
Array.isArray(candidate.pathSegments) && candidate.pathSegments.length > 0
? candidate.pathSegments.map((segment) => String(segment).trim()).filter(Boolean)
: parseDotPath(path);
if (
segments.length === 0 ||
hasForbiddenPathSegment(segments) ||
path !== segments.join(".") ||
!hasMatchingPathShape(
{
type: candidate.type,
providerId: candidate.providerId,
accountId: candidate.accountId,
},
segments,
)
) {
return null;
}
return segments;
}
export function isSecretsApplyPlan(value: unknown): value is SecretsApplyPlan {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const typed = value as Partial<SecretsApplyPlan>;
if (typed.version !== 1 || typed.protocolVersion !== 1 || !Array.isArray(typed.targets)) {
return false;
}
for (const target of typed.targets) {
if (!target || typeof target !== "object") {
return false;
}
const candidate = target as Partial<SecretsPlanTarget>;
const ref = candidate.ref as Partial<SecretRef> | undefined;
if (
(candidate.type !== "models.providers.apiKey" &&
candidate.type !== "skills.entries.apiKey" &&
candidate.type !== "channels.googlechat.serviceAccount") ||
typeof candidate.path !== "string" ||
!candidate.path.trim() ||
(candidate.pathSegments !== undefined && !Array.isArray(candidate.pathSegments)) ||
!resolveValidatedTargetPathSegments({
type: candidate.type,
path: candidate.path,
pathSegments: candidate.pathSegments,
providerId: candidate.providerId,
accountId: candidate.accountId,
}) ||
!ref ||
typeof ref !== "object" ||
(ref.source !== "env" && ref.source !== "file" && ref.source !== "exec") ||
typeof ref.provider !== "string" ||
ref.provider.trim().length === 0 ||
typeof ref.id !== "string" ||
ref.id.trim().length === 0
) {
return false;
}
}
if (typed.providerUpserts !== undefined) {
if (!isObjectRecord(typed.providerUpserts)) {
return false;
}
for (const [providerAlias, providerValue] of Object.entries(typed.providerUpserts)) {
if (!PROVIDER_ALIAS_PATTERN.test(providerAlias)) {
return false;
}
if (!isSecretProviderConfigShape(providerValue)) {
return false;
}
}
}
if (typed.providerDeletes !== undefined) {
if (
!Array.isArray(typed.providerDeletes) ||
typed.providerDeletes.some(
(providerAlias) =>
typeof providerAlias !== "string" || !PROVIDER_ALIAS_PATTERN.test(providerAlias),
)
) {
return false;
}
}
return true;
}
export function normalizeSecretsPlanOptions(
options: SecretsApplyPlan["options"] | undefined,
): Required<NonNullable<SecretsApplyPlan["options"]>> {
return {
scrubEnv: options?.scrubEnv ?? true,
scrubAuthProfilesForProviderTargets: options?.scrubAuthProfilesForProviderTargets ?? true,
scrubLegacyAuthJson: options?.scrubLegacyAuthJson ?? true,
};
}

View File

@@ -0,0 +1,30 @@
export const PROVIDER_ENV_VARS: Record<string, readonly string[]> = {
openai: ["OPENAI_API_KEY"],
anthropic: ["ANTHROPIC_API_KEY"],
google: ["GEMINI_API_KEY"],
minimax: ["MINIMAX_API_KEY"],
"minimax-cn": ["MINIMAX_API_KEY"],
moonshot: ["MOONSHOT_API_KEY"],
"kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"],
synthetic: ["SYNTHETIC_API_KEY"],
venice: ["VENICE_API_KEY"],
zai: ["ZAI_API_KEY", "Z_AI_API_KEY"],
xiaomi: ["XIAOMI_API_KEY"],
openrouter: ["OPENROUTER_API_KEY"],
"cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"],
litellm: ["LITELLM_API_KEY"],
"vercel-ai-gateway": ["AI_GATEWAY_API_KEY"],
opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
together: ["TOGETHER_API_KEY"],
huggingface: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"],
qianfan: ["QIANFAN_API_KEY"],
xai: ["XAI_API_KEY"],
mistral: ["MISTRAL_API_KEY"],
kilocode: ["KILOCODE_API_KEY"],
volcengine: ["VOLCANO_ENGINE_API_KEY"],
byteplus: ["BYTEPLUS_API_KEY"],
};
export function listKnownSecretEnvVarNames(): string[] {
return [...new Set(Object.values(PROVIDER_ENV_VARS).flatMap((keys) => keys))];
}

View File

@@ -0,0 +1,66 @@
import {
DEFAULT_SECRET_PROVIDER_ALIAS,
type SecretRef,
type SecretRefSource,
} from "../config/types.secrets.js";
const FILE_SECRET_REF_SEGMENT_PATTERN = /^(?:[^~]|~0|~1)*$/;
export const SINGLE_VALUE_FILE_REF_ID = "value";
export type SecretRefDefaultsCarrier = {
secrets?: {
defaults?: {
env?: string;
file?: string;
exec?: string;
};
providers?: Record<string, { source?: string }>;
};
};
export function secretRefKey(ref: SecretRef): string {
return `${ref.source}:${ref.provider}:${ref.id}`;
}
export function resolveDefaultSecretProviderAlias(
config: SecretRefDefaultsCarrier,
source: SecretRefSource,
options?: { preferFirstProviderForSource?: boolean },
): string {
const configured =
source === "env"
? config.secrets?.defaults?.env
: source === "file"
? config.secrets?.defaults?.file
: config.secrets?.defaults?.exec;
if (configured?.trim()) {
return configured.trim();
}
if (options?.preferFirstProviderForSource) {
const providers = config.secrets?.providers;
if (providers) {
for (const [providerName, provider] of Object.entries(providers)) {
if (provider?.source === source) {
return providerName;
}
}
}
}
return DEFAULT_SECRET_PROVIDER_ALIAS;
}
export function isValidFileSecretRefId(value: string): boolean {
if (value === SINGLE_VALUE_FILE_REF_ID) {
return true;
}
if (!value.startsWith("/")) {
return false;
}
return value
.slice(1)
.split("/")
.every((segment) => FILE_SECRET_REF_SEGMENT_PATTERN.test(segment));
}

View File

@@ -0,0 +1,536 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resolveSecretRefString, resolveSecretRefValue } from "./resolve.js";
async function writeSecureFile(filePath: string, content: string, mode = 0o600): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, "utf8");
await fs.chmod(filePath, mode);
}
describe("secret ref resolver", () => {
const cleanupRoots: string[] = [];
afterEach(async () => {
vi.restoreAllMocks();
while (cleanupRoots.length > 0) {
const root = cleanupRoots.pop();
if (!root) {
continue;
}
await fs.rm(root, { recursive: true, force: true });
}
});
it("resolves env refs via implicit default env provider", async () => {
const config: OpenClawConfig = {};
const value = await resolveSecretRefString(
{ source: "env", provider: "default", id: "OPENAI_API_KEY" },
{
config,
env: { OPENAI_API_KEY: "sk-env-value" },
},
);
expect(value).toBe("sk-env-value");
});
it("resolves file refs in json mode", async () => {
if (process.platform === "win32") {
return;
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-file-"));
cleanupRoots.push(root);
const filePath = path.join(root, "secrets.json");
await writeSecureFile(
filePath,
JSON.stringify({
providers: {
openai: {
apiKey: "sk-file-value",
},
},
}),
);
const value = await resolveSecretRefString(
{ source: "file", provider: "filemain", id: "/providers/openai/apiKey" },
{
config: {
secrets: {
providers: {
filemain: {
source: "file",
path: filePath,
mode: "json",
},
},
},
},
},
);
expect(value).toBe("sk-file-value");
});
it("resolves exec refs with protocolVersion 1 response", async () => {
if (process.platform === "win32") {
return;
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-exec-"));
cleanupRoots.push(root);
const scriptPath = path.join(root, "resolver.mjs");
await writeSecureFile(
scriptPath,
[
"#!/usr/bin/env node",
"import fs from 'node:fs';",
"const req = JSON.parse(fs.readFileSync(0, 'utf8'));",
"const values = Object.fromEntries((req.ids ?? []).map((id) => [id, `value:${id}`]));",
"process.stdout.write(JSON.stringify({ protocolVersion: 1, values }));",
].join("\n"),
0o700,
);
const value = await resolveSecretRefString(
{ source: "exec", provider: "execmain", id: "openai/api-key" },
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: scriptPath,
passEnv: ["PATH"],
},
},
},
},
},
);
expect(value).toBe("value:openai/api-key");
});
it("supports non-JSON single-value exec output when jsonOnly is false", async () => {
if (process.platform === "win32") {
return;
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-exec-plain-"));
cleanupRoots.push(root);
const scriptPath = path.join(root, "resolver-plain.mjs");
await writeSecureFile(
scriptPath,
["#!/usr/bin/env node", "process.stdout.write('plain-secret');"].join("\n"),
0o700,
);
const value = await resolveSecretRefString(
{ source: "exec", provider: "execmain", id: "openai/api-key" },
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: scriptPath,
passEnv: ["PATH"],
jsonOnly: false,
},
},
},
},
},
);
expect(value).toBe("plain-secret");
});
it("rejects symlink command paths unless allowSymlinkCommand is enabled", async () => {
if (process.platform === "win32") {
return;
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-exec-link-"));
cleanupRoots.push(root);
const scriptPath = path.join(root, "resolver-target.mjs");
const symlinkPath = path.join(root, "resolver-link.mjs");
await writeSecureFile(
scriptPath,
["#!/usr/bin/env node", "process.stdout.write('plain-secret');"].join("\n"),
0o700,
);
await fs.symlink(scriptPath, symlinkPath);
await expect(
resolveSecretRefString(
{ source: "exec", provider: "execmain", id: "openai/api-key" },
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: symlinkPath,
passEnv: ["PATH"],
jsonOnly: false,
},
},
},
},
},
),
).rejects.toThrow("must not be a symlink");
});
it("allows symlink command paths when allowSymlinkCommand is enabled", async () => {
if (process.platform === "win32") {
return;
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-exec-link-"));
cleanupRoots.push(root);
const scriptPath = path.join(root, "resolver-target.mjs");
const symlinkPath = path.join(root, "resolver-link.mjs");
await writeSecureFile(
scriptPath,
["#!/usr/bin/env node", "process.stdout.write('plain-secret');"].join("\n"),
0o700,
);
await fs.symlink(scriptPath, symlinkPath);
const trustedRoot = await fs.realpath(root);
const value = await resolveSecretRefString(
{ source: "exec", provider: "execmain", id: "openai/api-key" },
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: symlinkPath,
passEnv: ["PATH"],
jsonOnly: false,
allowSymlinkCommand: true,
trustedDirs: [trustedRoot],
},
},
},
},
},
);
expect(value).toBe("plain-secret");
});
it("handles Homebrew-style symlinked exec commands with args only when explicitly allowed", async () => {
if (process.platform === "win32") {
return;
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-homebrew-"));
cleanupRoots.push(root);
const binDir = path.join(root, "opt", "homebrew", "bin");
const cellarDir = path.join(root, "opt", "homebrew", "Cellar", "node", "25.0.0", "bin");
await fs.mkdir(binDir, { recursive: true });
await fs.mkdir(cellarDir, { recursive: true });
const targetCommand = path.join(cellarDir, "node");
const symlinkCommand = path.join(binDir, "node");
await writeSecureFile(
targetCommand,
[
`#!${process.execPath}`,
"import fs from 'node:fs';",
"const req = JSON.parse(fs.readFileSync(0, 'utf8'));",
"const suffix = process.argv[2] ?? 'missing';",
"const values = Object.fromEntries((req.ids ?? []).map((id) => [id, `${suffix}:${id}`]));",
"process.stdout.write(JSON.stringify({ protocolVersion: 1, values }));",
].join("\n"),
0o700,
);
await fs.symlink(targetCommand, symlinkCommand);
const trustedRoot = await fs.realpath(root);
await expect(
resolveSecretRefString(
{ source: "exec", provider: "execmain", id: "openai/api-key" },
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: symlinkCommand,
args: ["brew"],
passEnv: ["PATH"],
},
},
},
},
},
),
).rejects.toThrow("must not be a symlink");
const value = await resolveSecretRefString(
{ source: "exec", provider: "execmain", id: "openai/api-key" },
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: symlinkCommand,
args: ["brew"],
allowSymlinkCommand: true,
trustedDirs: [trustedRoot],
},
},
},
},
},
);
expect(value).toBe("brew:openai/api-key");
});
it("checks trustedDirs against resolved symlink target", async () => {
if (process.platform === "win32") {
return;
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-exec-link-"));
const outside = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-exec-out-"));
cleanupRoots.push(root);
cleanupRoots.push(outside);
const scriptPath = path.join(outside, "resolver-target.mjs");
const symlinkPath = path.join(root, "resolver-link.mjs");
await writeSecureFile(
scriptPath,
["#!/usr/bin/env node", "process.stdout.write('plain-secret');"].join("\n"),
0o700,
);
await fs.symlink(scriptPath, symlinkPath);
await expect(
resolveSecretRefString(
{ source: "exec", provider: "execmain", id: "openai/api-key" },
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: symlinkPath,
passEnv: ["PATH"],
jsonOnly: false,
allowSymlinkCommand: true,
trustedDirs: [root],
},
},
},
},
},
),
).rejects.toThrow("outside trustedDirs");
});
it("rejects exec refs when protocolVersion is not 1", async () => {
if (process.platform === "win32") {
return;
}
const root = await fs.mkdtemp(
path.join(os.tmpdir(), "openclaw-secrets-resolve-exec-protocol-"),
);
cleanupRoots.push(root);
const scriptPath = path.join(root, "resolver-protocol.mjs");
await writeSecureFile(
scriptPath,
[
"#!/usr/bin/env node",
"process.stdout.write(JSON.stringify({ protocolVersion: 2, values: { 'openai/api-key': 'x' } }));",
].join("\n"),
0o700,
);
await expect(
resolveSecretRefString(
{ source: "exec", provider: "execmain", id: "openai/api-key" },
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: scriptPath,
passEnv: ["PATH"],
},
},
},
},
},
),
).rejects.toThrow("protocolVersion must be 1");
});
it("rejects exec refs when response omits requested id", async () => {
if (process.platform === "win32") {
return;
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-exec-id-"));
cleanupRoots.push(root);
const scriptPath = path.join(root, "resolver-missing-id.mjs");
await writeSecureFile(
scriptPath,
[
"#!/usr/bin/env node",
"process.stdout.write(JSON.stringify({ protocolVersion: 1, values: {} }));",
].join("\n"),
0o700,
);
await expect(
resolveSecretRefString(
{ source: "exec", provider: "execmain", id: "openai/api-key" },
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: scriptPath,
passEnv: ["PATH"],
},
},
},
},
},
),
).rejects.toThrow('response missing id "openai/api-key"');
});
it("rejects exec refs with invalid JSON when jsonOnly is true", async () => {
if (process.platform === "win32") {
return;
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-exec-json-"));
cleanupRoots.push(root);
const scriptPath = path.join(root, "resolver-invalid-json.mjs");
await writeSecureFile(
scriptPath,
["#!/usr/bin/env node", "process.stdout.write('not-json');"].join("\n"),
0o700,
);
await expect(
resolveSecretRefString(
{ source: "exec", provider: "execmain", id: "openai/api-key" },
{
config: {
secrets: {
providers: {
execmain: {
source: "exec",
command: scriptPath,
passEnv: ["PATH"],
jsonOnly: true,
},
},
},
},
},
),
).rejects.toThrow("returned invalid JSON");
});
it("supports file singleValue mode with id=value", async () => {
if (process.platform === "win32") {
return;
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-single-value-"));
cleanupRoots.push(root);
const filePath = path.join(root, "token.txt");
await writeSecureFile(filePath, "raw-token-value\n");
const value = await resolveSecretRefString(
{ source: "file", provider: "rawfile", id: "value" },
{
config: {
secrets: {
providers: {
rawfile: {
source: "file",
path: filePath,
mode: "singleValue",
},
},
},
},
},
);
expect(value).toBe("raw-token-value");
});
it("times out file provider reads when timeoutMs elapses", async () => {
if (process.platform === "win32") {
return;
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-timeout-"));
cleanupRoots.push(root);
const filePath = path.join(root, "secrets.json");
await writeSecureFile(
filePath,
JSON.stringify({
providers: {
openai: {
apiKey: "sk-file-value",
},
},
}),
);
const originalReadFile = fs.readFile.bind(fs);
vi.spyOn(fs, "readFile").mockImplementation(((
targetPath: Parameters<typeof fs.readFile>[0],
options?: Parameters<typeof fs.readFile>[1],
) => {
if (typeof targetPath === "string" && targetPath === filePath) {
return new Promise<Buffer>(() => {});
}
return originalReadFile(targetPath, options);
}) as typeof fs.readFile);
await expect(
resolveSecretRefString(
{ source: "file", provider: "filemain", id: "/providers/openai/apiKey" },
{
config: {
secrets: {
providers: {
filemain: {
source: "file",
path: filePath,
mode: "json",
timeoutMs: 5,
},
},
},
},
},
),
).rejects.toThrow('File provider "filemain" timed out');
});
it("rejects misconfigured provider source mismatches", async () => {
await expect(
resolveSecretRefValue(
{ source: "exec", provider: "default", id: "abc" },
{
config: {
secrets: {
providers: {
default: {
source: "env",
},
},
},
},
},
),
).rejects.toThrow('has source "env" but ref requests "exec"');
});
});

View File

@@ -0,0 +1,714 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import type {
ExecSecretProviderConfig,
FileSecretProviderConfig,
SecretProviderConfig,
SecretRef,
SecretRefSource,
} from "../config/types.secrets.js";
import { inspectPathPermissions, safeStat } from "../security/audit-fs.js";
import { isPathInside } from "../security/scan-paths.js";
import { resolveUserPath } from "../utils.js";
import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js";
import { readJsonPointer } from "./json-pointer.js";
import {
SINGLE_VALUE_FILE_REF_ID,
resolveDefaultSecretProviderAlias,
secretRefKey,
} from "./ref-contract.js";
import { isNonEmptyString, isRecord, normalizePositiveInt } from "./shared.js";
const DEFAULT_PROVIDER_CONCURRENCY = 4;
const DEFAULT_MAX_REFS_PER_PROVIDER = 512;
const DEFAULT_MAX_BATCH_BYTES = 256 * 1024;
const DEFAULT_FILE_MAX_BYTES = 1024 * 1024;
const DEFAULT_FILE_TIMEOUT_MS = 5_000;
const DEFAULT_EXEC_TIMEOUT_MS = 5_000;
const DEFAULT_EXEC_NO_OUTPUT_TIMEOUT_MS = 2_000;
const DEFAULT_EXEC_MAX_OUTPUT_BYTES = 1024 * 1024;
const WINDOWS_ABS_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/;
export type SecretRefResolveCache = {
resolvedByRefKey?: Map<string, Promise<unknown>>;
filePayloadByProvider?: Map<string, Promise<unknown>>;
};
type ResolveSecretRefOptions = {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
cache?: SecretRefResolveCache;
};
type ResolutionLimits = {
maxProviderConcurrency: number;
maxRefsPerProvider: number;
maxBatchBytes: number;
};
type ProviderResolutionOutput = Map<string, unknown>;
function isAbsolutePathname(value: string): boolean {
return (
path.isAbsolute(value) ||
WINDOWS_ABS_PATH_PATTERN.test(value) ||
WINDOWS_UNC_PATH_PATTERN.test(value)
);
}
function resolveResolutionLimits(config: OpenClawConfig): ResolutionLimits {
const resolution = config.secrets?.resolution;
return {
maxProviderConcurrency: normalizePositiveInt(
resolution?.maxProviderConcurrency,
DEFAULT_PROVIDER_CONCURRENCY,
),
maxRefsPerProvider: normalizePositiveInt(
resolution?.maxRefsPerProvider,
DEFAULT_MAX_REFS_PER_PROVIDER,
),
maxBatchBytes: normalizePositiveInt(resolution?.maxBatchBytes, DEFAULT_MAX_BATCH_BYTES),
};
}
function toProviderKey(source: SecretRefSource, provider: string): string {
return `${source}:${provider}`;
}
function resolveConfiguredProvider(ref: SecretRef, config: OpenClawConfig): SecretProviderConfig {
const providerConfig = config.secrets?.providers?.[ref.provider];
if (!providerConfig) {
if (ref.source === "env" && ref.provider === resolveDefaultSecretProviderAlias(config, "env")) {
return { source: "env" };
}
throw new Error(
`Secret provider "${ref.provider}" is not configured (ref: ${ref.source}:${ref.provider}:${ref.id}).`,
);
}
if (providerConfig.source !== ref.source) {
throw new Error(
`Secret provider "${ref.provider}" has source "${providerConfig.source}" but ref requests "${ref.source}".`,
);
}
return providerConfig;
}
async function assertSecurePath(params: {
targetPath: string;
label: string;
trustedDirs?: string[];
allowInsecurePath?: boolean;
allowReadableByOthers?: boolean;
allowSymlinkPath?: boolean;
}): Promise<string> {
if (!isAbsolutePathname(params.targetPath)) {
throw new Error(`${params.label} must be an absolute path.`);
}
let effectivePath = params.targetPath;
let stat = await safeStat(effectivePath);
if (!stat.ok) {
throw new Error(`${params.label} is not readable: ${effectivePath}`);
}
if (stat.isDir) {
throw new Error(`${params.label} must be a file: ${effectivePath}`);
}
if (stat.isSymlink) {
if (!params.allowSymlinkPath) {
throw new Error(`${params.label} must not be a symlink: ${effectivePath}`);
}
try {
effectivePath = await fs.realpath(effectivePath);
} catch {
throw new Error(`${params.label} symlink target is not readable: ${params.targetPath}`);
}
if (!isAbsolutePathname(effectivePath)) {
throw new Error(`${params.label} resolved symlink target must be an absolute path.`);
}
stat = await safeStat(effectivePath);
if (!stat.ok) {
throw new Error(`${params.label} is not readable: ${effectivePath}`);
}
if (stat.isDir) {
throw new Error(`${params.label} must be a file: ${effectivePath}`);
}
if (stat.isSymlink) {
throw new Error(`${params.label} symlink target must not be a symlink: ${effectivePath}`);
}
}
if (params.trustedDirs && params.trustedDirs.length > 0) {
const trusted = params.trustedDirs.map((entry) => resolveUserPath(entry));
const inTrustedDir = trusted.some((dir) => isPathInside(dir, effectivePath));
if (!inTrustedDir) {
throw new Error(`${params.label} is outside trustedDirs: ${effectivePath}`);
}
}
if (params.allowInsecurePath) {
return effectivePath;
}
const perms = await inspectPathPermissions(effectivePath);
if (!perms.ok) {
throw new Error(`${params.label} permissions could not be verified: ${effectivePath}`);
}
const writableByOthers = perms.worldWritable || perms.groupWritable;
const readableByOthers = perms.worldReadable || perms.groupReadable;
if (writableByOthers || (!params.allowReadableByOthers && readableByOthers)) {
throw new Error(`${params.label} permissions are too open: ${effectivePath}`);
}
if (process.platform === "win32" && perms.source === "unknown") {
throw new Error(
`${params.label} ACL verification unavailable on Windows for ${effectivePath}.`,
);
}
if (process.platform !== "win32" && typeof process.getuid === "function" && stat.uid != null) {
const uid = process.getuid();
if (stat.uid !== uid) {
throw new Error(
`${params.label} must be owned by the current user (uid=${uid}): ${effectivePath}`,
);
}
}
return effectivePath;
}
async function readFileProviderPayload(params: {
providerName: string;
providerConfig: FileSecretProviderConfig;
cache?: SecretRefResolveCache;
}): Promise<unknown> {
const cacheKey = params.providerName;
const cache = params.cache;
if (cache?.filePayloadByProvider?.has(cacheKey)) {
return await (cache.filePayloadByProvider.get(cacheKey) as Promise<unknown>);
}
const filePath = resolveUserPath(params.providerConfig.path);
const readPromise = (async () => {
const secureFilePath = await assertSecurePath({
targetPath: filePath,
label: `secrets.providers.${params.providerName}.path`,
});
const timeoutMs = normalizePositiveInt(
params.providerConfig.timeoutMs,
DEFAULT_FILE_TIMEOUT_MS,
);
const maxBytes = normalizePositiveInt(params.providerConfig.maxBytes, DEFAULT_FILE_MAX_BYTES);
const abortController = new AbortController();
const timeoutErrorMessage = `File provider "${params.providerName}" timed out after ${timeoutMs}ms.`;
let timeoutHandle: NodeJS.Timeout | null = null;
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timeoutHandle = setTimeout(() => {
abortController.abort();
reject(new Error(timeoutErrorMessage));
}, timeoutMs);
});
try {
const payload = await Promise.race([
fs.readFile(secureFilePath, { signal: abortController.signal }),
timeoutPromise,
]);
if (payload.byteLength > maxBytes) {
throw new Error(`File provider "${params.providerName}" exceeded maxBytes (${maxBytes}).`);
}
const text = payload.toString("utf8");
if (params.providerConfig.mode === "singleValue") {
return text.replace(/\r?\n$/, "");
}
const parsed = JSON.parse(text) as unknown;
if (!isRecord(parsed)) {
throw new Error(`File provider "${params.providerName}" payload is not a JSON object.`);
}
return parsed;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error(timeoutErrorMessage, { cause: error });
}
throw error;
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
})();
if (cache) {
cache.filePayloadByProvider ??= new Map();
cache.filePayloadByProvider.set(cacheKey, readPromise);
}
return await readPromise;
}
async function resolveEnvRefs(params: {
refs: SecretRef[];
providerName: string;
providerConfig: Extract<SecretProviderConfig, { source: "env" }>;
env: NodeJS.ProcessEnv;
}): Promise<ProviderResolutionOutput> {
const resolved = new Map<string, unknown>();
const allowlist = params.providerConfig.allowlist
? new Set(params.providerConfig.allowlist)
: null;
for (const ref of params.refs) {
if (allowlist && !allowlist.has(ref.id)) {
throw new Error(
`Environment variable "${ref.id}" is not allowlisted in secrets.providers.${params.providerName}.allowlist.`,
);
}
const envValue = params.env[ref.id] ?? process.env[ref.id];
if (!isNonEmptyString(envValue)) {
throw new Error(`Environment variable "${ref.id}" is missing or empty.`);
}
resolved.set(ref.id, envValue);
}
return resolved;
}
async function resolveFileRefs(params: {
refs: SecretRef[];
providerName: string;
providerConfig: FileSecretProviderConfig;
cache?: SecretRefResolveCache;
}): Promise<ProviderResolutionOutput> {
const payload = await readFileProviderPayload({
providerName: params.providerName,
providerConfig: params.providerConfig,
cache: params.cache,
});
const mode = params.providerConfig.mode ?? "json";
const resolved = new Map<string, unknown>();
if (mode === "singleValue") {
for (const ref of params.refs) {
if (ref.id !== SINGLE_VALUE_FILE_REF_ID) {
throw new Error(
`singleValue file provider "${params.providerName}" expects ref id "${SINGLE_VALUE_FILE_REF_ID}".`,
);
}
resolved.set(ref.id, payload);
}
return resolved;
}
for (const ref of params.refs) {
resolved.set(ref.id, readJsonPointer(payload, ref.id, { onMissing: "throw" }));
}
return resolved;
}
type ExecRunResult = {
stdout: string;
stderr: string;
code: number | null;
signal: NodeJS.Signals | null;
termination: "exit" | "timeout" | "no-output-timeout";
};
async function runExecResolver(params: {
command: string;
args: string[];
cwd: string;
env: NodeJS.ProcessEnv;
input: string;
timeoutMs: number;
noOutputTimeoutMs: number;
maxOutputBytes: number;
}): Promise<ExecRunResult> {
return await new Promise((resolve, reject) => {
const child = spawn(params.command, params.args, {
cwd: params.cwd,
env: params.env,
stdio: ["pipe", "pipe", "pipe"],
shell: false,
windowsHide: true,
});
let settled = false;
let stdout = "";
let stderr = "";
let timedOut = false;
let noOutputTimedOut = false;
let outputBytes = 0;
let noOutputTimer: NodeJS.Timeout | null = null;
const timeoutTimer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, params.timeoutMs);
const clearTimers = () => {
clearTimeout(timeoutTimer);
if (noOutputTimer) {
clearTimeout(noOutputTimer);
noOutputTimer = null;
}
};
const armNoOutputTimer = () => {
if (noOutputTimer) {
clearTimeout(noOutputTimer);
}
noOutputTimer = setTimeout(() => {
noOutputTimedOut = true;
child.kill("SIGKILL");
}, params.noOutputTimeoutMs);
};
const append = (chunk: Buffer | string, target: "stdout" | "stderr") => {
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
outputBytes += Buffer.byteLength(text, "utf8");
if (outputBytes > params.maxOutputBytes) {
child.kill("SIGKILL");
if (!settled) {
settled = true;
clearTimers();
reject(
new Error(`Exec provider output exceeded maxOutputBytes (${params.maxOutputBytes}).`),
);
}
return;
}
if (target === "stdout") {
stdout += text;
} else {
stderr += text;
}
armNoOutputTimer();
};
armNoOutputTimer();
child.on("error", (error) => {
if (settled) {
return;
}
settled = true;
clearTimers();
reject(error);
});
child.stdout?.on("data", (chunk) => append(chunk, "stdout"));
child.stderr?.on("data", (chunk) => append(chunk, "stderr"));
child.on("close", (code, signal) => {
if (settled) {
return;
}
settled = true;
clearTimers();
resolve({
stdout,
stderr,
code,
signal,
termination: noOutputTimedOut ? "no-output-timeout" : timedOut ? "timeout" : "exit",
});
});
child.stdin?.end(params.input);
});
}
function parseExecValues(params: {
providerName: string;
ids: string[];
stdout: string;
jsonOnly: boolean;
}): Record<string, unknown> {
const trimmed = params.stdout.trim();
if (!trimmed) {
throw new Error(`Exec provider "${params.providerName}" returned empty stdout.`);
}
let parsed: unknown;
if (!params.jsonOnly && params.ids.length === 1) {
try {
parsed = JSON.parse(trimmed) as unknown;
} catch {
return { [params.ids[0]]: trimmed };
}
} else {
try {
parsed = JSON.parse(trimmed) as unknown;
} catch {
throw new Error(`Exec provider "${params.providerName}" returned invalid JSON.`);
}
}
if (!isRecord(parsed)) {
if (!params.jsonOnly && params.ids.length === 1 && typeof parsed === "string") {
return { [params.ids[0]]: parsed };
}
throw new Error(`Exec provider "${params.providerName}" response must be an object.`);
}
if (parsed.protocolVersion !== 1) {
throw new Error(`Exec provider "${params.providerName}" protocolVersion must be 1.`);
}
const responseValues = parsed.values;
if (!isRecord(responseValues)) {
throw new Error(`Exec provider "${params.providerName}" response missing "values".`);
}
const responseErrors = isRecord(parsed.errors) ? parsed.errors : null;
const out: Record<string, unknown> = {};
for (const id of params.ids) {
if (responseErrors && id in responseErrors) {
const entry = responseErrors[id];
if (isRecord(entry) && typeof entry.message === "string" && entry.message.trim()) {
throw new Error(
`Exec provider "${params.providerName}" failed for id "${id}" (${entry.message.trim()}).`,
);
}
throw new Error(`Exec provider "${params.providerName}" failed for id "${id}".`);
}
if (!(id in responseValues)) {
throw new Error(`Exec provider "${params.providerName}" response missing id "${id}".`);
}
out[id] = responseValues[id];
}
return out;
}
async function resolveExecRefs(params: {
refs: SecretRef[];
providerName: string;
providerConfig: ExecSecretProviderConfig;
env: NodeJS.ProcessEnv;
limits: ResolutionLimits;
}): Promise<ProviderResolutionOutput> {
const ids = [...new Set(params.refs.map((ref) => ref.id))];
if (ids.length > params.limits.maxRefsPerProvider) {
throw new Error(
`Exec provider "${params.providerName}" exceeded maxRefsPerProvider (${params.limits.maxRefsPerProvider}).`,
);
}
const commandPath = resolveUserPath(params.providerConfig.command);
const secureCommandPath = await assertSecurePath({
targetPath: commandPath,
label: `secrets.providers.${params.providerName}.command`,
trustedDirs: params.providerConfig.trustedDirs,
allowInsecurePath: params.providerConfig.allowInsecurePath,
allowReadableByOthers: true,
allowSymlinkPath: params.providerConfig.allowSymlinkCommand,
});
const requestPayload = {
protocolVersion: 1,
provider: params.providerName,
ids,
};
const input = JSON.stringify(requestPayload);
if (Buffer.byteLength(input, "utf8") > params.limits.maxBatchBytes) {
throw new Error(
`Exec provider "${params.providerName}" request exceeded maxBatchBytes (${params.limits.maxBatchBytes}).`,
);
}
const childEnv: NodeJS.ProcessEnv = {};
for (const key of params.providerConfig.passEnv ?? []) {
const value = params.env[key] ?? process.env[key];
if (value !== undefined) {
childEnv[key] = value;
}
}
for (const [key, value] of Object.entries(params.providerConfig.env ?? {})) {
childEnv[key] = value;
}
const timeoutMs = normalizePositiveInt(params.providerConfig.timeoutMs, DEFAULT_EXEC_TIMEOUT_MS);
const noOutputTimeoutMs = normalizePositiveInt(
params.providerConfig.noOutputTimeoutMs,
DEFAULT_EXEC_NO_OUTPUT_TIMEOUT_MS,
);
const maxOutputBytes = normalizePositiveInt(
params.providerConfig.maxOutputBytes,
DEFAULT_EXEC_MAX_OUTPUT_BYTES,
);
const jsonOnly = params.providerConfig.jsonOnly ?? true;
const result = await runExecResolver({
command: secureCommandPath,
args: params.providerConfig.args ?? [],
cwd: path.dirname(secureCommandPath),
env: childEnv,
input,
timeoutMs,
noOutputTimeoutMs,
maxOutputBytes,
});
if (result.termination === "timeout") {
throw new Error(`Exec provider "${params.providerName}" timed out after ${timeoutMs}ms.`);
}
if (result.termination === "no-output-timeout") {
throw new Error(
`Exec provider "${params.providerName}" produced no output for ${noOutputTimeoutMs}ms.`,
);
}
if (result.code !== 0) {
throw new Error(
`Exec provider "${params.providerName}" exited with code ${String(result.code)}.`,
);
}
const values = parseExecValues({
providerName: params.providerName,
ids,
stdout: result.stdout,
jsonOnly,
});
const resolved = new Map<string, unknown>();
for (const id of ids) {
resolved.set(id, values[id]);
}
return resolved;
}
async function resolveProviderRefs(params: {
refs: SecretRef[];
source: SecretRefSource;
providerName: string;
providerConfig: SecretProviderConfig;
options: ResolveSecretRefOptions;
limits: ResolutionLimits;
}): Promise<ProviderResolutionOutput> {
if (params.providerConfig.source === "env") {
return await resolveEnvRefs({
refs: params.refs,
providerName: params.providerName,
providerConfig: params.providerConfig,
env: params.options.env ?? process.env,
});
}
if (params.providerConfig.source === "file") {
return await resolveFileRefs({
refs: params.refs,
providerName: params.providerName,
providerConfig: params.providerConfig,
cache: params.options.cache,
});
}
if (params.providerConfig.source === "exec") {
return await resolveExecRefs({
refs: params.refs,
providerName: params.providerName,
providerConfig: params.providerConfig,
env: params.options.env ?? process.env,
limits: params.limits,
});
}
throw new Error(
`Unsupported secret provider source "${String((params.providerConfig as { source?: unknown }).source)}".`,
);
}
export async function resolveSecretRefValues(
refs: SecretRef[],
options: ResolveSecretRefOptions,
): Promise<Map<string, unknown>> {
if (refs.length === 0) {
return new Map();
}
const limits = resolveResolutionLimits(options.config);
const uniqueRefs = new Map<string, SecretRef>();
for (const ref of refs) {
const id = ref.id.trim();
if (!id) {
throw new Error("Secret reference id is empty.");
}
uniqueRefs.set(secretRefKey(ref), { ...ref, id });
}
const grouped = new Map<
string,
{ source: SecretRefSource; providerName: string; refs: SecretRef[] }
>();
for (const ref of uniqueRefs.values()) {
const key = toProviderKey(ref.source, ref.provider);
const existing = grouped.get(key);
if (existing) {
existing.refs.push(ref);
continue;
}
grouped.set(key, { source: ref.source, providerName: ref.provider, refs: [ref] });
}
const tasks = [...grouped.values()].map(
(group) => async (): Promise<{ group: typeof group; values: ProviderResolutionOutput }> => {
if (group.refs.length > limits.maxRefsPerProvider) {
throw new Error(
`Secret provider "${group.providerName}" exceeded maxRefsPerProvider (${limits.maxRefsPerProvider}).`,
);
}
const providerConfig = resolveConfiguredProvider(group.refs[0], options.config);
const values = await resolveProviderRefs({
refs: group.refs,
source: group.source,
providerName: group.providerName,
providerConfig,
options,
limits,
});
return { group, values };
},
);
const taskResults = await runTasksWithConcurrency({
tasks,
limit: limits.maxProviderConcurrency,
errorMode: "stop",
});
if (taskResults.hasError) {
throw taskResults.firstError;
}
const resolved = new Map<string, unknown>();
for (const result of taskResults.results) {
for (const ref of result.group.refs) {
if (!result.values.has(ref.id)) {
throw new Error(
`Secret provider "${result.group.providerName}" did not return id "${ref.id}".`,
);
}
resolved.set(secretRefKey(ref), result.values.get(ref.id));
}
}
return resolved;
}
export async function resolveSecretRefValue(
ref: SecretRef,
options: ResolveSecretRefOptions,
): Promise<unknown> {
const cache = options.cache;
const key = secretRefKey(ref);
if (cache?.resolvedByRefKey?.has(key)) {
return await (cache.resolvedByRefKey.get(key) as Promise<unknown>);
}
const promise = (async () => {
const resolved = await resolveSecretRefValues([ref], options);
if (!resolved.has(key)) {
throw new Error(`Secret reference "${key}" resolved to no value.`);
}
return resolved.get(key);
})();
if (cache) {
cache.resolvedByRefKey ??= new Map();
cache.resolvedByRefKey.set(key, promise);
}
return await promise;
}
export async function resolveSecretRefString(
ref: SecretRef,
options: ResolveSecretRefOptions,
): Promise<string> {
const resolved = await resolveSecretRefValue(ref, options);
if (!isNonEmptyString(resolved)) {
throw new Error(
`Secret reference "${ref.source}:${ref.provider}:${ref.id}" resolved to a non-string or empty value.`,
);
}
return resolved;
}

View File

@@ -0,0 +1,270 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import { loadConfig, type OpenClawConfig } from "../config/config.js";
import {
activateSecretsRuntimeSnapshot,
clearSecretsRuntimeSnapshot,
prepareSecretsRuntimeSnapshot,
} from "./runtime.js";
describe("secrets runtime snapshot", () => {
afterEach(() => {
clearSecretsRuntimeSnapshot();
});
it("resolves env refs for config and auth profiles", async () => {
const config: OpenClawConfig = {
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
models: [],
},
},
},
skills: {
entries: {
"review-pr": {
enabled: true,
apiKey: { source: "env", provider: "default", id: "REVIEW_SKILL_API_KEY" },
},
},
},
};
const snapshot = await prepareSecretsRuntimeSnapshot({
config,
env: {
OPENAI_API_KEY: "sk-env-openai",
GITHUB_TOKEN: "ghp-env-token",
REVIEW_SKILL_API_KEY: "sk-skill-ref",
},
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "old-openai",
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
"github-copilot:default": {
type: "token",
provider: "github-copilot",
token: "old-gh",
tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" },
},
"openai:inline": {
type: "api_key",
provider: "openai",
key: "${OPENAI_API_KEY}",
},
},
}),
});
expect(snapshot.config.models?.providers?.openai?.apiKey).toBe("sk-env-openai");
expect(snapshot.config.skills?.entries?.["review-pr"]?.apiKey).toBe("sk-skill-ref");
expect(snapshot.warnings).toHaveLength(2);
expect(snapshot.authStores[0]?.store.profiles["openai:default"]).toMatchObject({
type: "api_key",
key: "sk-env-openai",
});
expect(snapshot.authStores[0]?.store.profiles["github-copilot:default"]).toMatchObject({
type: "token",
token: "ghp-env-token",
});
expect(snapshot.authStores[0]?.store.profiles["openai:inline"]).toMatchObject({
type: "api_key",
key: "sk-env-openai",
});
});
it("resolves file refs via configured file provider", async () => {
if (process.platform === "win32") {
return;
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-file-provider-"));
const secretsPath = path.join(root, "secrets.json");
try {
await fs.writeFile(
secretsPath,
JSON.stringify(
{
providers: {
openai: {
apiKey: "sk-from-file-provider",
},
},
},
null,
2,
),
"utf8",
);
await fs.chmod(secretsPath, 0o600);
const config: OpenClawConfig = {
secrets: {
providers: {
default: {
source: "file",
path: secretsPath,
mode: "json",
},
},
defaults: {
file: "default",
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "file", provider: "default", id: "/providers/openai/apiKey" },
models: [],
},
},
},
};
const snapshot = await prepareSecretsRuntimeSnapshot({
config,
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
});
expect(snapshot.config.models?.providers?.openai?.apiKey).toBe("sk-from-file-provider");
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("fails when file provider payload is not a JSON object", async () => {
if (process.platform === "win32") {
return;
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-file-provider-bad-"));
const secretsPath = path.join(root, "secrets.json");
try {
await fs.writeFile(secretsPath, JSON.stringify(["not-an-object"]), "utf8");
await fs.chmod(secretsPath, 0o600);
await expect(
prepareSecretsRuntimeSnapshot({
config: {
secrets: {
providers: {
default: {
source: "file",
path: secretsPath,
mode: "json",
},
},
},
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "file", provider: "default", id: "/providers/openai/apiKey" },
models: [],
},
},
},
},
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
}),
).rejects.toThrow("payload is not a JSON object");
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("activates runtime snapshots for loadConfig and ensureAuthProfileStore", async () => {
const prepared = await prepareSecretsRuntimeSnapshot({
config: {
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
models: [],
},
},
},
},
env: { OPENAI_API_KEY: "sk-runtime" },
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
},
}),
});
activateSecretsRuntimeSnapshot(prepared);
expect(loadConfig().models?.providers?.openai?.apiKey).toBe("sk-runtime");
const store = ensureAuthProfileStore("/tmp/openclaw-agent-main");
expect(store.profiles["openai:default"]).toMatchObject({
type: "api_key",
key: "sk-runtime",
});
});
it("does not write inherited auth stores during runtime secret activation", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-runtime-"));
const stateDir = path.join(root, ".openclaw");
const mainAgentDir = path.join(stateDir, "agents", "main", "agent");
const workerStorePath = path.join(stateDir, "agents", "worker", "agent", "auth-profiles.json");
const prevStateDir = process.env.OPENCLAW_STATE_DIR;
try {
await fs.mkdir(mainAgentDir, { recursive: true });
await fs.writeFile(
path.join(mainAgentDir, "auth-profiles.json"),
JSON.stringify({
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
},
}),
"utf8",
);
process.env.OPENCLAW_STATE_DIR = stateDir;
await prepareSecretsRuntimeSnapshot({
config: {
agents: {
list: [{ id: "worker" }],
},
},
env: { OPENAI_API_KEY: "sk-runtime-worker" },
});
await expect(fs.access(workerStorePath)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
if (prevStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = prevStateDir;
}
await fs.rm(root, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,426 @@
import { resolveOpenClawAgentDir } from "../agents/agent-paths.js";
import { listAgentIds, resolveAgentDir } from "../agents/agent-scope.js";
import type { AuthProfileCredential, AuthProfileStore } from "../agents/auth-profiles.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
loadAuthProfileStoreForSecretsRuntime,
replaceRuntimeAuthProfileStoreSnapshots,
} from "../agents/auth-profiles.js";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
type OpenClawConfig,
} from "../config/config.js";
import { coerceSecretRef, type SecretRef } from "../config/types.secrets.js";
import { resolveUserPath } from "../utils.js";
import { secretRefKey } from "./ref-contract.js";
import { resolveSecretRefValues, type SecretRefResolveCache } from "./resolve.js";
import { isNonEmptyString, isRecord } from "./shared.js";
type SecretResolverWarningCode = "SECRETS_REF_OVERRIDES_PLAINTEXT";
export type SecretResolverWarning = {
code: SecretResolverWarningCode;
path: string;
message: string;
};
export type PreparedSecretsRuntimeSnapshot = {
sourceConfig: OpenClawConfig;
config: OpenClawConfig;
authStores: Array<{ agentDir: string; store: AuthProfileStore }>;
warnings: SecretResolverWarning[];
};
type ProviderLike = {
apiKey?: unknown;
};
type SkillEntryLike = {
apiKey?: unknown;
};
type GoogleChatAccountLike = {
serviceAccount?: unknown;
serviceAccountRef?: unknown;
accounts?: Record<string, unknown>;
};
type ApiKeyCredentialLike = AuthProfileCredential & {
type: "api_key";
key?: string;
keyRef?: unknown;
};
type TokenCredentialLike = AuthProfileCredential & {
type: "token";
token?: string;
tokenRef?: unknown;
};
type SecretAssignment = {
ref: SecretRef;
path: string;
expected: "string" | "string-or-object";
apply: (value: unknown) => void;
};
type ResolverContext = {
sourceConfig: OpenClawConfig;
env: NodeJS.ProcessEnv;
cache: SecretRefResolveCache;
warnings: SecretResolverWarning[];
assignments: SecretAssignment[];
};
type SecretDefaults = NonNullable<OpenClawConfig["secrets"]>["defaults"];
let activeSnapshot: PreparedSecretsRuntimeSnapshot | null = null;
function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecretsRuntimeSnapshot {
return {
sourceConfig: structuredClone(snapshot.sourceConfig),
config: structuredClone(snapshot.config),
authStores: snapshot.authStores.map((entry) => ({
agentDir: entry.agentDir,
store: structuredClone(entry.store),
})),
warnings: snapshot.warnings.map((warning) => ({ ...warning })),
};
}
function pushAssignment(context: ResolverContext, assignment: SecretAssignment): void {
context.assignments.push(assignment);
}
function collectModelProviderAssignments(params: {
providers: Record<string, ProviderLike>;
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
for (const [providerId, provider] of Object.entries(params.providers)) {
const ref = coerceSecretRef(provider.apiKey, params.defaults);
if (!ref) {
continue;
}
pushAssignment(params.context, {
ref,
path: `models.providers.${providerId}.apiKey`,
expected: "string",
apply: (value) => {
provider.apiKey = value;
},
});
}
}
function collectSkillAssignments(params: {
entries: Record<string, SkillEntryLike>;
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
for (const [skillKey, entry] of Object.entries(params.entries)) {
const ref = coerceSecretRef(entry.apiKey, params.defaults);
if (!ref) {
continue;
}
pushAssignment(params.context, {
ref,
path: `skills.entries.${skillKey}.apiKey`,
expected: "string",
apply: (value) => {
entry.apiKey = value;
},
});
}
}
function collectGoogleChatAccountAssignment(params: {
target: GoogleChatAccountLike;
path: string;
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const explicitRef = coerceSecretRef(params.target.serviceAccountRef, params.defaults);
const inlineRef = coerceSecretRef(params.target.serviceAccount, params.defaults);
const ref = explicitRef ?? inlineRef;
if (!ref) {
return;
}
if (
explicitRef &&
params.target.serviceAccount !== undefined &&
!coerceSecretRef(params.target.serviceAccount, params.defaults)
) {
params.context.warnings.push({
code: "SECRETS_REF_OVERRIDES_PLAINTEXT",
path: params.path,
message: `${params.path}: serviceAccountRef is set; runtime will ignore plaintext serviceAccount.`,
});
}
pushAssignment(params.context, {
ref,
path: `${params.path}.serviceAccount`,
expected: "string-or-object",
apply: (value) => {
params.target.serviceAccount = value;
},
});
}
function collectGoogleChatAssignments(params: {
googleChat: GoogleChatAccountLike;
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
collectGoogleChatAccountAssignment({
target: params.googleChat,
path: "channels.googlechat",
defaults: params.defaults,
context: params.context,
});
if (!isRecord(params.googleChat.accounts)) {
return;
}
for (const [accountId, account] of Object.entries(params.googleChat.accounts)) {
if (!isRecord(account)) {
continue;
}
collectGoogleChatAccountAssignment({
target: account as GoogleChatAccountLike,
path: `channels.googlechat.accounts.${accountId}`,
defaults: params.defaults,
context: params.context,
});
}
}
function collectConfigAssignments(params: {
config: OpenClawConfig;
context: ResolverContext;
}): void {
const defaults = params.context.sourceConfig.secrets?.defaults;
const providers = params.config.models?.providers as Record<string, ProviderLike> | undefined;
if (providers) {
collectModelProviderAssignments({
providers,
defaults,
context: params.context,
});
}
const skillEntries = params.config.skills?.entries as Record<string, SkillEntryLike> | undefined;
if (skillEntries) {
collectSkillAssignments({
entries: skillEntries,
defaults,
context: params.context,
});
}
const googleChat = params.config.channels?.googlechat as GoogleChatAccountLike | undefined;
if (googleChat) {
collectGoogleChatAssignments({
googleChat,
defaults,
context: params.context,
});
}
}
function collectApiKeyProfileAssignment(params: {
profile: ApiKeyCredentialLike;
profileId: string;
agentDir: string;
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const keyRef = coerceSecretRef(params.profile.keyRef, params.defaults);
const inlineKeyRef = keyRef ? null : coerceSecretRef(params.profile.key, params.defaults);
const resolvedKeyRef = keyRef ?? inlineKeyRef;
if (!resolvedKeyRef) {
return;
}
if (keyRef && isNonEmptyString(params.profile.key)) {
params.context.warnings.push({
code: "SECRETS_REF_OVERRIDES_PLAINTEXT",
path: `${params.agentDir}.auth-profiles.${params.profileId}.key`,
message: `auth-profiles ${params.profileId}: keyRef is set; runtime will ignore plaintext key.`,
});
}
pushAssignment(params.context, {
ref: resolvedKeyRef,
path: `${params.agentDir}.auth-profiles.${params.profileId}.key`,
expected: "string",
apply: (value) => {
params.profile.key = String(value);
},
});
}
function collectTokenProfileAssignment(params: {
profile: TokenCredentialLike;
profileId: string;
agentDir: string;
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const tokenRef = coerceSecretRef(params.profile.tokenRef, params.defaults);
const inlineTokenRef = tokenRef ? null : coerceSecretRef(params.profile.token, params.defaults);
const resolvedTokenRef = tokenRef ?? inlineTokenRef;
if (!resolvedTokenRef) {
return;
}
if (tokenRef && isNonEmptyString(params.profile.token)) {
params.context.warnings.push({
code: "SECRETS_REF_OVERRIDES_PLAINTEXT",
path: `${params.agentDir}.auth-profiles.${params.profileId}.token`,
message: `auth-profiles ${params.profileId}: tokenRef is set; runtime will ignore plaintext token.`,
});
}
pushAssignment(params.context, {
ref: resolvedTokenRef,
path: `${params.agentDir}.auth-profiles.${params.profileId}.token`,
expected: "string",
apply: (value) => {
params.profile.token = String(value);
},
});
}
function collectAuthStoreAssignments(params: {
store: AuthProfileStore;
context: ResolverContext;
agentDir: string;
}): void {
const defaults = params.context.sourceConfig.secrets?.defaults;
for (const [profileId, profile] of Object.entries(params.store.profiles)) {
if (profile.type === "api_key") {
collectApiKeyProfileAssignment({
profile: profile as ApiKeyCredentialLike,
profileId,
agentDir: params.agentDir,
defaults,
context: params.context,
});
continue;
}
if (profile.type === "token") {
collectTokenProfileAssignment({
profile: profile as TokenCredentialLike,
profileId,
agentDir: params.agentDir,
defaults,
context: params.context,
});
}
}
}
function applyAssignments(params: {
assignments: SecretAssignment[];
resolved: Map<string, unknown>;
}): void {
for (const assignment of params.assignments) {
const key = secretRefKey(assignment.ref);
if (!params.resolved.has(key)) {
throw new Error(`Secret reference "${key}" resolved to no value.`);
}
const value = params.resolved.get(key);
if (assignment.expected === "string") {
if (!isNonEmptyString(value)) {
throw new Error(`${assignment.path} resolved to a non-string or empty value.`);
}
assignment.apply(value);
continue;
}
if (!(isNonEmptyString(value) || isRecord(value))) {
throw new Error(`${assignment.path} resolved to an unsupported value type.`);
}
assignment.apply(value);
}
}
function collectCandidateAgentDirs(config: OpenClawConfig): string[] {
const dirs = new Set<string>();
dirs.add(resolveUserPath(resolveOpenClawAgentDir()));
for (const agentId of listAgentIds(config)) {
dirs.add(resolveUserPath(resolveAgentDir(config, agentId)));
}
return [...dirs];
}
export async function prepareSecretsRuntimeSnapshot(params: {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
agentDirs?: string[];
loadAuthStore?: (agentDir?: string) => AuthProfileStore;
}): Promise<PreparedSecretsRuntimeSnapshot> {
const sourceConfig = structuredClone(params.config);
const resolvedConfig = structuredClone(params.config);
const context: ResolverContext = {
sourceConfig,
env: params.env ?? process.env,
cache: {},
warnings: [],
assignments: [],
};
collectConfigAssignments({
config: resolvedConfig,
context,
});
const loadAuthStore = params.loadAuthStore ?? loadAuthProfileStoreForSecretsRuntime;
const candidateDirs = params.agentDirs?.length
? [...new Set(params.agentDirs.map((entry) => resolveUserPath(entry)))]
: collectCandidateAgentDirs(resolvedConfig);
const authStores: Array<{ agentDir: string; store: AuthProfileStore }> = [];
for (const agentDir of candidateDirs) {
const store = structuredClone(loadAuthStore(agentDir));
collectAuthStoreAssignments({
store,
context,
agentDir,
});
authStores.push({ agentDir, store });
}
if (context.assignments.length > 0) {
const refs = context.assignments.map((assignment) => assignment.ref);
const resolved = await resolveSecretRefValues(refs, {
config: sourceConfig,
env: context.env,
cache: context.cache,
});
applyAssignments({
assignments: context.assignments,
resolved,
});
}
return {
sourceConfig,
config: resolvedConfig,
authStores,
warnings: context.warnings,
};
}
export function activateSecretsRuntimeSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): void {
const next = cloneSnapshot(snapshot);
setRuntimeConfigSnapshot(next.config, next.sourceConfig);
replaceRuntimeAuthProfileStoreSnapshots(next.authStores);
activeSnapshot = next;
}
export function getActiveSecretsRuntimeSnapshot(): PreparedSecretsRuntimeSnapshot | null {
return activeSnapshot ? cloneSnapshot(activeSnapshot) : null;
}
export function clearSecretsRuntimeSnapshot(): void {
activeSnapshot = null;
clearRuntimeConfigSnapshot();
clearRuntimeAuthProfileStoreSnapshots();
}

View File

@@ -0,0 +1,42 @@
import fs from "node:fs";
import path from "node:path";
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
export function normalizePositiveInt(value: unknown, fallback: number): number {
if (typeof value === "number" && Number.isFinite(value)) {
return Math.max(1, Math.floor(value));
}
return Math.max(1, Math.floor(fallback));
}
export function ensureDirForFile(filePath: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
}
export function writeJsonFileSecure(pathname: string, value: unknown): void {
ensureDirForFile(pathname);
fs.writeFileSync(pathname, `${JSON.stringify(value, null, 2)}\n`, "utf8");
fs.chmodSync(pathname, 0o600);
}
export function readTextFileIfExists(pathname: string): string | null {
if (!fs.existsSync(pathname)) {
return null;
}
return fs.readFileSync(pathname, "utf8");
}
export function writeTextFileAtomic(pathname: string, value: string, mode = 0o600): void {
ensureDirForFile(pathname);
const tempPath = `${pathname}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(tempPath, value, "utf8");
fs.chmodSync(tempPath, mode);
fs.renameSync(tempPath, pathname);
}