Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
447
openclaw/src/secrets/apply.test.ts
Normal file
447
openclaw/src/secrets/apply.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
593
openclaw/src/secrets/apply.ts
Normal file
593
openclaw/src/secrets/apply.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
186
openclaw/src/secrets/audit.test.ts
Normal file
186
openclaw/src/secrets/audit.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
755
openclaw/src/secrets/audit.ts
Normal file
755
openclaw/src/secrets/audit.ts
Normal 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;
|
||||
}
|
||||
14
openclaw/src/secrets/config-io.ts
Normal file
14
openclaw/src/secrets/config-io.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
876
openclaw/src/secrets/configure.ts
Normal file
876
openclaw/src/secrets/configure.ts
Normal 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 };
|
||||
}
|
||||
94
openclaw/src/secrets/json-pointer.ts
Normal file
94
openclaw/src/secrets/json-pointer.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
238
openclaw/src/secrets/plan.ts
Normal file
238
openclaw/src/secrets/plan.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
30
openclaw/src/secrets/provider-env-vars.ts
Normal file
30
openclaw/src/secrets/provider-env-vars.ts
Normal 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))];
|
||||
}
|
||||
66
openclaw/src/secrets/ref-contract.ts
Normal file
66
openclaw/src/secrets/ref-contract.ts
Normal 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));
|
||||
}
|
||||
536
openclaw/src/secrets/resolve.test.ts
Normal file
536
openclaw/src/secrets/resolve.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
714
openclaw/src/secrets/resolve.ts
Normal file
714
openclaw/src/secrets/resolve.ts
Normal 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;
|
||||
}
|
||||
270
openclaw/src/secrets/runtime.test.ts
Normal file
270
openclaw/src/secrets/runtime.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
426
openclaw/src/secrets/runtime.ts
Normal file
426
openclaw/src/secrets/runtime.ts
Normal 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();
|
||||
}
|
||||
42
openclaw/src/secrets/shared.ts
Normal file
42
openclaw/src/secrets/shared.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user