feat(secrets): expand SecretRef coverage across user-supplied credentials (#29580)

* feat(secrets): expand secret target coverage and gateway tooling

* docs(secrets): align gateway and CLI secret docs

* chore(protocol): regenerate swift gateway models for secrets methods

* fix(config): restore talk apiKey fallback and stabilize runner test

* ci(windows): reduce test worker count for shard stability

* ci(windows): raise node heap for test shard stability

* test(feishu): make proxy env precedence assertion windows-safe

* fix(gateway): resolve auth password SecretInput refs for clients

* fix(gateway): resolve remote SecretInput credentials for clients

* fix(secrets): skip inactive refs in command snapshot assignments

* fix(secrets): scope gateway.remote refs to effective auth surfaces

* fix(secrets): ignore memory defaults when enabled agents disable search

* fix(secrets): honor Google Chat serviceAccountRef inheritance

* fix(secrets): address tsgo errors in command and gateway collectors

* fix(secrets): avoid auth-store load in providers-only configure

* fix(gateway): defer local password ref resolution by precedence

* fix(secrets): gate telegram webhook secret refs by webhook mode

* fix(secrets): gate slack signing secret refs to http mode

* fix(secrets): skip telegram botToken refs when tokenFile is set

* fix(secrets): gate discord pluralkit refs by enabled flag

* fix(secrets): gate discord voice tts refs by voice enabled

* test(secrets): make runtime fixture modes explicit

* fix(cli): resolve local qr password secret refs

* fix(cli): fail when gateway leaves command refs unresolved

* fix(gateway): fail when local password SecretRef is unresolved

* fix(gateway): fail when required remote SecretRefs are unresolved

* fix(gateway): resolve local password refs only when password can win

* fix(cli): skip local password SecretRef resolution on qr token override

* test(gateway): cast SecretRef fixtures to OpenClawConfig

* test(secrets): activate mode-gated targets in runtime coverage fixture

* fix(cron): support SecretInput webhook tokens safely

* fix(bluebubbles): support SecretInput passwords across config paths

* fix(msteams): make appPassword SecretInput-safe in onboarding/token paths

* fix(bluebubbles): align SecretInput schema helper typing

* fix(cli): clarify secrets.resolve version-skew errors

* refactor(secrets): return structured inactive paths from secrets.resolve

* refactor(gateway): type onboarding secret writes as SecretInput

* chore(protocol): regenerate swift models for secrets.resolve

* feat(secrets): expand extension credential secretref support

* fix(secrets): gate web-search refs by active provider

* fix(onboarding): detect SecretRef credentials in extension status

* fix(onboarding): allow keeping existing ref in secret prompt

* fix(onboarding): resolve gateway password SecretRefs for probe and tui

* fix(onboarding): honor secret-input-mode for local gateway auth

* fix(acp): resolve gateway SecretInput credentials

* fix(secrets): gate gateway.remote refs to remote surfaces

* test(secrets): cover pattern matching and inactive array refs

* docs(secrets): clarify secrets.resolve and remote active surfaces

* fix(bluebubbles): keep existing SecretRef during onboarding

* fix(tests): resolve CI type errors in new SecretRef coverage

* fix(extensions): replace raw fetch with SSRF-guarded fetch

* test(secrets): mark gateway remote targets active in runtime coverage

* test(infra): normalize home-prefix expectation across platforms

* fix(cli): only resolve local qr password refs in password mode

* test(cli): cover local qr token mode with unresolved password ref

* docs(cli): clarify local qr password ref resolution behavior

* refactor(extensions): reuse sdk SecretInput helpers

* fix(wizard): resolve onboarding env-template secrets before plaintext

* fix(cli): surface secrets.resolve diagnostics in memory and qr

* test(secrets): repair post-rebase runtime and fixtures

* fix(gateway): skip remote password ref resolution when token wins

* fix(secrets): treat tailscale remote gateway refs as active

* fix(gateway): allow remote password fallback when token ref is unresolved

* fix(gateway): ignore stale local password refs for none and trusted-proxy

* fix(gateway): skip remote secret ref resolution on local call paths

* test(cli): cover qr remote tailscale secret ref resolution

* fix(secrets): align gateway password active-surface with auth inference

* fix(cli): resolve inferred local gateway password refs in qr

* fix(gateway): prefer resolvable remote password over token ref pre-resolution

* test(gateway): cover none and trusted-proxy stale password refs

* docs(secrets): sync qr and gateway active-surface behavior

* fix: restore stability blockers from pre-release audit

* Secrets: fix collector/runtime precedence contradictions

* docs: align secrets and web credential docs

* fix(rebase): resolve integration regressions after main rebase

* fix(node-host): resolve gateway secret refs for auth

* fix(secrets): harden secretinput runtime readers

* gateway: skip inactive auth secretref resolution

* cli: avoid gateway preflight for inactive secret refs

* extensions: allow unresolved refs in onboarding status

* tests: fix qr-cli module mock hoist ordering

* Security: align audit checks with SecretInput resolution

* Gateway: resolve local-mode remote fallback secret refs

* Node host: avoid resolving inactive password secret refs

* Secrets runtime: mark Slack appToken inactive for HTTP mode

* secrets: keep inactive gateway remote refs non-blocking

* cli: include agent memory secret targets in runtime resolution

* docs(secrets): sync docs with active-surface and web search behavior

* fix(secrets): keep telegram top-level token refs active for blank account tokens

* fix(daemon): resolve gateway password secret refs for probe auth

* fix(secrets): skip IRC NickServ ref resolution when NickServ is disabled

* fix(secrets): align token inheritance and exec timeout defaults

* docs(secrets): clarify active-surface notes in cli docs

* cli: require secrets.resolve gateway capability

* gateway: log auth secret surface diagnostics

* secrets: remove dead provider resolver module

* fix(secrets): restore gateway auth precedence and fallback resolution

* fix(tests): align plugin runtime mock typings

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Josh Avant
2026-03-02 20:58:20 -06:00
committed by GitHub
parent f212351aed
commit 806803b7ef
236 changed files with 16810 additions and 2861 deletions

View File

@@ -117,16 +117,6 @@ async function applyPlanAndReadConfig<T>(
return JSON.parse(await fs.readFile(fixture.configPath, "utf8")) as T;
}
async function expectInvalidTargetPath(
fixture: ApplyFixture,
target: SecretsApplyPlan["targets"][number],
): Promise<void> {
const plan = createPlan({ targets: [target] });
await expect(runSecretsApply({ plan, env: fixture.env, write: false })).rejects.toThrow(
"Invalid plan target path",
);
}
function createPlan(params: {
targets: SecretsApplyPlan["targets"];
options?: SecretsApplyPlan["options"];
@@ -215,6 +205,87 @@ describe("secrets apply", () => {
expect(nextEnv).toContain("UNRELATED=value");
});
it("applies auth-profiles sibling ref targets to the scoped agent store", async () => {
const plan: SecretsApplyPlan = {
version: 1,
protocolVersion: 1,
generatedAt: new Date().toISOString(),
generatedBy: "manual",
targets: [
{
type: "auth-profiles.api_key.key",
path: "profiles.openai:default.key",
pathSegments: ["profiles", "openai:default", "key"],
agentId: "main",
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
],
options: {
scrubEnv: false,
scrubAuthProfilesForProviderTargets: false,
scrubLegacyAuthJson: false,
},
};
const result = await runSecretsApply({ plan, env: fixture.env, write: true });
expect(result.changed).toBe(true);
expect(result.changedFiles).toContain(fixture.authStorePath);
const nextAuthStore = JSON.parse(await fs.readFile(fixture.authStorePath, "utf8")) as {
profiles: { "openai:default": { key?: string; keyRef?: unknown } };
};
expect(nextAuthStore.profiles["openai:default"].key).toBeUndefined();
expect(nextAuthStore.profiles["openai:default"].keyRef).toEqual({
source: "env",
provider: "default",
id: "OPENAI_API_KEY",
});
});
it("creates a new auth-profiles mapping when provider metadata is supplied", async () => {
const plan: SecretsApplyPlan = {
version: 1,
protocolVersion: 1,
generatedAt: new Date().toISOString(),
generatedBy: "manual",
targets: [
{
type: "auth-profiles.token.token",
path: "profiles.openai:bot.token",
pathSegments: ["profiles", "openai:bot", "token"],
agentId: "main",
authProfileProvider: "openai",
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
],
options: {
scrubEnv: false,
scrubAuthProfilesForProviderTargets: false,
scrubLegacyAuthJson: false,
},
};
await runSecretsApply({ plan, env: fixture.env, write: true });
const nextAuthStore = JSON.parse(await fs.readFile(fixture.authStorePath, "utf8")) as {
profiles: {
"openai:bot": {
type: string;
provider: string;
tokenRef?: unknown;
};
};
};
expect(nextAuthStore.profiles["openai:bot"]).toEqual({
type: "token",
provider: "openai",
tokenRef: {
source: "env",
provider: "default",
id: "OPENAI_API_KEY",
},
});
});
it("is idempotent on repeated write applies", async () => {
const plan = createPlan({
targets: [createOpenAiProviderTarget()],
@@ -317,19 +388,161 @@ describe("secrets apply", () => {
expect(rawConfig).not.toContain("sk-skill-plaintext");
});
it.each([
createOpenAiProviderTarget({
path: "models.providers.openai.baseUrl",
pathSegments: ["models", "providers", "openai", "baseUrl"],
}),
{
type: "skills.entries.apiKey",
path: "skills.entries.__proto__.apiKey",
pathSegments: ["skills", "entries", "__proto__", "apiKey"],
ref: OPENAI_API_KEY_ENV_REF,
} satisfies SecretsApplyPlan["targets"][number],
])("rejects invalid target path: %s", async (target) => {
await expectInvalidTargetPath(fixture, target);
it("applies non-legacy target types", async () => {
await fs.writeFile(
fixture.configPath,
`${JSON.stringify(
{
talk: {
apiKey: "sk-talk-plaintext",
},
},
null,
2,
)}\n`,
"utf8",
);
const plan: SecretsApplyPlan = {
version: 1,
protocolVersion: 1,
generatedAt: new Date().toISOString(),
generatedBy: "manual",
targets: [
{
type: "talk.apiKey",
path: "talk.apiKey",
pathSegments: ["talk", "apiKey"],
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
],
options: {
scrubEnv: false,
scrubAuthProfilesForProviderTargets: false,
scrubLegacyAuthJson: false,
},
};
const result = await runSecretsApply({ plan, env: fixture.env, write: true });
expect(result.changed).toBe(true);
const nextConfig = JSON.parse(await fs.readFile(fixture.configPath, "utf8")) as {
talk?: { apiKey?: unknown };
};
expect(nextConfig.talk?.apiKey).toEqual({
source: "env",
provider: "default",
id: "OPENAI_API_KEY",
});
});
it("applies array-indexed targets for agent memory search", async () => {
await fs.writeFile(
fixture.configPath,
`${JSON.stringify(
{
agents: {
list: [
{
id: "main",
memorySearch: {
remote: {
apiKey: "sk-memory-plaintext",
},
},
},
],
},
},
null,
2,
)}\n`,
"utf8",
);
const plan: SecretsApplyPlan = {
version: 1,
protocolVersion: 1,
generatedAt: new Date().toISOString(),
generatedBy: "manual",
targets: [
{
type: "agents.list[].memorySearch.remote.apiKey",
path: "agents.list.0.memorySearch.remote.apiKey",
pathSegments: ["agents", "list", "0", "memorySearch", "remote", "apiKey"],
ref: { source: "env", provider: "default", id: "MEMORY_REMOTE_API_KEY" },
},
],
options: {
scrubEnv: false,
scrubAuthProfilesForProviderTargets: false,
scrubLegacyAuthJson: false,
},
};
fixture.env.MEMORY_REMOTE_API_KEY = "sk-memory-live-env";
const result = await runSecretsApply({ plan, env: fixture.env, write: true });
expect(result.changed).toBe(true);
const nextConfig = JSON.parse(await fs.readFile(fixture.configPath, "utf8")) as {
agents?: {
list?: Array<{
memorySearch?: {
remote?: {
apiKey?: unknown;
};
};
}>;
};
};
expect(nextConfig.agents?.list?.[0]?.memorySearch?.remote?.apiKey).toEqual({
source: "env",
provider: "default",
id: "MEMORY_REMOTE_API_KEY",
});
});
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: fixture.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: fixture.env, write: false })).rejects.toThrow(
"Invalid plan target path",
);
});
it("applies provider upserts and deletes from plan", async () => {

View File

@@ -2,25 +2,36 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { resolveAgentConfig } from "../agents/agent-scope.js";
import { loadAuthProfileStoreForSecretsRuntime } from "../agents/auth-profiles.js";
import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.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 { normalizeAgentId } from "../routing/session-key.js";
import { resolveConfigDir, resolveUserPath } from "../utils.js";
import { collectAuthStorePaths } from "./auth-store-paths.js";
import { iterateAuthProfileCredentials } from "./auth-profiles-scan.js";
import { createSecretsConfigIO } from "./config-io.js";
import { deletePathStrict, getPath, setPathCreateStrict } from "./path-utils.js";
import {
type SecretsApplyPlan,
type SecretsPlanTarget,
normalizeSecretsPlanOptions,
resolveValidatedTargetPathSegments,
resolveValidatedPlanTarget,
} from "./plan.js";
import { listKnownSecretEnvVarNames } from "./provider-env-vars.js";
import { resolveSecretRefValue } from "./resolve.js";
import { prepareSecretsRuntimeSnapshot } from "./runtime.js";
import { isNonEmptyString, isRecord, parseEnvValue, writeTextFileAtomic } from "./shared.js";
import { assertExpectedResolvedSecretValue } from "./secret-value.js";
import { isNonEmptyString, isRecord, writeTextFileAtomic } from "./shared.js";
import {
listAuthProfileStorePaths,
listLegacyAuthJsonPaths,
parseEnvAssignmentValue,
readJsonObjectIfExists,
} from "./storage-scan.js";
type FileSnapshot = {
existed: boolean;
@@ -45,6 +56,23 @@ type ProjectedState = {
warnings: string[];
};
type ResolvedPlanTargetEntry = {
target: SecretsPlanTarget;
resolved: NonNullable<ReturnType<typeof resolveValidatedPlanTarget>>;
};
type ConfigTargetMutationResult = {
resolvedTargets: ResolvedPlanTargetEntry[];
scrubbedValues: Set<string>;
providerTargets: Set<string>;
configChanged: boolean;
authStoreByPath: Map<string, Record<string, unknown>>;
};
type MutableAuthProfileStore = Record<string, unknown> & {
profiles: Record<string, unknown>;
};
export type SecretsApplyResult = {
mode: "dry-run" | "write";
changed: boolean;
@@ -53,65 +81,10 @@ export type SecretsApplyResult = {
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);
function resolveTarget(
target: SecretsPlanTarget,
): NonNullable<ReturnType<typeof resolveValidatedPlanTarget>> {
const resolved = resolveValidatedPlanTarget(target);
if (!resolved) {
throw new Error(`Invalid plan target path for ${target.type}: ${target.path}`);
}
@@ -143,7 +116,7 @@ function scrubEnvRaw(
nextLines.push(line);
continue;
}
const parsedValue = parseEnvValue(match[2] ?? "");
const parsedValue = parseEnvAssignmentValue(match[2] ?? "");
if (migratedValues.has(parsedValue)) {
removed += 1;
continue;
@@ -161,33 +134,6 @@ function scrubEnvRaw(
};
}
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;
@@ -239,13 +185,12 @@ async function projectPlanState(params: {
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({
@@ -257,177 +202,46 @@ async function projectPlanState(params: {
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 targetMutations = applyConfigTargetMutations({
planTargets: params.plan.targets,
nextConfig,
stateDir,
authStoreByPath: new Map<string, Record<string, unknown>>(),
changedFiles,
});
if (targetMutations.configChanged) {
changedFiles.add(configPath);
}
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 authStoreByPath = scrubAuthStoresForProviderTargets({
nextConfig,
stateDir,
providerTargets: targetMutations.providerTargets,
scrubbedValues: targetMutations.scrubbedValues,
authStoreByPath: targetMutations.authStoreByPath,
changedFiles,
warnings,
enabled: options.scrubAuthProfilesForProviderTargets,
});
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 authJsonByPath = scrubLegacyAuthJsonStores({
stateDir,
changedFiles,
enabled: options.scrubLegacyAuthJson,
});
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,
const envRawByPath = scrubEnvFiles({
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);
},
scrubbedValues: targetMutations.scrubbedValues,
changedFiles,
enabled: options.scrubEnv,
});
await validateProjectedSecretsState({
env: params.env,
nextConfig,
resolvedTargets: targetMutations.resolvedTargets,
authStoreByPath,
});
return {
@@ -442,6 +256,415 @@ async function projectPlanState(params: {
};
}
function applyConfigTargetMutations(params: {
planTargets: SecretsPlanTarget[];
nextConfig: OpenClawConfig;
stateDir: string;
authStoreByPath: Map<string, Record<string, unknown>>;
changedFiles: Set<string>;
}): ConfigTargetMutationResult {
const resolvedTargets = params.planTargets.map((target) => ({
target,
resolved: resolveTarget(target),
}));
const scrubbedValues = new Set<string>();
const providerTargets = new Set<string>();
let configChanged = false;
for (const { target, resolved } of resolvedTargets) {
if (resolved.entry.configFile === "auth-profiles.json") {
const authStoreChanged = applyAuthProfileTargetMutation({
target,
resolved,
nextConfig: params.nextConfig,
stateDir: params.stateDir,
authStoreByPath: params.authStoreByPath,
scrubbedValues,
});
if (authStoreChanged) {
const agentId = String(target.agentId ?? "").trim();
if (!agentId) {
throw new Error(`Missing required agentId for auth-profiles target ${target.path}.`);
}
params.changedFiles.add(
resolveAuthStorePathForAgent({
nextConfig: params.nextConfig,
stateDir: params.stateDir,
agentId,
}),
);
}
continue;
}
const targetPathSegments = resolved.pathSegments;
if (resolved.entry.secretShape === "sibling_ref") {
const previous = getPath(params.nextConfig, targetPathSegments);
if (isNonEmptyString(previous)) {
scrubbedValues.add(previous.trim());
}
const refPathSegments = resolved.refPathSegments;
if (!refPathSegments) {
throw new Error(`Missing sibling ref path for target ${target.type}.`);
}
const wroteRef = setPathCreateStrict(params.nextConfig, refPathSegments, target.ref);
const deletedLegacy = deletePathStrict(params.nextConfig, targetPathSegments);
if (wroteRef || deletedLegacy) {
configChanged = true;
}
continue;
}
const previous = getPath(params.nextConfig, targetPathSegments);
if (isNonEmptyString(previous)) {
scrubbedValues.add(previous.trim());
}
const wroteRef = setPathCreateStrict(params.nextConfig, targetPathSegments, target.ref);
if (wroteRef) {
configChanged = true;
}
if (resolved.entry.trackProviderShadowing && resolved.providerId) {
providerTargets.add(normalizeProviderId(resolved.providerId));
}
}
return {
resolvedTargets,
scrubbedValues,
providerTargets,
configChanged,
authStoreByPath: params.authStoreByPath,
};
}
function scrubAuthStoresForProviderTargets(params: {
nextConfig: OpenClawConfig;
stateDir: string;
providerTargets: Set<string>;
scrubbedValues: Set<string>;
authStoreByPath: Map<string, Record<string, unknown>>;
changedFiles: Set<string>;
warnings: string[];
enabled: boolean;
}): Map<string, Record<string, unknown>> {
if (!params.enabled || params.providerTargets.size === 0) {
return params.authStoreByPath;
}
for (const authStorePath of listAuthProfileStorePaths(params.nextConfig, params.stateDir)) {
const existing = params.authStoreByPath.get(authStorePath);
const parsed = existing ?? readJsonObjectIfExists(authStorePath).value;
if (!parsed || !isRecord(parsed.profiles)) {
continue;
}
const nextStore = structuredClone(parsed) as Record<string, unknown> & {
profiles: Record<string, unknown>;
};
let mutated = false;
for (const profile of iterateAuthProfileCredentials(nextStore.profiles)) {
const provider = normalizeProviderId(profile.provider);
if (!params.providerTargets.has(provider)) {
continue;
}
if (profile.kind === "api_key" || profile.kind === "token") {
if (isNonEmptyString(profile.value)) {
params.scrubbedValues.add(profile.value.trim());
}
if (profile.valueField in profile.profile) {
delete profile.profile[profile.valueField];
mutated = true;
}
if (profile.refField in profile.profile) {
delete profile.profile[profile.refField];
mutated = true;
}
continue;
}
if (profile.kind === "oauth" && (profile.hasAccess || profile.hasRefresh)) {
params.warnings.push(
`Provider "${provider}" has OAuth credentials in ${authStorePath}; those still take precedence and are out of scope for static SecretRef migration.`,
);
}
}
if (mutated) {
params.authStoreByPath.set(authStorePath, nextStore);
params.changedFiles.add(authStorePath);
}
}
return params.authStoreByPath;
}
function ensureMutableAuthStore(
store: Record<string, unknown> | undefined,
): MutableAuthProfileStore {
const next: Record<string, unknown> = store ? structuredClone(store) : {};
if (!isRecord(next.profiles)) {
next.profiles = {};
}
if (typeof next.version !== "number" || !Number.isFinite(next.version)) {
next.version = AUTH_STORE_VERSION;
}
return next as MutableAuthProfileStore;
}
function resolveAuthStoreForTarget(params: {
target: SecretsPlanTarget;
nextConfig: OpenClawConfig;
stateDir: string;
authStoreByPath: Map<string, Record<string, unknown>>;
}): { path: string; store: MutableAuthProfileStore } {
const agentId = String(params.target.agentId ?? "").trim();
if (!agentId) {
throw new Error(`Missing required agentId for auth-profiles target ${params.target.path}.`);
}
const authStorePath = resolveAuthStorePathForAgent({
nextConfig: params.nextConfig,
stateDir: params.stateDir,
agentId,
});
const existing = params.authStoreByPath.get(authStorePath);
const loaded = existing ?? readJsonObjectIfExists(authStorePath).value;
const store = ensureMutableAuthStore(isRecord(loaded) ? loaded : undefined);
params.authStoreByPath.set(authStorePath, store);
return { path: authStorePath, store };
}
function asConfigPathRoot(store: MutableAuthProfileStore): OpenClawConfig {
return store as unknown as OpenClawConfig;
}
function resolveAuthStorePathForAgent(params: {
nextConfig: OpenClawConfig;
stateDir: string;
agentId: string;
}): string {
const normalizedAgentId = normalizeAgentId(params.agentId);
const configuredAgentDir = resolveAgentConfig(
params.nextConfig,
normalizedAgentId,
)?.agentDir?.trim();
if (configuredAgentDir) {
return resolveUserPath(resolveAuthStorePath(configuredAgentDir));
}
return path.join(
resolveUserPath(params.stateDir),
"agents",
normalizedAgentId,
"agent",
"auth-profiles.json",
);
}
function ensureAuthProfileContainer(params: {
target: SecretsPlanTarget;
resolved: ResolvedPlanTargetEntry["resolved"];
store: MutableAuthProfileStore;
}): boolean {
let changed = false;
const profilePathSegments = params.resolved.pathSegments.slice(0, 2);
const profileId = profilePathSegments[1];
if (!profileId) {
throw new Error(`Invalid auth profile target path: ${params.target.path}`);
}
const current = getPath(params.store, profilePathSegments);
const expectedType = params.resolved.entry.authProfileType;
if (isRecord(current)) {
if (expectedType && typeof current.type === "string" && current.type !== expectedType) {
throw new Error(
`Auth profile "${profileId}" type mismatch for ${params.target.path}: expected "${expectedType}", got "${current.type}".`,
);
}
if (
!isNonEmptyString(current.provider) &&
isNonEmptyString(params.target.authProfileProvider)
) {
const wroteProvider = setPathCreateStrict(
asConfigPathRoot(params.store),
[...profilePathSegments, "provider"],
params.target.authProfileProvider,
);
changed = changed || wroteProvider;
}
return changed;
}
if (!expectedType) {
throw new Error(
`Auth profile target ${params.target.path} is missing auth profile type metadata.`,
);
}
const provider = String(params.target.authProfileProvider ?? "").trim();
if (!provider) {
throw new Error(
`Cannot create auth profile "${profileId}" for ${params.target.path} without authProfileProvider.`,
);
}
const wroteProfile = setPathCreateStrict(asConfigPathRoot(params.store), profilePathSegments, {
type: expectedType,
provider,
});
changed = changed || wroteProfile;
return changed;
}
function applyAuthProfileTargetMutation(params: {
target: SecretsPlanTarget;
resolved: ResolvedPlanTargetEntry["resolved"];
nextConfig: OpenClawConfig;
stateDir: string;
authStoreByPath: Map<string, Record<string, unknown>>;
scrubbedValues: Set<string>;
}): boolean {
if (params.resolved.entry.configFile !== "auth-profiles.json") {
return false;
}
const { store } = resolveAuthStoreForTarget({
target: params.target,
nextConfig: params.nextConfig,
stateDir: params.stateDir,
authStoreByPath: params.authStoreByPath,
});
let changed = ensureAuthProfileContainer({
target: params.target,
resolved: params.resolved,
store,
});
const targetPathSegments = params.resolved.pathSegments;
if (params.resolved.entry.secretShape === "sibling_ref") {
const previous = getPath(store, targetPathSegments);
if (isNonEmptyString(previous)) {
params.scrubbedValues.add(previous.trim());
}
const refPathSegments = params.resolved.refPathSegments;
if (!refPathSegments) {
throw new Error(`Missing sibling ref path for auth-profiles target ${params.target.path}.`);
}
const wroteRef = setPathCreateStrict(
asConfigPathRoot(store),
refPathSegments,
params.target.ref,
);
const deletedPlaintext = deletePathStrict(asConfigPathRoot(store), targetPathSegments);
changed = changed || wroteRef || deletedPlaintext;
return changed;
}
const previous = getPath(store, targetPathSegments);
if (isNonEmptyString(previous)) {
params.scrubbedValues.add(previous.trim());
}
const wroteRef = setPathCreateStrict(
asConfigPathRoot(store),
targetPathSegments,
params.target.ref,
);
changed = changed || wroteRef;
return changed;
}
function scrubLegacyAuthJsonStores(params: {
stateDir: string;
changedFiles: Set<string>;
enabled: boolean;
}): Map<string, Record<string, unknown>> {
const authJsonByPath = new Map<string, Record<string, unknown>>();
if (!params.enabled) {
return authJsonByPath;
}
for (const authJsonPath of listLegacyAuthJsonPaths(params.stateDir)) {
const parsedResult = readJsonObjectIfExists(authJsonPath);
const parsed = parsedResult.value;
if (!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);
params.changedFiles.add(authJsonPath);
}
}
return authJsonByPath;
}
function scrubEnvFiles(params: {
env: NodeJS.ProcessEnv;
scrubbedValues: Set<string>;
changedFiles: Set<string>;
enabled: boolean;
}): Map<string, string> {
const envRawByPath = new Map<string, string>();
if (!params.enabled || params.scrubbedValues.size === 0) {
return envRawByPath;
}
const envPath = path.join(resolveConfigDir(params.env, os.homedir), ".env");
if (!fs.existsSync(envPath)) {
return envRawByPath;
}
const current = fs.readFileSync(envPath, "utf8");
const scrubbed = scrubEnvRaw(
current,
params.scrubbedValues,
new Set(listKnownSecretEnvVarNames()),
);
if (scrubbed.removed > 0 && scrubbed.nextRaw !== current) {
envRawByPath.set(envPath, scrubbed.nextRaw);
params.changedFiles.add(envPath);
}
return envRawByPath;
}
async function validateProjectedSecretsState(params: {
env: NodeJS.ProcessEnv;
nextConfig: OpenClawConfig;
resolvedTargets: ResolvedPlanTargetEntry[];
authStoreByPath: Map<string, Record<string, unknown>>;
}): Promise<void> {
const cache = {};
for (const { target, resolved: resolvedTarget } of params.resolvedTargets) {
const resolved = await resolveSecretRefValue(target.ref, {
config: params.nextConfig,
env: params.env,
cache,
});
assertExpectedResolvedSecretValue({
value: resolved,
expected: resolvedTarget.entry.expectedResolvedValue,
errorMessage:
resolvedTarget.entry.expectedResolvedValue === "string"
? `Ref ${target.ref.source}:${target.ref.provider}:${target.ref.id} is not a non-empty string.`
: `Ref ${target.ref.source}:${target.ref.provider}:${target.ref.id} is not string/object.`,
});
}
const authStoreLookup = new Map<string, Record<string, unknown>>();
for (const [authStorePath, store] of params.authStoreByPath.entries()) {
authStoreLookup.set(resolveUserPath(authStorePath), store);
}
await prepareSecretsRuntimeSnapshot({
config: params.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);
},
});
}
function captureFileSnapshot(pathname: string): FileSnapshot {
if (!fs.existsSync(pathname)) {
return { existed: false, content: "", mode: 0o600 };

View File

@@ -190,4 +190,68 @@ describe("secrets audit", () => {
const callCount = callLog.split("\n").filter((line) => line.trim().length > 0).length;
expect(callCount).toBe(1);
});
it("short-circuits per-ref fallback for provider-wide batch failures", async () => {
if (process.platform === "win32") {
return;
}
const execLogPath = path.join(fixture.rootDir, "exec-fail-calls.log");
const execScriptPath = path.join(fixture.rootDir, "resolver-fail.mjs");
await fs.writeFile(
execScriptPath,
[
"#!/usr/bin/env node",
"import fs from 'node:fs';",
`fs.appendFileSync(${JSON.stringify(execLogPath)}, 'x\\n');`,
"process.exit(1);",
].join("\n"),
{ encoding: "utf8", mode: 0o700 },
);
await fs.writeFile(
fixture.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(fixture.authStorePath, { force: true });
await fs.writeFile(fixture.envPath, "", "utf8");
const report = await runSecretsAudit({ env: fixture.env });
expect(report.summary.unresolvedRefCount).toBeGreaterThanOrEqual(2);
const callLog = await fs.readFile(execLogPath, "utf8");
const callCount = callLog.split("\n").filter((line) => line.trim().length > 0).length;
expect(callCount).toBe(1);
});
});

View File

@@ -3,18 +3,32 @@ import os from "node:os";
import path from "node:path";
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 { resolveSecretInputRef, type SecretRef } from "../config/types.secrets.js";
import { resolveConfigDir, resolveUserPath } from "../utils.js";
import { collectAuthStorePaths } from "./auth-store-paths.js";
import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js";
import { iterateAuthProfileCredentials } from "./auth-profiles-scan.js";
import { createSecretsConfigIO } from "./config-io.js";
import { listKnownSecretEnvVarNames } from "./provider-env-vars.js";
import { secretRefKey } from "./ref-contract.js";
import {
isProviderScopedSecretResolutionError,
resolveSecretRefValue,
resolveSecretRefValues,
type SecretRefResolveCache,
} from "./resolve.js";
import { isNonEmptyString, isRecord, parseEnvValue } from "./shared.js";
import {
hasConfiguredPlaintextSecretValue,
isExpectedResolvedSecretValue,
} from "./secret-value.js";
import { isNonEmptyString, isRecord } from "./shared.js";
import { describeUnknownError } from "./shared.js";
import {
listAuthProfileStorePaths,
listLegacyAuthJsonPaths,
parseEnvAssignmentValue,
readJsonObjectIfExists,
} from "./storage-scan.js";
import { discoverConfigSecretTargets } from "./target-registry.js";
export type SecretsAuditCode =
| "PLAINTEXT_FOUND"
@@ -76,6 +90,8 @@ type AuditCollector = {
filesScanned: Set<string>;
};
const REF_RESOLVE_FALLBACK_CONCURRENCY = 8;
function addFinding(collector: AuditCollector, finding: SecretsAuditFinding): void {
collector.findings.push(finding);
}
@@ -112,10 +128,6 @@ function trackAuthProviderState(
});
}
function parseDotPath(pathname: string): string[] {
return pathname.split(".").filter(Boolean);
}
function collectEnvPlaintext(params: { envPath: string; collector: AuditCollector }): void {
if (!fs.existsSync(params.envPath)) {
return;
@@ -133,7 +145,7 @@ function collectEnvPlaintext(params: { envPath: string; collector: AuditCollecto
if (!knownKeys.has(key)) {
continue;
}
const value = parseEnvValue(match[2] ?? "");
const value = parseEnvAssignmentValue(match[2] ?? "");
if (!value) {
continue;
}
@@ -147,150 +159,50 @@ function collectEnvPlaintext(params: { envPath: string; collector: AuditCollecto
}
}
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,
});
}
for (const target of discoverConfigSecretTargets(params.config)) {
if (!target.entry.includeInAudit) {
continue;
}
}
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;
const { ref } = resolveSecretInputRef({
value: target.value,
refValue: target.refValue,
defaults,
});
if (ref) {
params.collector.refAssignments.push({
file: params.configPath,
path: pathLabel,
path: target.path,
ref,
expected: "string-or-object",
provider: accountId ? "googlechat" : undefined,
expected: target.entry.expectedResolvedValue,
provider: target.providerId,
});
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)) {
if (target.entry.trackProviderShadowing && target.providerId) {
collectProviderRefPath(params.collector, target.providerId, target.path);
}
continue;
}
collectGoogleChatValue(
accountValue.serviceAccount,
accountValue.serviceAccountRef,
`channels.googlechat.accounts.${accountId}.serviceAccount`,
accountId,
const hasPlaintext = hasConfiguredPlaintextSecretValue(
target.value,
target.entry.expectedResolvedValue,
);
if (!hasPlaintext) {
continue;
}
addFinding(params.collector, {
code: "PLAINTEXT_FOUND",
severity: "warn",
file: params.configPath,
jsonPath: target.path,
message: `${target.path} is stored as plaintext.`,
provider: target.providerId,
});
}
}
@@ -303,7 +215,7 @@ function collectAuthStoreSecrets(params: {
return;
}
params.collector.filesScanned.add(params.authStorePath);
const parsedResult = readJsonObject(params.authStorePath);
const parsedResult = readJsonObjectIfExists(params.authStorePath);
if (parsedResult.error) {
addFinding(params.collector, {
code: "REF_UNRESOLVED",
@@ -318,101 +230,59 @@ function collectAuthStoreSecrets(params: {
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;
for (const entry of iterateAuthProfileCredentials(parsed.profiles)) {
if (entry.kind === "api_key" || entry.kind === "token") {
const { ref } = resolveSecretInputRef({
value: entry.value,
refValue: entry.refValue,
defaults: params.defaults,
});
if (ref) {
params.collector.refAssignments.push({
file: params.authStorePath,
path: `profiles.${profileId}.key`,
path: `profiles.${entry.profileId}.${entry.valueField}`,
ref,
expected: "string",
provider,
provider: entry.provider,
});
trackAuthProviderState(params.collector, provider, "api_key");
trackAuthProviderState(params.collector, entry.provider, entry.kind);
}
if (isNonEmptyString(profileValue.key)) {
if (isNonEmptyString(entry.value)) {
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,
jsonPath: `profiles.${entry.profileId}.${entry.valueField}`,
message:
entry.kind === "api_key"
? "Auth profile API key is stored as plaintext."
: "Auth profile token is stored as plaintext.",
provider: entry.provider,
profileId: entry.profileId,
});
trackAuthProviderState(params.collector, provider, "api_key");
trackAuthProviderState(params.collector, entry.provider, entry.kind);
}
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");
}
if (entry.hasAccess || entry.hasRefresh) {
addFinding(params.collector, {
code: "LEGACY_RESIDUE",
severity: "info",
file: params.authStorePath,
jsonPath: `profiles.${entry.profileId}`,
message: "OAuth credentials are present (out of scope for static SecretRef migration).",
provider: entry.provider,
profileId: entry.profileId,
});
trackAuthProviderState(params.collector, entry.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;
}
for (const authJsonPath of listLegacyAuthJsonPaths(params.stateDir)) {
params.collector.filesScanned.add(authJsonPath);
const parsedResult = readJsonObject(authJsonPath);
const parsedResult = readJsonObjectIfExists(authJsonPath);
if (parsedResult.error) {
addFinding(params.collector, {
code: "REF_UNRESOLVED",
@@ -467,6 +337,7 @@ async function collectUnresolvedRefFindings(params: {
for (const refsForProvider of refsByProvider.values()) {
const refs = [...refsForProvider.values()];
const provider = refs[0]?.provider;
try {
const resolved = await resolveSecretRefValues(refs, {
config: params.config,
@@ -477,22 +348,43 @@ async function collectUnresolvedRefFindings(params: {
resolvedByRefKey.set(key, value);
}
continue;
} catch {
} catch (err) {
if (provider && isProviderScopedSecretResolutionError(err)) {
for (const ref of refs) {
errorsByRefKey.set(secretRefKey(ref), err);
}
continue;
}
// 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, {
const tasks = refs.map(
(ref) => async (): Promise<{ key: string; resolved: unknown }> => ({
key: secretRefKey(ref),
resolved: await resolveSecretRefValue(ref, {
config: params.config,
env: params.env,
cache,
});
resolvedByRefKey.set(key, resolved);
} catch (err) {
errorsByRefKey.set(key, err);
}),
}),
);
const fallback = await runTasksWithConcurrency({
tasks,
limit: Math.min(REF_RESOLVE_FALLBACK_CONCURRENCY, refs.length),
errorMode: "continue",
onTaskError: (error, index) => {
const ref = refs[index];
if (!ref) {
return;
}
errorsByRefKey.set(secretRefKey(ref), error);
},
});
for (const result of fallback.results) {
if (!result) {
continue;
}
resolvedByRefKey.set(result.key, result.resolved);
}
}
@@ -524,26 +416,16 @@ async function collectUnresolvedRefFindings(params: {
}
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))) {
if (!isExpectedResolvedSecretValue(resolved, assignment.expected)) {
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).`,
message:
assignment.expected === "string"
? `Failed to resolve ${assignment.ref.source}:${assignment.ref.provider}:${assignment.ref.id} (resolved value is not a non-empty string).`
: `Failed to resolve ${assignment.ref.source}:${assignment.ref.provider}:${assignment.ref.id} (resolved value is not a string/object).`,
provider: assignment.provider,
});
}
@@ -570,21 +452,6 @@ function collectShadowingFindings(collector: AuditCollector): void {
}
}
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,
@@ -600,86 +467,76 @@ export async function runSecretsAudit(
} = {},
): 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 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 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);
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,
if (snapshot.valid) {
collectConfigSecrets({
config,
configPath,
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;
for (const authStorePath of listAuthProfileStorePaths(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,
};
}
export function resolveSecretsAuditExitCode(report: SecretsAuditReport, check: boolean): number {
@@ -691,23 +548,3 @@ export function resolveSecretsAuditExitCode(report: SecretsAuditReport, check: b
}
return 0;
}
export function applySecretsPlanTarget(
config: OpenClawConfig,
pathLabel: string,
value: unknown,
): void {
const segments = parseDotPath(pathLabel);
if (segments.length === 0) {
throw new Error("Invalid target path.");
}
let cursor: Record<string, unknown> = config as unknown as Record<string, unknown>;
for (const segment of segments.slice(0, -1)) {
const existing = cursor[segment];
if (!isRecord(existing)) {
cursor[segment] = {};
}
cursor = cursor[segment] as Record<string, unknown>;
}
cursor[segments[segments.length - 1]] = value;
}

View File

@@ -0,0 +1,123 @@
import { isNonEmptyString, isRecord } from "./shared.js";
import { listAuthProfileSecretTargetEntries } from "./target-registry.js";
export type AuthProfileCredentialType = "api_key" | "token";
type AuthProfileFieldSpec = {
valueField: string;
refField: string;
};
type ApiKeyCredentialVisit = {
kind: "api_key";
profileId: string;
provider: string;
profile: Record<string, unknown>;
valueField: string;
refField: string;
value: unknown;
refValue: unknown;
};
type TokenCredentialVisit = {
kind: "token";
profileId: string;
provider: string;
profile: Record<string, unknown>;
valueField: string;
refField: string;
value: unknown;
refValue: unknown;
};
type OauthCredentialVisit = {
kind: "oauth";
profileId: string;
provider: string;
profile: Record<string, unknown>;
hasAccess: boolean;
hasRefresh: boolean;
};
export type AuthProfileCredentialVisit =
| ApiKeyCredentialVisit
| TokenCredentialVisit
| OauthCredentialVisit;
function getAuthProfileFieldName(pathPattern: string): string {
const segments = pathPattern.split(".").filter(Boolean);
return segments[segments.length - 1] ?? "";
}
const AUTH_PROFILE_FIELD_SPEC_BY_TYPE = (() => {
const defaults: Record<AuthProfileCredentialType, AuthProfileFieldSpec> = {
api_key: { valueField: "key", refField: "keyRef" },
token: { valueField: "token", refField: "tokenRef" },
};
for (const target of listAuthProfileSecretTargetEntries()) {
if (!target.authProfileType) {
continue;
}
defaults[target.authProfileType] = {
valueField: getAuthProfileFieldName(target.pathPattern),
refField:
target.refPathPattern !== undefined
? getAuthProfileFieldName(target.refPathPattern)
: defaults[target.authProfileType].refField,
};
}
return defaults;
})();
export function getAuthProfileFieldSpec(type: AuthProfileCredentialType): AuthProfileFieldSpec {
return AUTH_PROFILE_FIELD_SPEC_BY_TYPE[type];
}
export function* iterateAuthProfileCredentials(
profiles: Record<string, unknown>,
): Iterable<AuthProfileCredentialVisit> {
for (const [profileId, value] of Object.entries(profiles)) {
if (!isRecord(value) || !isNonEmptyString(value.provider)) {
continue;
}
const provider = String(value.provider);
if (value.type === "api_key") {
const spec = getAuthProfileFieldSpec("api_key");
yield {
kind: "api_key",
profileId,
provider,
profile: value,
valueField: spec.valueField,
refField: spec.refField,
value: value[spec.valueField],
refValue: value[spec.refField],
};
continue;
}
if (value.type === "token") {
const spec = getAuthProfileFieldSpec("token");
yield {
kind: "token",
profileId,
provider,
profile: value,
valueField: spec.valueField,
refField: spec.refField,
value: value[spec.valueField],
refValue: value[spec.refField],
};
continue;
}
if (value.type === "oauth") {
yield {
kind: "oauth",
profileId,
provider,
profile: value,
hasAccess: isNonEmptyString(value.access),
hasRefresh: isNonEmptyString(value.refresh),
};
}
}
}

View File

@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { collectCommandSecretAssignmentsFromSnapshot } from "./command-config.js";
describe("collectCommandSecretAssignmentsFromSnapshot", () => {
it("returns assignments from the active runtime snapshot for configured refs", () => {
const sourceConfig = {
talk: {
apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" },
},
} as unknown as OpenClawConfig;
const resolvedConfig = {
talk: {
apiKey: "talk-key",
},
} as unknown as OpenClawConfig;
const result = collectCommandSecretAssignmentsFromSnapshot({
sourceConfig,
resolvedConfig,
commandName: "memory status",
targetIds: new Set(["talk.apiKey"]),
});
expect(result.assignments).toEqual([
{
path: "talk.apiKey",
pathSegments: ["talk", "apiKey"],
value: "talk-key",
},
]);
});
it("throws when configured refs are unresolved in the snapshot", () => {
const sourceConfig = {
talk: {
apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" },
},
} as unknown as OpenClawConfig;
const resolvedConfig = {
talk: {},
} as unknown as OpenClawConfig;
expect(() =>
collectCommandSecretAssignmentsFromSnapshot({
sourceConfig,
resolvedConfig,
commandName: "memory search",
targetIds: new Set(["talk.apiKey"]),
}),
).toThrow(/memory search: talk\.apiKey is unresolved in the active runtime snapshot/);
});
it("skips unresolved refs that are marked inactive by runtime warnings", () => {
const sourceConfig = {
agents: {
defaults: {
memorySearch: {
remote: {
apiKey: { source: "env", provider: "default", id: "DEFAULT_MEMORY_KEY" },
},
},
},
},
} as unknown as OpenClawConfig;
const resolvedConfig = {
agents: {
defaults: {
memorySearch: {
remote: {
apiKey: { source: "env", provider: "default", id: "DEFAULT_MEMORY_KEY" },
},
},
},
},
} as unknown as OpenClawConfig;
const result = collectCommandSecretAssignmentsFromSnapshot({
sourceConfig,
resolvedConfig,
commandName: "memory search",
targetIds: new Set(["agents.defaults.memorySearch.remote.apiKey"]),
inactiveRefPaths: new Set(["agents.defaults.memorySearch.remote.apiKey"]),
});
expect(result.assignments).toEqual([]);
expect(result.diagnostics).toEqual([
"agents.defaults.memorySearch.remote.apiKey: secret ref is configured on an inactive surface; skipping command-time assignment.",
]);
});
});

View File

@@ -0,0 +1,67 @@
import type { OpenClawConfig } from "../config/config.js";
import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js";
import { getPath } from "./path-utils.js";
import { isExpectedResolvedSecretValue } from "./secret-value.js";
import { discoverConfigSecretTargetsByIds } from "./target-registry.js";
export type CommandSecretAssignment = {
path: string;
pathSegments: string[];
value: unknown;
};
export type ResolveAssignmentsFromSnapshotResult = {
assignments: CommandSecretAssignment[];
diagnostics: string[];
};
export function collectCommandSecretAssignmentsFromSnapshot(params: {
sourceConfig: OpenClawConfig;
resolvedConfig: OpenClawConfig;
commandName: string;
targetIds: ReadonlySet<string>;
inactiveRefPaths?: ReadonlySet<string>;
}): ResolveAssignmentsFromSnapshotResult {
const defaults = params.sourceConfig.secrets?.defaults;
const assignments: CommandSecretAssignment[] = [];
const diagnostics: string[] = [];
for (const target of discoverConfigSecretTargetsByIds(params.sourceConfig, params.targetIds)) {
const { explicitRef, ref } = resolveSecretInputRef({
value: target.value,
refValue: target.refValue,
defaults,
});
const inlineCandidateRef = explicitRef ? coerceSecretRef(target.value, defaults) : null;
if (!ref) {
continue;
}
const resolved = getPath(params.resolvedConfig, target.pathSegments);
if (!isExpectedResolvedSecretValue(resolved, target.entry.expectedResolvedValue)) {
if (params.inactiveRefPaths?.has(target.path)) {
diagnostics.push(
`${target.path}: secret ref is configured on an inactive surface; skipping command-time assignment.`,
);
continue;
}
throw new Error(
`${params.commandName}: ${target.path} is unresolved in the active runtime snapshot.`,
);
}
assignments.push({
path: target.path,
pathSegments: [...target.pathSegments],
value: resolved,
});
if (target.entry.secretShape === "sibling_ref" && explicitRef && inlineCandidateRef) {
diagnostics.push(
`${target.path}: both inline and sibling ref were present; sibling ref took precedence.`,
);
}
}
return { assignments, diagnostics };
}

View File

@@ -0,0 +1,209 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
buildConfigureCandidates,
buildConfigureCandidatesForScope,
buildSecretsConfigurePlan,
collectConfigureProviderChanges,
hasConfigurePlanChanges,
} from "./configure-plan.js";
describe("secrets configure plan helpers", () => {
it("builds configure candidates from supported configure targets", () => {
const config = {
talk: {
apiKey: "plain",
},
channels: {
telegram: {
botToken: "token",
},
},
} as OpenClawConfig;
const candidates = buildConfigureCandidates(config);
const paths = candidates.map((entry) => entry.path);
expect(paths).toContain("talk.apiKey");
expect(paths).toContain("channels.telegram.botToken");
});
it("collects provider upserts and deletes", () => {
const original = {
secrets: {
providers: {
default: { source: "env" },
legacy: { source: "env" },
},
},
} as OpenClawConfig;
const next = {
secrets: {
providers: {
default: { source: "env", allowlist: ["OPENAI_API_KEY"] },
modern: { source: "env" },
},
},
} as OpenClawConfig;
const changes = collectConfigureProviderChanges({ original, next });
expect(Object.keys(changes.upserts).toSorted()).toEqual(["default", "modern"]);
expect(changes.deletes).toEqual(["legacy"]);
});
it("discovers auth-profiles candidates for the selected agent scope", () => {
const candidates = buildConfigureCandidatesForScope({
config: {} as OpenClawConfig,
authProfiles: {
agentId: "main",
store: {
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk",
},
},
},
},
});
expect(candidates).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "auth-profiles.api_key.key",
path: "profiles.openai:default.key",
agentId: "main",
configFile: "auth-profiles.json",
authProfileProvider: "openai",
}),
]),
);
});
it("captures existing refs for prefilled configure prompts", () => {
const candidates = buildConfigureCandidatesForScope({
config: {
talk: {
apiKey: {
source: "env",
provider: "default",
id: "TALK_API_KEY",
},
},
} as OpenClawConfig,
authProfiles: {
agentId: "main",
store: {
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
keyRef: {
source: "env",
provider: "default",
id: "OPENAI_API_KEY",
},
},
},
},
},
});
expect(candidates).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: "talk.apiKey",
existingRef: {
source: "env",
provider: "default",
id: "TALK_API_KEY",
},
}),
expect.objectContaining({
path: "profiles.openai:default.key",
existingRef: {
source: "env",
provider: "default",
id: "OPENAI_API_KEY",
},
}),
]),
);
});
it("marks normalized alias paths as derived when not authored directly", () => {
const candidates = buildConfigureCandidatesForScope({
config: {
talk: {
provider: "elevenlabs",
providers: {
elevenlabs: {
apiKey: "demo-talk-key",
},
},
apiKey: "demo-talk-key",
},
} as OpenClawConfig,
authoredOpenClawConfig: {
talk: {
apiKey: "demo-talk-key",
},
} as OpenClawConfig,
});
const legacy = candidates.find((entry) => entry.path === "talk.apiKey");
const normalized = candidates.find(
(entry) => entry.path === "talk.providers.elevenlabs.apiKey",
);
expect(legacy?.isDerived).not.toBe(true);
expect(normalized?.isDerived).toBe(true);
});
it("reports configure change presence and builds deterministic plan shape", () => {
const selected = new Map([
[
"talk.apiKey",
{
type: "talk.apiKey",
path: "talk.apiKey",
pathSegments: ["talk", "apiKey"],
label: "talk.apiKey",
configFile: "openclaw.json" as const,
expectedResolvedValue: "string" as const,
ref: {
source: "env" as const,
provider: "default",
id: "TALK_API_KEY",
},
},
],
]);
const providerChanges = {
upserts: {
default: { source: "env" as const },
},
deletes: [],
};
expect(
hasConfigurePlanChanges({
selectedTargets: selected,
providerChanges,
}),
).toBe(true);
const plan = buildSecretsConfigurePlan({
selectedTargets: selected,
providerChanges,
generatedAt: "2026-02-28T00:00:00.000Z",
});
expect(plan.targets).toHaveLength(1);
expect(plan.targets[0]?.path).toBe("talk.apiKey");
expect(plan.providerUpserts).toBeDefined();
expect(plan.options).toEqual({
scrubEnv: true,
scrubAuthProfilesForProviderTargets: true,
scrubLegacyAuthJson: true,
});
});
});

View File

@@ -0,0 +1,259 @@
import { isDeepStrictEqual } from "node:util";
import type { AuthProfileStore } from "../agents/auth-profiles.js";
import type { OpenClawConfig } from "../config/config.js";
import {
resolveSecretInputRef,
type SecretProviderConfig,
type SecretRef,
} from "../config/types.secrets.js";
import type { SecretsApplyPlan } from "./plan.js";
import { isRecord } from "./shared.js";
import {
discoverAuthProfileSecretTargets,
discoverConfigSecretTargets,
} from "./target-registry.js";
export type ConfigureCandidate = {
type: string;
path: string;
pathSegments: string[];
label: string;
configFile: "openclaw.json" | "auth-profiles.json";
expectedResolvedValue: "string" | "string-or-object";
existingRef?: SecretRef;
isDerived?: boolean;
agentId?: string;
providerId?: string;
accountId?: string;
authProfileProvider?: string;
};
export type ConfigureSelectedTarget = ConfigureCandidate & {
ref: SecretRef;
};
export type ConfigureProviderChanges = {
upserts: Record<string, SecretProviderConfig>;
deletes: string[];
};
function getSecretProviders(config: OpenClawConfig): Record<string, SecretProviderConfig> {
if (!isRecord(config.secrets?.providers)) {
return {};
}
return config.secrets.providers;
}
export function buildConfigureCandidates(config: OpenClawConfig): ConfigureCandidate[] {
return buildConfigureCandidatesForScope({ config });
}
function configureCandidateSortKey(candidate: ConfigureCandidate): string {
if (candidate.configFile === "auth-profiles.json") {
const agentId = candidate.agentId ?? "";
return `auth-profiles:${agentId}:${candidate.path}`;
}
return `openclaw:${candidate.path}`;
}
function resolveAuthProfileProvider(
store: AuthProfileStore,
pathSegments: string[],
): string | undefined {
const profileId = pathSegments[1];
if (!profileId) {
return undefined;
}
const profile = store.profiles?.[profileId];
if (!isRecord(profile) || typeof profile.provider !== "string") {
return undefined;
}
const provider = profile.provider.trim();
return provider.length > 0 ? provider : undefined;
}
export function buildConfigureCandidatesForScope(params: {
config: OpenClawConfig;
authoredOpenClawConfig?: OpenClawConfig;
authProfiles?: {
agentId: string;
store: AuthProfileStore;
};
}): ConfigureCandidate[] {
const authoredConfig = params.authoredOpenClawConfig ?? params.config;
const hasPathInAuthoredConfig = (pathSegments: string[]): boolean =>
hasPath(authoredConfig, pathSegments);
const openclawCandidates = discoverConfigSecretTargets(params.config)
.filter((entry) => entry.entry.includeInConfigure)
.map((entry) => {
const resolved = resolveSecretInputRef({
value: entry.value,
refValue: entry.refValue,
defaults: params.config.secrets?.defaults,
});
const pathExists = hasPathInAuthoredConfig(entry.pathSegments);
const refPathExists = entry.refPathSegments
? hasPathInAuthoredConfig(entry.refPathSegments)
: false;
return {
type: entry.entry.targetType,
path: entry.path,
pathSegments: [...entry.pathSegments],
label: entry.path,
configFile: "openclaw.json" as const,
expectedResolvedValue: entry.entry.expectedResolvedValue,
...(resolved.ref ? { existingRef: resolved.ref } : {}),
...(pathExists || refPathExists ? {} : { isDerived: true }),
...(entry.providerId ? { providerId: entry.providerId } : {}),
...(entry.accountId ? { accountId: entry.accountId } : {}),
};
});
const authCandidates =
params.authProfiles === undefined
? []
: discoverAuthProfileSecretTargets(params.authProfiles.store)
.filter((entry) => entry.entry.includeInConfigure)
.map((entry) => {
const authProfiles = params.authProfiles;
if (!authProfiles) {
throw new Error("Missing auth profile scope for configure candidate discovery.");
}
const authProfileProvider = resolveAuthProfileProvider(
authProfiles.store,
entry.pathSegments,
);
const resolved = resolveSecretInputRef({
value: entry.value,
refValue: entry.refValue,
defaults: params.config.secrets?.defaults,
});
return {
type: entry.entry.targetType,
path: entry.path,
pathSegments: [...entry.pathSegments],
label: `${entry.path} (auth profile, agent ${authProfiles.agentId})`,
configFile: "auth-profiles.json" as const,
expectedResolvedValue: entry.entry.expectedResolvedValue,
...(resolved.ref ? { existingRef: resolved.ref } : {}),
agentId: authProfiles.agentId,
...(authProfileProvider ? { authProfileProvider } : {}),
};
});
return [...openclawCandidates, ...authCandidates].toSorted((a, b) =>
configureCandidateSortKey(a).localeCompare(configureCandidateSortKey(b)),
);
}
function hasPath(root: unknown, segments: string[]): boolean {
if (segments.length === 0) {
return false;
}
let cursor: unknown = root;
for (let index = 0; index < segments.length; index += 1) {
const segment = segments[index] ?? "";
if (Array.isArray(cursor)) {
if (!/^\d+$/.test(segment)) {
return false;
}
const parsedIndex = Number.parseInt(segment, 10);
if (!Number.isFinite(parsedIndex) || parsedIndex < 0 || parsedIndex >= cursor.length) {
return false;
}
if (index === segments.length - 1) {
return true;
}
cursor = cursor[parsedIndex];
continue;
}
if (!isRecord(cursor)) {
return false;
}
if (!Object.prototype.hasOwnProperty.call(cursor, segment)) {
return false;
}
if (index === segments.length - 1) {
return true;
}
cursor = cursor[segment];
}
return false;
}
export function collectConfigureProviderChanges(params: {
original: OpenClawConfig;
next: OpenClawConfig;
}): ConfigureProviderChanges {
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 function hasConfigurePlanChanges(params: {
selectedTargets: ReadonlyMap<string, ConfigureSelectedTarget>;
providerChanges: ConfigureProviderChanges;
}): boolean {
return (
params.selectedTargets.size > 0 ||
Object.keys(params.providerChanges.upserts).length > 0 ||
params.providerChanges.deletes.length > 0
);
}
export function buildSecretsConfigurePlan(params: {
selectedTargets: ReadonlyMap<string, ConfigureSelectedTarget>;
providerChanges: ConfigureProviderChanges;
generatedAt?: string;
}): SecretsApplyPlan {
return {
version: 1,
protocolVersion: 1,
generatedAt: params.generatedAt ?? new Date().toISOString(),
generatedBy: "openclaw secrets configure",
targets: [...params.selectedTargets.values()].map((entry) => ({
type: entry.type,
path: entry.path,
pathSegments: [...entry.pathSegments],
ref: entry.ref,
...(entry.agentId ? { agentId: entry.agentId } : {}),
...(entry.providerId ? { providerId: entry.providerId } : {}),
...(entry.accountId ? { accountId: entry.accountId } : {}),
...(entry.authProfileProvider ? { authProfileProvider: entry.authProfileProvider } : {}),
})),
...(Object.keys(params.providerChanges.upserts).length > 0
? { providerUpserts: params.providerChanges.upserts }
: {}),
...(params.providerChanges.deletes.length > 0
? { providerDeletes: params.providerChanges.deletes }
: {}),
options: {
scrubEnv: true,
scrubAuthProfilesForProviderTargets: true,
scrubLegacyAuthJson: true,
},
};
}

View File

@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const selectMock = vi.hoisted(() => vi.fn());
const createSecretsConfigIOMock = vi.hoisted(() => vi.fn());
const readJsonObjectIfExistsMock = vi.hoisted(() => vi.fn());
vi.mock("@clack/prompts", () => ({
confirm: vi.fn(),
select: (...args: unknown[]) => selectMock(...args),
text: vi.fn(),
}));
vi.mock("./config-io.js", () => ({
createSecretsConfigIO: (...args: unknown[]) => createSecretsConfigIOMock(...args),
}));
vi.mock("./storage-scan.js", () => ({
readJsonObjectIfExists: (...args: unknown[]) => readJsonObjectIfExistsMock(...args),
}));
const { runSecretsConfigureInteractive } = await import("./configure.js");
describe("runSecretsConfigureInteractive", () => {
beforeEach(() => {
selectMock.mockReset();
createSecretsConfigIOMock.mockReset();
readJsonObjectIfExistsMock.mockReset();
});
it("does not load auth-profiles when running providers-only", async () => {
Object.defineProperty(process.stdin, "isTTY", {
value: true,
configurable: true,
});
selectMock.mockResolvedValue("continue");
createSecretsConfigIOMock.mockReturnValue({
readConfigFileSnapshotForWrite: async () => ({
snapshot: {
valid: true,
config: {},
resolved: {},
},
}),
});
readJsonObjectIfExistsMock.mockReturnValue({
error: "boom",
value: null,
});
await expect(runSecretsConfigureInteractive({ providersOnly: true })).rejects.toThrow(
"No secrets changes were selected.",
);
expect(readJsonObjectIfExistsMock).not.toHaveBeenCalled();
});
});

View File

@@ -1,30 +1,36 @@
import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { confirm, select, text } from "@clack/prompts";
import { listAgentIds, resolveAgentDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import type { AuthProfileStore } from "../agents/auth-profiles.js";
import { AUTH_STORE_VERSION } from "../agents/auth-profiles/constants.js";
import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js";
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 { normalizeAgentId } from "../routing/session-key.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 {
buildConfigureCandidatesForScope,
buildSecretsConfigurePlan,
collectConfigureProviderChanges,
hasConfigurePlanChanges,
type ConfigureCandidate,
} from "./configure-plan.js";
import type { SecretsApplyPlan } from "./plan.js";
import { PROVIDER_ENV_VARS } from "./provider-env-vars.js";
import { isValidSecretProviderAlias, resolveDefaultSecretProviderAlias } from "./ref-contract.js";
import { resolveSecretRefValue } from "./resolve.js";
import { assertExpectedResolvedSecretValue } from "./secret-value.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;
};
import { readJsonObjectIfExists } from "./storage-scan.js";
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 = /^\\\\[^\\]+\\[^\\]+/;
@@ -124,67 +130,6 @@ function providerHint(provider: SecretProviderConfig): string {
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);
@@ -210,6 +155,8 @@ function assertNoCancel<T>(value: T | symbol, message: string): T {
return value;
}
const AUTH_PROFILE_ID_PATTERN = /^[A-Za-z0-9:_-]{1,128}$/;
function validateEnvNameCsv(value: string): string | undefined {
const entries = parseCsv(value);
for (const entry of entries) {
@@ -243,10 +190,14 @@ async function promptOptionalPositiveInt(params: {
const raw = assertNoCancel(
await text({
message: params.message,
initialValue: params.initialValue ? String(params.initialValue) : "",
initialValue: params.initialValue === undefined ? "" : String(params.initialValue),
validate: (value) => {
const parsed = parseOptionalPositiveInt(String(value ?? ""), params.max);
if (String(value ?? "").trim() && parsed === undefined) {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return undefined;
}
const parsed = parseOptionalPositiveInt(trimmed, params.max);
if (parsed === undefined) {
return `Must be an integer between 1 and ${params.max}`;
}
return undefined;
@@ -254,7 +205,168 @@ async function promptOptionalPositiveInt(params: {
}),
"Secrets configure cancelled.",
);
return parseOptionalPositiveInt(String(raw ?? ""), params.max);
const parsed = parseOptionalPositiveInt(String(raw ?? ""), params.max);
return parsed;
}
function configureCandidateKey(candidate: {
configFile: "openclaw.json" | "auth-profiles.json";
path: string;
agentId?: string;
}): string {
if (candidate.configFile === "auth-profiles.json") {
return `auth-profiles:${String(candidate.agentId ?? "").trim()}:${candidate.path}`;
}
return `openclaw:${candidate.path}`;
}
function hasSourceChoice(
sourceChoices: Array<{ value: SecretRefSource; label: string }>,
source: SecretRefSource,
): boolean {
return sourceChoices.some((entry) => entry.value === source);
}
function resolveCandidateProviderHint(candidate: ConfigureCandidate): string | undefined {
if (typeof candidate.authProfileProvider === "string" && candidate.authProfileProvider.trim()) {
return candidate.authProfileProvider.trim().toLowerCase();
}
if (typeof candidate.providerId === "string" && candidate.providerId.trim()) {
return candidate.providerId.trim().toLowerCase();
}
return undefined;
}
function resolveSuggestedEnvSecretId(candidate: ConfigureCandidate): string | undefined {
const hintedProvider = resolveCandidateProviderHint(candidate);
if (!hintedProvider) {
return undefined;
}
const envCandidates = PROVIDER_ENV_VARS[hintedProvider];
if (!Array.isArray(envCandidates) || envCandidates.length === 0) {
return undefined;
}
return envCandidates[0];
}
function resolveConfigureAgentId(config: OpenClawConfig, explicitAgentId?: string): string {
const knownAgentIds = new Set(listAgentIds(config));
if (!explicitAgentId) {
return resolveDefaultAgentId(config);
}
const normalized = normalizeAgentId(explicitAgentId);
if (knownAgentIds.has(normalized)) {
return normalized;
}
const known = [...knownAgentIds].toSorted().join(", ");
throw new Error(
`Unknown agent id "${explicitAgentId}". Known agents: ${known || "none configured"}.`,
);
}
function normalizeAuthStoreForConfigure(
raw: Record<string, unknown> | null,
storePath: string,
): AuthProfileStore {
if (!raw) {
return {
version: AUTH_STORE_VERSION,
profiles: {},
};
}
if (!isRecord(raw.profiles)) {
throw new Error(
`Cannot run interactive secrets configure because ${storePath} is invalid (missing "profiles" object).`,
);
}
const version = typeof raw.version === "number" && Number.isFinite(raw.version) ? raw.version : 1;
return {
version,
profiles: raw.profiles as AuthProfileStore["profiles"],
...(isRecord(raw.order) ? { order: raw.order as AuthProfileStore["order"] } : {}),
...(isRecord(raw.lastGood) ? { lastGood: raw.lastGood as AuthProfileStore["lastGood"] } : {}),
...(isRecord(raw.usageStats)
? { usageStats: raw.usageStats as AuthProfileStore["usageStats"] }
: {}),
};
}
function loadAuthProfileStoreForConfigure(params: {
config: OpenClawConfig;
agentId: string;
}): AuthProfileStore {
const agentDir = resolveAgentDir(params.config, params.agentId);
const storePath = resolveAuthStorePath(agentDir);
const parsed = readJsonObjectIfExists(storePath);
if (parsed.error) {
throw new Error(
`Cannot run interactive secrets configure because ${storePath} could not be read: ${parsed.error}`,
);
}
return normalizeAuthStoreForConfigure(parsed.value, storePath);
}
async function promptNewAuthProfileCandidate(agentId: string): Promise<ConfigureCandidate> {
const profileId = assertNoCancel(
await text({
message: "Auth profile id",
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return "Required";
}
if (!AUTH_PROFILE_ID_PATTERN.test(trimmed)) {
return 'Use letters/numbers/":"/"_"/"-" only.';
}
return undefined;
},
}),
"Secrets configure cancelled.",
);
const credentialType = assertNoCancel(
await select({
message: "Auth profile credential type",
options: [
{ value: "api_key", label: "api_key (key/keyRef)" },
{ value: "token", label: "token (token/tokenRef)" },
],
}),
"Secrets configure cancelled.",
);
const provider = assertNoCancel(
await text({
message: "Provider id",
validate: (value) => (String(value ?? "").trim().length > 0 ? undefined : "Required"),
}),
"Secrets configure cancelled.",
);
const profileIdTrimmed = String(profileId).trim();
const providerTrimmed = String(provider).trim();
if (credentialType === "token") {
return {
type: "auth-profiles.token.token",
path: `profiles.${profileIdTrimmed}.token`,
pathSegments: ["profiles", profileIdTrimmed, "token"],
label: `profiles.${profileIdTrimmed}.token (auth profile, agent ${agentId})`,
configFile: "auth-profiles.json",
agentId,
authProfileProvider: providerTrimmed,
expectedResolvedValue: "string",
};
}
return {
type: "auth-profiles.api_key.key",
path: `profiles.${profileIdTrimmed}.key`,
pathSegments: ["profiles", profileIdTrimmed, "key"],
label: `profiles.${profileIdTrimmed}.key (auth profile, agent ${agentId})`,
configFile: "auth-profiles.json",
agentId,
authProfileProvider: providerTrimmed,
expectedResolvedValue: "string",
};
}
async function promptProviderAlias(params: { existingAliases: Set<string> }): Promise<string> {
@@ -267,7 +379,7 @@ async function promptProviderAlias(params: { existingAliases: Set<string> }): Pr
if (!trimmed) {
return "Required";
}
if (!PROVIDER_ALIAS_PATTERN.test(trimmed)) {
if (!isValidSecretProviderAlias(trimmed)) {
return "Must match /^[a-z][a-z0-9_-]{0,63}$/";
}
if (params.existingAliases.has(trimmed)) {
@@ -625,41 +737,12 @@ async function configureProvidersInteractive(config: OpenClawConfig): Promise<vo
}
}
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;
agentId?: string;
} = {},
): Promise<SecretsConfigureResult> {
if (!process.stdin.isTTY) {
@@ -681,26 +764,62 @@ export async function runSecretsConfigureInteractive(
await configureProvidersInteractive(stagedConfig);
}
const providerChanges = collectProviderPlanChanges({
const providerChanges = collectConfigureProviderChanges({
original: snapshot.config,
next: stagedConfig,
});
const selectedByPath = new Map<string, ConfigureCandidate & { ref: SecretRef }>();
if (!params.providersOnly) {
const candidates = buildCandidates(stagedConfig);
const configureAgentId = resolveConfigureAgentId(snapshot.config, params.agentId);
const authStore = loadAuthProfileStoreForConfigure({
config: snapshot.config,
agentId: configureAgentId,
});
const candidates = buildConfigureCandidatesForScope({
config: stagedConfig,
authoredOpenClawConfig: snapshot.resolved,
authProfiles: {
agentId: configureAgentId,
store: authStore,
},
});
if (candidates.length === 0) {
throw new Error("No configurable secret-bearing fields found in openclaw.json.");
throw new Error("No configurable secret-bearing fields found for this agent scope.");
}
const sourceChoices = toSourceChoices(stagedConfig);
const hasDerivedCandidates = candidates.some((candidate) => candidate.isDerived === true);
let showDerivedCandidates = false;
while (true) {
const options = candidates.map((candidate) => ({
value: candidate.path,
const visibleCandidates = showDerivedCandidates
? candidates
: candidates.filter((candidate) => candidate.isDerived !== true);
const options = visibleCandidates.map((candidate) => ({
value: configureCandidateKey(candidate),
label: candidate.label,
hint: candidate.path,
hint: [
candidate.configFile === "auth-profiles.json" ? "auth-profiles.json" : "openclaw.json",
candidate.isDerived === true ? "derived" : undefined,
]
.filter(Boolean)
.join(" | "),
}));
options.push({
value: "__create_auth_profile__",
label: "Create auth profile mapping",
hint: `Add a new auth-profiles target for agent ${configureAgentId}`,
});
if (hasDerivedCandidates) {
options.push({
value: "__toggle_derived__",
label: showDerivedCandidates ? "Hide derived targets" : "Show derived targets",
hint: showDerivedCandidates
? "Show only fields authored directly in config"
: "Include normalized/derived aliases",
});
}
if (selectedByPath.size > 0) {
options.unshift({
value: "__done__",
@@ -720,16 +839,41 @@ export async function runSecretsConfigureInteractive(
if (selectedPath === "__done__") {
break;
}
if (selectedPath === "__create_auth_profile__") {
const createdCandidate = await promptNewAuthProfileCandidate(configureAgentId);
const key = configureCandidateKey(createdCandidate);
const existingIndex = candidates.findIndex((entry) => configureCandidateKey(entry) === key);
if (existingIndex >= 0) {
candidates[existingIndex] = createdCandidate;
} else {
candidates.push(createdCandidate);
}
continue;
}
if (selectedPath === "__toggle_derived__") {
showDerivedCandidates = !showDerivedCandidates;
continue;
}
const candidate = candidates.find((entry) => entry.path === selectedPath);
const candidate = visibleCandidates.find(
(entry) => configureCandidateKey(entry) === selectedPath,
);
if (!candidate) {
throw new Error(`Unknown configure target: ${selectedPath}`);
}
const candidateKey = configureCandidateKey(candidate);
const priorSelection = selectedByPath.get(candidateKey);
const existingRef = priorSelection?.ref ?? candidate.existingRef;
const sourceInitialValue =
existingRef && hasSourceChoice(sourceChoices, existingRef.source)
? existingRef.source
: undefined;
const source = assertNoCancel(
await select({
message: "Secret source",
options: sourceChoices,
initialValue: sourceInitialValue,
}),
"Secrets configure cancelled.",
) as SecretRefSource;
@@ -737,16 +881,18 @@ export async function runSecretsConfigureInteractive(
const defaultAlias = resolveDefaultSecretProviderAlias(stagedConfig, source, {
preferFirstProviderForSource: true,
});
const providerInitialValue =
existingRef?.source === source ? existingRef.provider : defaultAlias;
const provider = assertNoCancel(
await text({
message: "Provider alias",
initialValue: defaultAlias,
initialValue: providerInitialValue,
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return "Required";
}
if (!PROVIDER_ALIAS_PATTERN.test(trimmed)) {
if (!isValidSecretProviderAlias(trimmed)) {
return "Must match /^[a-z][a-z0-9_-]{0,63}$/";
}
return undefined;
@@ -754,24 +900,50 @@ export async function runSecretsConfigureInteractive(
}),
"Secrets configure cancelled.",
);
const providerAlias = String(provider).trim();
const suggestedIdFromExistingRef =
existingRef?.source === source ? existingRef.id : undefined;
let suggestedId = suggestedIdFromExistingRef;
if (!suggestedId && source === "env") {
suggestedId = resolveSuggestedEnvSecretId(candidate);
}
if (!suggestedId && source === "file") {
const configuredProvider = stagedConfig.secrets?.providers?.[providerAlias];
if (configuredProvider?.source === "file" && configuredProvider.mode === "singleValue") {
suggestedId = "value";
}
}
const id = assertNoCancel(
await text({
message: "Secret id",
initialValue: suggestedId,
validate: (value) => (String(value ?? "").trim().length > 0 ? undefined : "Required"),
}),
"Secrets configure cancelled.",
);
const ref: SecretRef = {
source,
provider: String(provider).trim(),
provider: providerAlias,
id: String(id).trim(),
};
const resolved = await resolveSecretRefValue(ref, {
config: stagedConfig,
env,
});
assertExpectedResolvedSecretValue({
value: resolved,
expected: candidate.expectedResolvedValue,
errorMessage:
candidate.expectedResolvedValue === "string"
? `Ref ${ref.source}:${ref.provider}:${ref.id} did not resolve to a non-empty string.`
: `Ref ${ref.source}:${ref.provider}:${ref.id} did not resolve to a supported value type.`,
});
const next = {
...candidate,
ref,
};
selectedByPath.set(candidate.path, next);
selectedByPath.set(candidateKey, next);
const addMore = assertNoCancel(
await confirm({
@@ -786,37 +958,14 @@ export async function runSecretsConfigureInteractive(
}
}
if (
selectedByPath.size === 0 &&
Object.keys(providerChanges.upserts).length === 0 &&
providerChanges.deletes.length === 0
) {
if (!hasConfigurePlanChanges({ selectedTargets: selectedByPath, providerChanges })) {
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 plan = buildSecretsConfigurePlan({
selectedTargets: selectedByPath,
providerChanges,
});
const preflight = await runSecretsApply({
plan,

View File

@@ -0,0 +1,61 @@
import { listSecretTargetRegistryEntries } from "./target-registry.js";
type CredentialMatrixEntry = {
id: string;
configFile: "openclaw.json" | "auth-profiles.json";
path: string;
refPath?: string;
when?: { type: "api_key" | "token" };
secretShape: "secret_input" | "sibling_ref";
optIn: true;
notes?: string;
};
export type SecretRefCredentialMatrixDocument = {
version: 1;
matrixId: "strictly-user-supplied-credentials";
pathSyntax: 'Dot path with "*" for map keys and "[]" for arrays.';
scope: "Credentials that are strictly user-supplied and not minted/rotated by OpenClaw runtime.";
excludedMutableOrRuntimeManaged: string[];
entries: CredentialMatrixEntry[];
};
const EXCLUDED_MUTABLE_OR_RUNTIME_MANAGED = [
"commands.ownerDisplaySecret",
"channels.matrix.accessToken",
"channels.matrix.accounts.*.accessToken",
"gateway.auth.token",
"hooks.token",
"hooks.gmail.pushToken",
"hooks.mappings[].sessionKey",
"auth-profiles.oauth.*",
"discord.threadBindings.*.webhookToken",
"whatsapp.creds.json",
];
export function buildSecretRefCredentialMatrix(): SecretRefCredentialMatrixDocument {
const entries: CredentialMatrixEntry[] = listSecretTargetRegistryEntries()
.map((entry) => ({
id: entry.id,
configFile: entry.configFile,
path: entry.pathPattern,
...(entry.refPathPattern ? { refPath: entry.refPathPattern } : {}),
...(entry.authProfileType ? { when: { type: entry.authProfileType } } : {}),
secretShape: entry.secretShape,
optIn: true as const,
...(entry.id.startsWith("channels.googlechat.")
? { notes: "Google Chat compatibility exception: sibling ref field remains canonical." }
: {}),
}))
.toSorted((a, b) => a.id.localeCompare(b.id));
return {
version: 1,
matrixId: "strictly-user-supplied-credentials",
pathSyntax: 'Dot path with "*" for map keys and "[]" for arrays.',
scope:
"Credentials that are strictly user-supplied and not minted/rotated by OpenClaw runtime.",
excludedMutableOrRuntimeManaged: [...EXCLUDED_MUTABLE_OR_RUNTIME_MANAGED],
entries,
};
}

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
deletePathStrict,
getPath,
setPathCreateStrict,
setPathExistingStrict,
} from "./path-utils.js";
function asConfig(value: unknown): OpenClawConfig {
return value as OpenClawConfig;
}
describe("secrets path utils", () => {
it("deletePathStrict compacts arrays via splice", () => {
const config = asConfig({});
setPathCreateStrict(config, ["agents", "list"], [{ id: "a" }, { id: "b" }, { id: "c" }]);
const changed = deletePathStrict(config, ["agents", "list", "1"]);
expect(changed).toBe(true);
expect(getPath(config, ["agents", "list"])).toEqual([{ id: "a" }, { id: "c" }]);
});
it("getPath returns undefined for invalid array path segment", () => {
const config = asConfig({
agents: {
list: [{ id: "a" }],
},
});
expect(getPath(config, ["agents", "list", "foo"])).toBeUndefined();
});
it("setPathExistingStrict throws when path does not already exist", () => {
const config = asConfig({
agents: {
list: [{ id: "a" }],
},
});
expect(() =>
setPathExistingStrict(
config,
["agents", "list", "0", "memorySearch", "remote", "apiKey"],
"x",
),
).toThrow(/Path segment does not exist/);
});
it("setPathExistingStrict updates an existing leaf", () => {
const config = asConfig({
talk: {
apiKey: "old",
},
});
const changed = setPathExistingStrict(config, ["talk", "apiKey"], "new");
expect(changed).toBe(true);
expect(getPath(config, ["talk", "apiKey"])).toBe("new");
});
it("setPathCreateStrict creates missing container segments", () => {
const config = asConfig({});
const changed = setPathCreateStrict(config, ["talk", "provider", "apiKey"], "x");
expect(changed).toBe(true);
expect(getPath(config, ["talk", "provider", "apiKey"])).toBe("x");
});
it("setPathCreateStrict leaves value unchanged when equal", () => {
const config = asConfig({
talk: {
apiKey: "same",
},
});
const changed = setPathCreateStrict(config, ["talk", "apiKey"], "same");
expect(changed).toBe(false);
expect(getPath(config, ["talk", "apiKey"])).toBe("same");
});
it("setPathExistingStrict fails when intermediate segment is missing", () => {
const config = asConfig({
agents: {
list: [{ id: "a" }],
},
});
expect(() =>
setPathExistingStrict(
config,
["agents", "list", "0", "memorySearch", "remote", "apiKey"],
"x",
),
).toThrow(/Path segment does not exist/);
});
});

204
src/secrets/path-utils.ts Normal file
View File

@@ -0,0 +1,204 @@
import { isDeepStrictEqual } from "node:util";
import type { OpenClawConfig } from "../config/config.js";
import { isRecord } from "./shared.js";
function isArrayIndexSegment(segment: string): boolean {
return /^\d+$/.test(segment);
}
function expectedContainer(nextSegment: string): "array" | "object" {
return isArrayIndexSegment(nextSegment) ? "array" : "object";
}
export function getPath(root: unknown, segments: string[]): unknown {
if (segments.length === 0) {
return undefined;
}
let cursor: unknown = root;
for (const segment of segments) {
if (Array.isArray(cursor)) {
if (!isArrayIndexSegment(segment)) {
return undefined;
}
cursor = cursor[Number.parseInt(segment, 10)];
continue;
}
if (!isRecord(cursor)) {
return undefined;
}
cursor = cursor[segment];
}
return cursor;
}
export function setPathCreateStrict(
root: OpenClawConfig,
segments: string[],
value: unknown,
): boolean {
if (segments.length === 0) {
throw new Error("Target path is empty.");
}
let cursor: unknown = root;
let changed = false;
for (let index = 0; index < segments.length - 1; index += 1) {
const segment = segments[index] ?? "";
const nextSegment = segments[index + 1] ?? "";
const needs = expectedContainer(nextSegment);
if (Array.isArray(cursor)) {
if (!isArrayIndexSegment(segment)) {
throw new Error(`Invalid array index segment "${segment}" at ${segments.join(".")}.`);
}
const arrayIndex = Number.parseInt(segment, 10);
const existing = cursor[arrayIndex];
if (existing === undefined || existing === null) {
cursor[arrayIndex] = needs === "array" ? [] : {};
changed = true;
} else if (needs === "array" ? !Array.isArray(existing) : !isRecord(existing)) {
throw new Error(`Invalid path shape at ${segments.slice(0, index + 1).join(".")}.`);
}
cursor = cursor[arrayIndex];
continue;
}
if (!isRecord(cursor)) {
throw new Error(`Invalid path shape at ${segments.slice(0, index).join(".") || "<root>"}.`);
}
const existing = cursor[segment];
if (existing === undefined || existing === null) {
cursor[segment] = needs === "array" ? [] : {};
changed = true;
} else if (needs === "array" ? !Array.isArray(existing) : !isRecord(existing)) {
throw new Error(`Invalid path shape at ${segments.slice(0, index + 1).join(".")}.`);
}
cursor = cursor[segment];
}
const leaf = segments[segments.length - 1] ?? "";
if (Array.isArray(cursor)) {
if (!isArrayIndexSegment(leaf)) {
throw new Error(`Invalid array index segment "${leaf}" at ${segments.join(".")}.`);
}
const arrayIndex = Number.parseInt(leaf, 10);
if (!isDeepStrictEqual(cursor[arrayIndex], value)) {
cursor[arrayIndex] = value;
changed = true;
}
return changed;
}
if (!isRecord(cursor)) {
throw new Error(`Invalid path shape at ${segments.slice(0, -1).join(".") || "<root>"}.`);
}
if (!isDeepStrictEqual(cursor[leaf], value)) {
cursor[leaf] = value;
changed = true;
}
return changed;
}
export function setPathExistingStrict(
root: OpenClawConfig,
segments: string[],
value: unknown,
): boolean {
if (segments.length === 0) {
throw new Error("Target path is empty.");
}
let cursor: unknown = root;
for (let index = 0; index < segments.length - 1; index += 1) {
const segment = segments[index] ?? "";
if (Array.isArray(cursor)) {
if (!isArrayIndexSegment(segment)) {
throw new Error(`Invalid array index segment "${segment}" at ${segments.join(".")}.`);
}
const arrayIndex = Number.parseInt(segment, 10);
if (arrayIndex < 0 || arrayIndex >= cursor.length) {
throw new Error(
`Path segment does not exist at ${segments.slice(0, index + 1).join(".")}.`,
);
}
cursor = cursor[arrayIndex];
continue;
}
if (!isRecord(cursor)) {
throw new Error(`Invalid path shape at ${segments.slice(0, index).join(".") || "<root>"}.`);
}
if (!Object.prototype.hasOwnProperty.call(cursor, segment)) {
throw new Error(`Path segment does not exist at ${segments.slice(0, index + 1).join(".")}.`);
}
cursor = cursor[segment];
}
const leaf = segments[segments.length - 1] ?? "";
if (Array.isArray(cursor)) {
if (!isArrayIndexSegment(leaf)) {
throw new Error(`Invalid array index segment "${leaf}" at ${segments.join(".")}.`);
}
const arrayIndex = Number.parseInt(leaf, 10);
if (arrayIndex < 0 || arrayIndex >= cursor.length) {
throw new Error(`Path segment does not exist at ${segments.join(".")}.`);
}
if (!isDeepStrictEqual(cursor[arrayIndex], value)) {
cursor[arrayIndex] = value;
return true;
}
return false;
}
if (!isRecord(cursor)) {
throw new Error(`Invalid path shape at ${segments.slice(0, -1).join(".") || "<root>"}.`);
}
if (!Object.prototype.hasOwnProperty.call(cursor, leaf)) {
throw new Error(`Path segment does not exist at ${segments.join(".")}.`);
}
if (!isDeepStrictEqual(cursor[leaf], value)) {
cursor[leaf] = value;
return true;
}
return false;
}
export function deletePathStrict(root: OpenClawConfig, segments: string[]): boolean {
if (segments.length === 0) {
throw new Error("Target path is empty.");
}
let cursor: unknown = root;
for (let index = 0; index < segments.length - 1; index += 1) {
const segment = segments[index] ?? "";
if (Array.isArray(cursor)) {
if (!isArrayIndexSegment(segment)) {
throw new Error(`Invalid array index segment "${segment}" at ${segments.join(".")}.`);
}
cursor = cursor[Number.parseInt(segment, 10)];
continue;
}
if (!isRecord(cursor)) {
throw new Error(`Invalid path shape at ${segments.slice(0, index).join(".") || "<root>"}.`);
}
cursor = cursor[segment];
}
const leaf = segments[segments.length - 1] ?? "";
if (Array.isArray(cursor)) {
if (!isArrayIndexSegment(leaf)) {
throw new Error(`Invalid array index segment "${leaf}" at ${segments.join(".")}.`);
}
const arrayIndex = Number.parseInt(leaf, 10);
if (arrayIndex < 0 || arrayIndex >= cursor.length) {
return false;
}
// Arrays are compacted to preserve predictable index semantics.
cursor.splice(arrayIndex, 1);
return true;
}
if (!isRecord(cursor)) {
throw new Error(`Invalid path shape at ${segments.slice(0, -1).join(".") || "<root>"}.`);
}
if (!Object.prototype.hasOwnProperty.call(cursor, leaf)) {
return false;
}
delete cursor[leaf];
return true;
}

85
src/secrets/plan.test.ts Normal file
View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { isSecretsApplyPlan, resolveValidatedPlanTarget } from "./plan.js";
describe("secrets plan validation", () => {
it("accepts legacy provider target types", () => {
const resolved = resolveValidatedPlanTarget({
type: "models.providers.apiKey",
path: "models.providers.openai.apiKey",
pathSegments: ["models", "providers", "openai", "apiKey"],
providerId: "openai",
});
expect(resolved?.pathSegments).toEqual(["models", "providers", "openai", "apiKey"]);
});
it("accepts expanded target types beyond legacy surface", () => {
const resolved = resolveValidatedPlanTarget({
type: "channels.telegram.botToken",
path: "channels.telegram.botToken",
pathSegments: ["channels", "telegram", "botToken"],
});
expect(resolved?.pathSegments).toEqual(["channels", "telegram", "botToken"]);
});
it("rejects target paths that do not match the registered shape", () => {
const resolved = resolveValidatedPlanTarget({
type: "channels.telegram.botToken",
path: "channels.telegram.webhookSecret",
pathSegments: ["channels", "telegram", "webhookSecret"],
});
expect(resolved).toBeNull();
});
it("validates plan files with non-legacy target types", () => {
const isValid = isSecretsApplyPlan({
version: 1,
protocolVersion: 1,
generatedAt: "2026-02-28T00:00:00.000Z",
generatedBy: "manual",
targets: [
{
type: "talk.apiKey",
path: "talk.apiKey",
pathSegments: ["talk", "apiKey"],
ref: { source: "env", provider: "default", id: "TALK_API_KEY" },
},
],
});
expect(isValid).toBe(true);
});
it("requires agentId for auth-profiles plan targets", () => {
const withoutAgent = isSecretsApplyPlan({
version: 1,
protocolVersion: 1,
generatedAt: "2026-02-28T00:00:00.000Z",
generatedBy: "manual",
targets: [
{
type: "auth-profiles.api_key.key",
path: "profiles.openai:default.key",
pathSegments: ["profiles", "openai:default", "key"],
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
],
});
expect(withoutAgent).toBe(false);
const withAgent = isSecretsApplyPlan({
version: 1,
protocolVersion: 1,
generatedAt: "2026-02-28T00:00:00.000Z",
generatedBy: "manual",
targets: [
{
type: "auth-profiles.api_key.key",
path: "profiles.openai:default.key",
pathSegments: ["profiles", "openai:default", "key"],
agentId: "main",
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
],
});
expect(withAgent).toBe(true);
});
});

View File

@@ -1,24 +1,36 @@
import type { SecretProviderConfig, SecretRef } from "../config/types.secrets.js";
import { SecretProviderSchema } from "../config/zod-schema.core.js";
import { isValidSecretProviderAlias } from "./ref-contract.js";
import { parseDotPath, toDotPath } from "./shared.js";
import {
isKnownSecretTargetType,
resolvePlanTargetAgainstRegistry,
type ResolvedPlanTarget,
} from "./target-registry.js";
export type SecretsPlanTargetType =
| "models.providers.apiKey"
| "skills.entries.apiKey"
| "channels.googlechat.serviceAccount";
export type SecretsPlanTargetType = string;
export type SecretsPlanTarget = {
type: SecretsPlanTargetType;
/**
* Dot path in openclaw.json for operator readability.
* Example: "models.providers.openai.apiKey"
* Dot path in the target config surface for operator readability.
* Examples:
* - "models.providers.openai.apiKey"
* - "profiles.openai.key"
*/
path: string;
/**
* Canonical path segments used for safe mutation.
* Example: ["models", "providers", "openai", "apiKey"]
* Examples:
* - ["models", "providers", "openai", "apiKey"]
* - ["profiles", "openai", "key"]
*/
pathSegments?: string[];
ref: SecretRef;
/**
* Required for auth-profiles targets so apply can resolve the correct agent store.
*/
agentId?: string;
/**
* For provider targets, used to scrub auth-profile/static residues.
*/
@@ -27,6 +39,10 @@ export type SecretsPlanTarget = {
* For googlechat account-scoped targets.
*/
accountId?: string;
/**
* Optional auth-profile provider value used when creating new auth profile mappings.
*/
authProfileProvider?: string;
};
export type SecretsApplyPlan = {
@@ -44,17 +60,8 @@ export type SecretsApplyPlan = {
};
};
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);
}
@@ -63,76 +70,20 @@ function isSecretProviderConfigShape(value: unknown): value is SecretProviderCon
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: {
export function resolveValidatedPlanTarget(candidate: {
type?: SecretsPlanTargetType;
path?: string;
pathSegments?: string[];
agentId?: string;
providerId?: string;
accountId?: string;
}): string[] | null {
if (!isSecretsPlanTargetType(candidate.type)) {
authProfileProvider?: string;
}): ResolvedPlanTarget | null {
if (!isKnownSecretTargetType(candidate.type)) {
return null;
}
const path = typeof candidate.path === "string" ? candidate.path.trim() : "";
@@ -143,22 +94,15 @@ export function resolveValidatedTargetPathSegments(candidate: {
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,
)
) {
if (segments.length === 0 || hasForbiddenPathSegment(segments) || path !== toDotPath(segments)) {
return null;
}
return segments;
return resolvePlanTargetAgainstRegistry({
type: candidate.type,
pathSegments: segments,
providerId: candidate.providerId,
accountId: candidate.accountId,
});
}
export function isSecretsApplyPlan(value: unknown): value is SecretsApplyPlan {
@@ -175,20 +119,21 @@ export function isSecretsApplyPlan(value: unknown): value is SecretsApplyPlan {
}
const candidate = target as Partial<SecretsPlanTarget>;
const ref = candidate.ref as Partial<SecretRef> | undefined;
const resolved = resolveValidatedPlanTarget({
type: candidate.type,
path: candidate.path,
pathSegments: candidate.pathSegments,
agentId: candidate.agentId,
providerId: candidate.providerId,
accountId: candidate.accountId,
authProfileProvider: candidate.authProfileProvider,
});
if (
(candidate.type !== "models.providers.apiKey" &&
candidate.type !== "skills.entries.apiKey" &&
candidate.type !== "channels.googlechat.serviceAccount") ||
!isKnownSecretTargetType(candidate.type) ||
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,
}) ||
!resolved ||
!ref ||
typeof ref !== "object" ||
(ref.source !== "env" && ref.source !== "file" && ref.source !== "exec") ||
@@ -199,13 +144,25 @@ export function isSecretsApplyPlan(value: unknown): value is SecretsApplyPlan {
) {
return false;
}
if (resolved.entry.configFile === "auth-profiles.json") {
if (typeof candidate.agentId !== "string" || candidate.agentId.trim().length === 0) {
return false;
}
if (
candidate.authProfileProvider !== undefined &&
(typeof candidate.authProfileProvider !== "string" ||
candidate.authProfileProvider.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)) {
if (!isValidSecretProviderAlias(providerAlias)) {
return false;
}
if (!isSecretProviderConfigShape(providerValue)) {
@@ -218,7 +175,7 @@ export function isSecretsApplyPlan(value: unknown): value is SecretsApplyPlan {
!Array.isArray(typed.providerDeletes) ||
typed.providerDeletes.some(
(providerAlias) =>
typeof providerAlias !== "string" || !PROVIDER_ALIAS_PATTERN.test(providerAlias),
typeof providerAlias !== "string" || !isValidSecretProviderAlias(providerAlias),
)
) {
return false;

View File

@@ -1,569 +0,0 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import type {
ExecSecretProviderConfig,
FileSecretProviderConfig,
SecretProviderConfig,
SecretRef,
} 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 { readJsonPointer } from "./json-pointer.js";
import { SINGLE_VALUE_FILE_REF_ID } from "./ref-contract.js";
import { isNonEmptyString, isRecord, normalizePositiveInt } from "./shared.js";
const DEFAULT_FILE_MAX_BYTES = 1024 * 1024;
const DEFAULT_FILE_TIMEOUT_MS = 5_000;
const DEFAULT_EXEC_TIMEOUT_MS = 5_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>>;
};
export type ResolutionLimits = {
maxProviderConcurrency: number;
maxRefsPerProvider: number;
maxBatchBytes: number;
};
export 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)
);
}
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";
};
function isIgnorableStdinWriteError(error: unknown): boolean {
if (typeof error !== "object" || error === null || !("code" in error)) {
return false;
}
const code = String(error.code);
return code === "EPIPE" || code === "ERR_STREAM_DESTROYED";
}
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",
});
});
const handleStdinError = (error: unknown) => {
if (isIgnorableStdinWriteError(error) || settled) {
return;
}
settled = true;
clearTimers();
reject(error instanceof Error ? error : new Error(String(error)));
};
child.stdin?.on("error", handleStdinError);
try {
child.stdin?.end(params.input);
} catch (error) {
handleStdinError(error);
}
});
}
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,
timeoutMs,
);
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;
}
export async function resolveProviderRefs(params: {
refs: SecretRef[];
providerName: string;
providerConfig: SecretProviderConfig;
env: NodeJS.ProcessEnv;
cache?: SecretRefResolveCache;
limits: ResolutionLimits;
}): Promise<ProviderResolutionOutput> {
if (params.providerConfig.source === "env") {
return await resolveEnvRefs({
refs: params.refs,
providerName: params.providerName,
providerConfig: params.providerConfig,
env: params.env,
});
}
if (params.providerConfig.source === "file") {
return await resolveFileRefs({
refs: params.refs,
providerName: params.providerName,
providerConfig: params.providerConfig,
cache: params.cache,
});
}
if (params.providerConfig.source === "exec") {
return await resolveExecRefs({
refs: params.refs,
providerName: params.providerName,
providerConfig: params.providerConfig,
env: params.env,
limits: params.limits,
});
}
throw new Error(
`Unsupported secret provider source "${String((params.providerConfig as { source?: unknown }).source)}".`,
);
}

View File

@@ -5,6 +5,7 @@ import {
} from "../config/types.secrets.js";
const FILE_SECRET_REF_SEGMENT_PATTERN = /^(?:[^~]|~0|~1)*$/;
export const SECRET_PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
export const SINGLE_VALUE_FILE_REF_ID = "value";
@@ -64,3 +65,7 @@ export function isValidFileSecretRefId(value: string): boolean {
.split("/")
.every((segment) => FILE_SECRET_REF_SEGMENT_PATTERN.test(segment));
}
export function isValidSecretProviderAlias(value: string): boolean {
return SECRET_PROVIDER_ALIAS_PATTERN.test(value);
}

View File

@@ -1,18 +1,45 @@
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 { SecretProviderConfig, SecretRef, SecretRefSource } from "../config/types.secrets.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 {
type ProviderResolutionOutput,
type ResolutionLimits,
resolveProviderRefs,
type SecretRefResolveCache,
} from "./provider-resolvers.js";
import { resolveDefaultSecretProviderAlias, secretRefKey } from "./ref-contract.js";
import { isNonEmptyString, normalizePositiveInt } from "./shared.js";
SINGLE_VALUE_FILE_REF_ID,
resolveDefaultSecretProviderAlias,
secretRefKey,
} from "./ref-contract.js";
import {
describeUnknownError,
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_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;
@@ -20,6 +47,94 @@ type ResolveSecretRefOptions = {
cache?: SecretRefResolveCache;
};
type ResolutionLimits = {
maxProviderConcurrency: number;
maxRefsPerProvider: number;
maxBatchBytes: number;
};
type ProviderResolutionOutput = Map<string, unknown>;
export class SecretProviderResolutionError extends Error {
readonly scope = "provider" as const;
readonly source: SecretRefSource;
readonly provider: string;
constructor(params: {
source: SecretRefSource;
provider: string;
message: string;
cause?: unknown;
}) {
super(params.message, params.cause !== undefined ? { cause: params.cause } : undefined);
this.name = "SecretProviderResolutionError";
this.source = params.source;
this.provider = params.provider;
}
}
export class SecretRefResolutionError extends Error {
readonly scope = "ref" as const;
readonly source: SecretRefSource;
readonly provider: string;
readonly refId: string;
constructor(params: {
source: SecretRefSource;
provider: string;
refId: string;
message: string;
cause?: unknown;
}) {
super(params.message, params.cause !== undefined ? { cause: params.cause } : undefined);
this.name = "SecretRefResolutionError";
this.source = params.source;
this.provider = params.provider;
this.refId = params.refId;
}
}
export function isProviderScopedSecretResolutionError(
value: unknown,
): value is SecretProviderResolutionError {
return value instanceof SecretProviderResolutionError;
}
function isSecretResolutionError(
value: unknown,
): value is SecretProviderResolutionError | SecretRefResolutionError {
return (
value instanceof SecretProviderResolutionError || value instanceof SecretRefResolutionError
);
}
function providerResolutionError(params: {
source: SecretRefSource;
provider: string;
message: string;
cause?: unknown;
}): SecretProviderResolutionError {
return new SecretProviderResolutionError(params);
}
function refResolutionError(params: {
source: SecretRefSource;
provider: string;
refId: string;
message: string;
cause?: unknown;
}): SecretRefResolutionError {
return new SecretRefResolutionError(params);
}
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 {
@@ -45,18 +160,680 @@ function resolveConfiguredProvider(ref: SecretRef, config: OpenClawConfig): Secr
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}).`,
);
throw providerResolutionError({
source: ref.source,
provider: ref.provider,
message: `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}".`,
);
throw providerResolutionError({
source: ref.source,
provider: ref.provider,
message: `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}. Set allowInsecurePath=true for this provider to bypass this check when the path is trusted.`,
);
}
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 refResolutionError({
source: "env",
provider: params.providerName,
refId: ref.id,
message: `Environment variable "${ref.id}" is not allowlisted in secrets.providers.${params.providerName}.allowlist.`,
});
}
const envValue = params.env[ref.id];
if (!isNonEmptyString(envValue)) {
throw refResolutionError({
source: "env",
provider: params.providerName,
refId: ref.id,
message: `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> {
let payload: unknown;
try {
payload = await readFileProviderPayload({
providerName: params.providerName,
providerConfig: params.providerConfig,
cache: params.cache,
});
} catch (err) {
if (isSecretResolutionError(err)) {
throw err;
}
throw providerResolutionError({
source: "file",
provider: params.providerName,
message: describeUnknownError(err),
cause: err,
});
}
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 refResolutionError({
source: "file",
provider: params.providerName,
refId: ref.id,
message: `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) {
try {
resolved.set(ref.id, readJsonPointer(payload, ref.id, { onMissing: "throw" }));
} catch (err) {
throw refResolutionError({
source: "file",
provider: params.providerName,
refId: ref.id,
message: describeUnknownError(err),
cause: err,
});
}
}
return resolved;
}
type ExecRunResult = {
stdout: string;
stderr: string;
code: number | null;
signal: NodeJS.Signals | null;
termination: "exit" | "timeout" | "no-output-timeout";
};
function isIgnorableStdinWriteError(error: unknown): boolean {
if (typeof error !== "object" || error === null || !("code" in error)) {
return false;
}
const code = String(error.code);
return code === "EPIPE" || code === "ERR_STREAM_DESTROYED";
}
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",
});
});
const handleStdinError = (error: unknown) => {
if (isIgnorableStdinWriteError(error) || settled) {
return;
}
settled = true;
clearTimers();
reject(error instanceof Error ? error : new Error(String(error)));
};
child.stdin?.on("error", handleStdinError);
try {
child.stdin?.end(params.input);
} catch (error) {
handleStdinError(error);
}
});
}
function parseExecValues(params: {
providerName: string;
ids: string[];
stdout: string;
jsonOnly: boolean;
}): Record<string, unknown> {
const trimmed = params.stdout.trim();
if (!trimmed) {
throw providerResolutionError({
source: "exec",
provider: params.providerName,
message: `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 providerResolutionError({
source: "exec",
provider: params.providerName,
message: `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 providerResolutionError({
source: "exec",
provider: params.providerName,
message: `Exec provider "${params.providerName}" response must be an object.`,
});
}
if (parsed.protocolVersion !== 1) {
throw providerResolutionError({
source: "exec",
provider: params.providerName,
message: `Exec provider "${params.providerName}" protocolVersion must be 1.`,
});
}
const responseValues = parsed.values;
if (!isRecord(responseValues)) {
throw providerResolutionError({
source: "exec",
provider: params.providerName,
message: `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 refResolutionError({
source: "exec",
provider: params.providerName,
refId: id,
message: `Exec provider "${params.providerName}" failed for id "${id}" (${entry.message.trim()}).`,
});
}
throw refResolutionError({
source: "exec",
provider: params.providerName,
refId: id,
message: `Exec provider "${params.providerName}" failed for id "${id}".`,
});
}
if (!(id in responseValues)) {
throw refResolutionError({
source: "exec",
provider: params.providerName,
refId: id,
message: `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 providerResolutionError({
source: "exec",
provider: params.providerName,
message: `Exec provider "${params.providerName}" exceeded maxRefsPerProvider (${params.limits.maxRefsPerProvider}).`,
});
}
const commandPath = resolveUserPath(params.providerConfig.command);
let secureCommandPath: string;
try {
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,
});
} catch (err) {
if (isSecretResolutionError(err)) {
throw err;
}
throw providerResolutionError({
source: "exec",
provider: params.providerName,
message: describeUnknownError(err),
cause: err,
});
}
const requestPayload = {
protocolVersion: 1,
provider: params.providerName,
ids,
};
const input = JSON.stringify(requestPayload);
if (Buffer.byteLength(input, "utf8") > params.limits.maxBatchBytes) {
throw providerResolutionError({
source: "exec",
provider: params.providerName,
message: `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];
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,
timeoutMs,
);
const maxOutputBytes = normalizePositiveInt(
params.providerConfig.maxOutputBytes,
DEFAULT_EXEC_MAX_OUTPUT_BYTES,
);
const jsonOnly = params.providerConfig.jsonOnly ?? true;
let result: ExecRunResult;
try {
result = await runExecResolver({
command: secureCommandPath,
args: params.providerConfig.args ?? [],
cwd: path.dirname(secureCommandPath),
env: childEnv,
input,
timeoutMs,
noOutputTimeoutMs,
maxOutputBytes,
});
} catch (err) {
if (isSecretResolutionError(err)) {
throw err;
}
throw providerResolutionError({
source: "exec",
provider: params.providerName,
message: describeUnknownError(err),
cause: err,
});
}
if (result.termination === "timeout") {
throw providerResolutionError({
source: "exec",
provider: params.providerName,
message: `Exec provider "${params.providerName}" timed out after ${timeoutMs}ms.`,
});
}
if (result.termination === "no-output-timeout") {
throw providerResolutionError({
source: "exec",
provider: params.providerName,
message: `Exec provider "${params.providerName}" produced no output for ${noOutputTimeoutMs}ms.`,
});
}
if (result.code !== 0) {
throw providerResolutionError({
source: "exec",
provider: params.providerName,
message: `Exec provider "${params.providerName}" exited with code ${String(result.code)}.`,
});
}
let values: Record<string, unknown>;
try {
values = parseExecValues({
providerName: params.providerName,
ids,
stdout: result.stdout,
jsonOnly,
});
} catch (err) {
if (isSecretResolutionError(err)) {
throw err;
}
throw providerResolutionError({
source: "exec",
provider: params.providerName,
message: describeUnknownError(err),
cause: err,
});
}
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> {
try {
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 providerResolutionError({
source: params.source,
provider: params.providerName,
message: `Unsupported secret provider source "${String((params.providerConfig as { source?: unknown }).source)}".`,
});
} catch (err) {
if (isSecretResolutionError(err)) {
throw err;
}
throw providerResolutionError({
source: params.source,
provider: params.providerName,
message: describeUnknownError(err),
cause: err,
});
}
}
export async function resolveSecretRefValues(
refs: SecretRef[],
options: ResolveSecretRefOptions,
@@ -88,21 +865,22 @@ export async function resolveSecretRefValues(
grouped.set(key, { source: ref.source, providerName: ref.provider, refs: [ref] });
}
const taskEnv = options.env ?? process.env;
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}).`,
);
throw providerResolutionError({
source: group.source,
provider: group.providerName,
message: `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,
env: taskEnv,
cache: options.cache,
options,
limits,
});
return { group, values };
@@ -122,9 +900,12 @@ export async function resolveSecretRefValues(
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}".`,
);
throw refResolutionError({
source: result.group.source,
provider: result.group.providerName,
refId: ref.id,
message: `Secret provider "${result.group.providerName}" did not return id "${ref.id}".`,
});
}
resolved.set(secretRefKey(ref), result.values.get(ref.id));
}
@@ -145,7 +926,12 @@ export async function resolveSecretRefValue(
const promise = (async () => {
const resolved = await resolveSecretRefValues([ref], options);
if (!resolved.has(key)) {
throw new Error(`Secret reference "${key}" resolved to no value.`);
throw refResolutionError({
source: ref.source,
provider: ref.provider,
refId: ref.id,
message: `Secret reference "${key}" resolved to no value.`,
});
}
return resolved.get(key);
})();
@@ -169,5 +955,3 @@ export async function resolveSecretRefString(
}
return resolved;
}
export type { SecretRefResolveCache };

View File

@@ -0,0 +1,128 @@
import type { AuthProfileCredential, AuthProfileStore } from "../agents/auth-profiles.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import {
pushAssignment,
pushWarning,
type ResolverContext,
type SecretDefaults,
} from "./runtime-shared.js";
import { isNonEmptyString } from "./shared.js";
type ApiKeyCredentialLike = AuthProfileCredential & {
type: "api_key";
key?: string;
keyRef?: unknown;
};
type TokenCredentialLike = AuthProfileCredential & {
type: "token";
token?: string;
tokenRef?: unknown;
};
function collectApiKeyProfileAssignment(params: {
profile: ApiKeyCredentialLike;
profileId: string;
agentDir: string;
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const {
explicitRef: keyRef,
inlineRef: inlineKeyRef,
ref: resolvedKeyRef,
} = resolveSecretInputRef({
value: params.profile.key,
refValue: params.profile.keyRef,
defaults: params.defaults,
});
if (!resolvedKeyRef) {
return;
}
if (!keyRef && inlineKeyRef) {
params.profile.keyRef = inlineKeyRef;
}
if (keyRef && isNonEmptyString(params.profile.key)) {
pushWarning(params.context, {
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 {
explicitRef: tokenRef,
inlineRef: inlineTokenRef,
ref: resolvedTokenRef,
} = resolveSecretInputRef({
value: params.profile.token,
refValue: params.profile.tokenRef,
defaults: params.defaults,
});
if (!resolvedTokenRef) {
return;
}
if (!tokenRef && inlineTokenRef) {
params.profile.tokenRef = inlineTokenRef;
}
if (tokenRef && isNonEmptyString(params.profile.token)) {
pushWarning(params.context, {
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);
},
});
}
export 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,
});
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,374 @@
import type { OpenClawConfig } from "../config/config.js";
import { collectTtsApiKeyAssignments } from "./runtime-config-collectors-tts.js";
import { evaluateGatewayAuthSurfaceStates } from "./runtime-gateway-auth-surfaces.js";
import {
collectSecretInputAssignment,
type ResolverContext,
type SecretDefaults,
} from "./runtime-shared.js";
import { isRecord } from "./shared.js";
type ProviderLike = {
apiKey?: unknown;
enabled?: unknown;
};
type SkillEntryLike = {
apiKey?: unknown;
enabled?: unknown;
};
function collectModelProviderAssignments(params: {
providers: Record<string, ProviderLike>;
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
for (const [providerId, provider] of Object.entries(params.providers)) {
collectSecretInputAssignment({
value: provider.apiKey,
path: `models.providers.${providerId}.apiKey`,
expected: "string",
defaults: params.defaults,
context: params.context,
active: provider.enabled !== false,
inactiveReason: "provider is disabled.",
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)) {
collectSecretInputAssignment({
value: entry.apiKey,
path: `skills.entries.${skillKey}.apiKey`,
expected: "string",
defaults: params.defaults,
context: params.context,
active: entry.enabled !== false,
inactiveReason: "skill entry is disabled.",
apply: (value) => {
entry.apiKey = value;
},
});
}
}
function collectAgentMemorySearchAssignments(params: {
config: OpenClawConfig;
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const agents = params.config.agents as Record<string, unknown> | undefined;
if (!isRecord(agents)) {
return;
}
const defaultsConfig = isRecord(agents.defaults) ? agents.defaults : undefined;
const defaultsMemorySearch = isRecord(defaultsConfig?.memorySearch)
? defaultsConfig.memorySearch
: undefined;
const defaultsEnabled = defaultsMemorySearch?.enabled !== false;
const list = Array.isArray(agents.list) ? agents.list : [];
let hasEnabledAgentWithoutOverride = false;
for (const rawAgent of list) {
if (!isRecord(rawAgent)) {
continue;
}
if (rawAgent.enabled === false) {
continue;
}
const memorySearch = isRecord(rawAgent.memorySearch) ? rawAgent.memorySearch : undefined;
if (memorySearch?.enabled === false) {
continue;
}
if (!memorySearch || !Object.prototype.hasOwnProperty.call(memorySearch, "remote")) {
hasEnabledAgentWithoutOverride = true;
continue;
}
const remote = isRecord(memorySearch.remote) ? memorySearch.remote : undefined;
if (!remote || !Object.prototype.hasOwnProperty.call(remote, "apiKey")) {
hasEnabledAgentWithoutOverride = true;
continue;
}
}
if (defaultsMemorySearch && isRecord(defaultsMemorySearch.remote)) {
const remote = defaultsMemorySearch.remote;
collectSecretInputAssignment({
value: remote.apiKey,
path: "agents.defaults.memorySearch.remote.apiKey",
expected: "string",
defaults: params.defaults,
context: params.context,
active: defaultsEnabled && (hasEnabledAgentWithoutOverride || list.length === 0),
inactiveReason: hasEnabledAgentWithoutOverride
? undefined
: "all enabled agents override memorySearch.remote.apiKey.",
apply: (value) => {
remote.apiKey = value;
},
});
}
list.forEach((rawAgent, index) => {
if (!isRecord(rawAgent)) {
return;
}
const memorySearch = isRecord(rawAgent.memorySearch) ? rawAgent.memorySearch : undefined;
if (!memorySearch) {
return;
}
const remote = isRecord(memorySearch.remote) ? memorySearch.remote : undefined;
if (!remote || !Object.prototype.hasOwnProperty.call(remote, "apiKey")) {
return;
}
const enabled = rawAgent.enabled !== false && memorySearch.enabled !== false;
collectSecretInputAssignment({
value: remote.apiKey,
path: `agents.list.${index}.memorySearch.remote.apiKey`,
expected: "string",
defaults: params.defaults,
context: params.context,
active: enabled,
inactiveReason: "agent or memorySearch override is disabled.",
apply: (value) => {
remote.apiKey = value;
},
});
});
}
function collectTalkAssignments(params: {
config: OpenClawConfig;
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const talk = params.config.talk as Record<string, unknown> | undefined;
if (!isRecord(talk)) {
return;
}
collectSecretInputAssignment({
value: talk.apiKey,
path: "talk.apiKey",
expected: "string",
defaults: params.defaults,
context: params.context,
apply: (value) => {
talk.apiKey = value;
},
});
const providers = talk.providers;
if (!isRecord(providers)) {
return;
}
for (const [providerId, providerConfig] of Object.entries(providers)) {
if (!isRecord(providerConfig)) {
continue;
}
collectSecretInputAssignment({
value: providerConfig.apiKey,
path: `talk.providers.${providerId}.apiKey`,
expected: "string",
defaults: params.defaults,
context: params.context,
apply: (value) => {
providerConfig.apiKey = value;
},
});
}
}
function collectGatewayAssignments(params: {
config: OpenClawConfig;
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const gateway = params.config.gateway as Record<string, unknown> | undefined;
if (!isRecord(gateway)) {
return;
}
const auth = isRecord(gateway.auth) ? gateway.auth : undefined;
const remote = isRecord(gateway.remote) ? gateway.remote : undefined;
const gatewaySurfaceStates = evaluateGatewayAuthSurfaceStates({
config: params.config,
env: params.context.env,
defaults: params.defaults,
});
if (auth) {
collectSecretInputAssignment({
value: auth.password,
path: "gateway.auth.password",
expected: "string",
defaults: params.defaults,
context: params.context,
active: gatewaySurfaceStates["gateway.auth.password"].active,
inactiveReason: gatewaySurfaceStates["gateway.auth.password"].reason,
apply: (value) => {
auth.password = value;
},
});
}
if (remote) {
collectSecretInputAssignment({
value: remote.token,
path: "gateway.remote.token",
expected: "string",
defaults: params.defaults,
context: params.context,
active: gatewaySurfaceStates["gateway.remote.token"].active,
inactiveReason: gatewaySurfaceStates["gateway.remote.token"].reason,
apply: (value) => {
remote.token = value;
},
});
collectSecretInputAssignment({
value: remote.password,
path: "gateway.remote.password",
expected: "string",
defaults: params.defaults,
context: params.context,
active: gatewaySurfaceStates["gateway.remote.password"].active,
inactiveReason: gatewaySurfaceStates["gateway.remote.password"].reason,
apply: (value) => {
remote.password = value;
},
});
}
}
function collectMessagesTtsAssignments(params: {
config: OpenClawConfig;
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const messages = params.config.messages as Record<string, unknown> | undefined;
if (!isRecord(messages) || !isRecord(messages.tts)) {
return;
}
collectTtsApiKeyAssignments({
tts: messages.tts,
pathPrefix: "messages.tts",
defaults: params.defaults,
context: params.context,
});
}
function collectToolsWebSearchAssignments(params: {
config: OpenClawConfig;
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const tools = params.config.tools as Record<string, unknown> | undefined;
if (!isRecord(tools) || !isRecord(tools.web) || !isRecord(tools.web.search)) {
return;
}
const search = tools.web.search;
const searchEnabled = search.enabled !== false;
const rawProvider =
typeof search.provider === "string" ? search.provider.trim().toLowerCase() : "";
const selectedProvider =
rawProvider === "brave" ||
rawProvider === "gemini" ||
rawProvider === "grok" ||
rawProvider === "kimi" ||
rawProvider === "perplexity"
? rawProvider
: undefined;
const paths = [
"apiKey",
"gemini.apiKey",
"grok.apiKey",
"kimi.apiKey",
"perplexity.apiKey",
] as const;
for (const path of paths) {
const [scope, field] = path.includes(".") ? path.split(".", 2) : [undefined, path];
const target = scope ? search[scope] : search;
if (!isRecord(target)) {
continue;
}
const active = scope
? searchEnabled && (selectedProvider === undefined || selectedProvider === scope)
: searchEnabled && (selectedProvider === undefined || selectedProvider === "brave");
const inactiveReason = !searchEnabled
? "tools.web.search is disabled."
: scope
? selectedProvider === undefined
? undefined
: `tools.web.search.provider is "${selectedProvider}".`
: selectedProvider === undefined
? undefined
: `tools.web.search.provider is "${selectedProvider}".`;
collectSecretInputAssignment({
value: target[field],
path: `tools.web.search.${path}`,
expected: "string",
defaults: params.defaults,
context: params.context,
active,
inactiveReason,
apply: (value) => {
target[field] = value;
},
});
}
}
function collectCronAssignments(params: {
config: OpenClawConfig;
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const cron = params.config.cron as Record<string, unknown> | undefined;
if (!isRecord(cron)) {
return;
}
collectSecretInputAssignment({
value: cron.webhookToken,
path: "cron.webhookToken",
expected: "string",
defaults: params.defaults,
context: params.context,
apply: (value) => {
cron.webhookToken = value;
},
});
}
export function collectCoreConfigAssignments(params: {
config: OpenClawConfig;
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const providers = params.config.models?.providers as Record<string, ProviderLike> | undefined;
if (providers) {
collectModelProviderAssignments({
providers,
defaults: params.defaults,
context: params.context,
});
}
const skillEntries = params.config.skills?.entries as Record<string, SkillEntryLike> | undefined;
if (skillEntries) {
collectSkillAssignments({
entries: skillEntries,
defaults: params.defaults,
context: params.context,
});
}
collectAgentMemorySearchAssignments(params);
collectTalkAssignments(params);
collectGatewayAssignments(params);
collectMessagesTtsAssignments(params);
collectToolsWebSearchAssignments(params);
collectCronAssignments(params);
}

View File

@@ -0,0 +1,46 @@
import {
collectSecretInputAssignment,
type ResolverContext,
type SecretDefaults,
} from "./runtime-shared.js";
import { isRecord } from "./shared.js";
export function collectTtsApiKeyAssignments(params: {
tts: Record<string, unknown>;
pathPrefix: string;
defaults: SecretDefaults | undefined;
context: ResolverContext;
active?: boolean;
inactiveReason?: string;
}): void {
const elevenlabs = params.tts.elevenlabs;
if (isRecord(elevenlabs)) {
collectSecretInputAssignment({
value: elevenlabs.apiKey,
path: `${params.pathPrefix}.elevenlabs.apiKey`,
expected: "string",
defaults: params.defaults,
context: params.context,
active: params.active,
inactiveReason: params.inactiveReason,
apply: (value) => {
elevenlabs.apiKey = value;
},
});
}
const openai = params.tts.openai;
if (isRecord(openai)) {
collectSecretInputAssignment({
value: openai.apiKey,
path: `${params.pathPrefix}.openai.apiKey`,
expected: "string",
defaults: params.defaults,
context: params.context,
active: params.active,
inactiveReason: params.inactiveReason,
apply: (value) => {
openai.apiKey = value;
},
});
}
}

View File

@@ -0,0 +1,23 @@
import type { OpenClawConfig } from "../config/config.js";
import { collectChannelConfigAssignments } from "./runtime-config-collectors-channels.js";
import { collectCoreConfigAssignments } from "./runtime-config-collectors-core.js";
import type { ResolverContext } from "./runtime-shared.js";
export function collectConfigAssignments(params: {
config: OpenClawConfig;
context: ResolverContext;
}): void {
const defaults = params.context.sourceConfig.secrets?.defaults;
collectCoreConfigAssignments({
config: params.config,
defaults,
context: params.context,
});
collectChannelConfigAssignments({
config: params.config,
defaults,
context: params.context,
});
}

View File

@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { evaluateGatewayAuthSurfaceStates } from "./runtime-gateway-auth-surfaces.js";
const EMPTY_ENV = {} as NodeJS.ProcessEnv;
function envRef(id: string) {
return { source: "env", provider: "default", id } as const;
}
function evaluate(config: OpenClawConfig, env: NodeJS.ProcessEnv = EMPTY_ENV) {
return evaluateGatewayAuthSurfaceStates({
config,
env,
});
}
describe("evaluateGatewayAuthSurfaceStates", () => {
it("marks gateway.auth.password active when password mode is explicit", () => {
const states = evaluate({
gateway: {
auth: {
mode: "password",
password: envRef("GW_AUTH_PASSWORD"),
},
},
} as OpenClawConfig);
expect(states["gateway.auth.password"]).toMatchObject({
hasSecretRef: true,
active: true,
reason: 'gateway.auth.mode is "password".',
});
});
it("marks gateway.auth.password inactive when env token is configured", () => {
const states = evaluate(
{
gateway: {
auth: {
password: envRef("GW_AUTH_PASSWORD"),
},
},
} as OpenClawConfig,
{ OPENCLAW_GATEWAY_TOKEN: "env-token" } as NodeJS.ProcessEnv,
);
expect(states["gateway.auth.password"]).toMatchObject({
hasSecretRef: true,
active: false,
reason: "gateway token env var is configured.",
});
});
it("marks gateway.remote.token active when remote token fallback is active", () => {
const states = evaluate({
gateway: {
mode: "local",
remote: {
enabled: true,
token: envRef("GW_REMOTE_TOKEN"),
},
},
} as OpenClawConfig);
expect(states["gateway.remote.token"]).toMatchObject({
hasSecretRef: true,
active: true,
reason: "local token auth can win and no env/auth token is configured.",
});
});
it("marks gateway.remote.token inactive when token auth cannot win", () => {
const states = evaluate({
gateway: {
auth: {
mode: "password",
},
remote: {
enabled: true,
token: envRef("GW_REMOTE_TOKEN"),
},
},
} as OpenClawConfig);
expect(states["gateway.remote.token"]).toMatchObject({
hasSecretRef: true,
active: false,
reason: 'token auth cannot win with gateway.auth.mode="password".',
});
});
it("marks gateway.remote.password active when remote url is configured", () => {
const states = evaluate({
gateway: {
remote: {
enabled: true,
url: "wss://gateway.example.com",
password: envRef("GW_REMOTE_PASSWORD"),
},
},
} as OpenClawConfig);
expect(states["gateway.remote.password"].hasSecretRef).toBe(true);
expect(states["gateway.remote.password"].active).toBe(true);
expect(states["gateway.remote.password"].reason).toContain("remote surface is active:");
expect(states["gateway.remote.password"].reason).toContain("gateway.remote.url is configured");
});
it("marks gateway.remote.password inactive when password auth cannot win", () => {
const states = evaluate({
gateway: {
auth: {
mode: "token",
},
remote: {
enabled: true,
password: envRef("GW_REMOTE_PASSWORD"),
},
},
} as OpenClawConfig);
expect(states["gateway.remote.password"]).toMatchObject({
hasSecretRef: true,
active: false,
reason: 'password auth cannot win with gateway.auth.mode="token".',
});
});
});

View File

@@ -0,0 +1,247 @@
import type { OpenClawConfig } from "../config/config.js";
import { coerceSecretRef, hasConfiguredSecretInput } from "../config/types.secrets.js";
import type { SecretDefaults } from "./runtime-shared.js";
import { isRecord } from "./shared.js";
const GATEWAY_TOKEN_ENV_KEYS = ["OPENCLAW_GATEWAY_TOKEN", "CLAWDBOT_GATEWAY_TOKEN"] as const;
const GATEWAY_PASSWORD_ENV_KEYS = [
"OPENCLAW_GATEWAY_PASSWORD",
"CLAWDBOT_GATEWAY_PASSWORD",
] as const;
export const GATEWAY_AUTH_SURFACE_PATHS = [
"gateway.auth.password",
"gateway.remote.token",
"gateway.remote.password",
] as const;
export type GatewayAuthSurfacePath = (typeof GATEWAY_AUTH_SURFACE_PATHS)[number];
export type GatewayAuthSurfaceState = {
path: GatewayAuthSurfacePath;
active: boolean;
reason: string;
hasSecretRef: boolean;
};
export type GatewayAuthSurfaceStateMap = Record<GatewayAuthSurfacePath, GatewayAuthSurfaceState>;
function readNonEmptyEnv(env: NodeJS.ProcessEnv, names: readonly string[]): string | undefined {
for (const name of names) {
const raw = env[name];
if (typeof raw !== "string") {
continue;
}
const trimmed = raw.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
return undefined;
}
function formatAuthMode(mode: string | undefined): string {
return mode ?? "unset";
}
function describeRemoteConfiguredSurface(parts: {
remoteMode: boolean;
remoteUrlConfigured: boolean;
tailscaleRemoteExposure: boolean;
}): string {
const reasons: string[] = [];
if (parts.remoteMode) {
reasons.push('gateway.mode is "remote"');
}
if (parts.remoteUrlConfigured) {
reasons.push("gateway.remote.url is configured");
}
if (parts.tailscaleRemoteExposure) {
reasons.push('gateway.tailscale.mode is "serve" or "funnel"');
}
return reasons.join("; ");
}
function createState(params: {
path: GatewayAuthSurfacePath;
active: boolean;
reason: string;
hasSecretRef: boolean;
}): GatewayAuthSurfaceState {
return {
path: params.path,
active: params.active,
reason: params.reason,
hasSecretRef: params.hasSecretRef,
};
}
export function evaluateGatewayAuthSurfaceStates(params: {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
defaults?: SecretDefaults;
}): GatewayAuthSurfaceStateMap {
const defaults = params.defaults ?? params.config.secrets?.defaults;
const gateway = params.config.gateway as Record<string, unknown> | undefined;
if (!isRecord(gateway)) {
return {
"gateway.auth.password": createState({
path: "gateway.auth.password",
active: false,
reason: "gateway configuration is not set.",
hasSecretRef: false,
}),
"gateway.remote.token": createState({
path: "gateway.remote.token",
active: false,
reason: "gateway configuration is not set.",
hasSecretRef: false,
}),
"gateway.remote.password": createState({
path: "gateway.remote.password",
active: false,
reason: "gateway configuration is not set.",
hasSecretRef: false,
}),
};
}
const auth = isRecord(gateway?.auth) ? gateway.auth : undefined;
const remote = isRecord(gateway?.remote) ? gateway.remote : undefined;
const authMode = auth && typeof auth.mode === "string" ? auth.mode : undefined;
const hasAuthPasswordRef = coerceSecretRef(auth?.password, defaults) !== null;
const hasRemoteTokenRef = coerceSecretRef(remote?.token, defaults) !== null;
const hasRemotePasswordRef = coerceSecretRef(remote?.password, defaults) !== null;
const envToken = readNonEmptyEnv(params.env, GATEWAY_TOKEN_ENV_KEYS);
const envPassword = readNonEmptyEnv(params.env, GATEWAY_PASSWORD_ENV_KEYS);
const localTokenConfigured = hasConfiguredSecretInput(auth?.token, defaults);
const localPasswordConfigured = hasConfiguredSecretInput(auth?.password, defaults);
const remoteTokenConfigured = hasConfiguredSecretInput(remote?.token, defaults);
const localTokenCanWin =
authMode !== "password" && authMode !== "none" && authMode !== "trusted-proxy";
const tokenCanWin = Boolean(envToken || localTokenConfigured || remoteTokenConfigured);
const passwordCanWin =
authMode === "password" ||
(authMode !== "token" && authMode !== "none" && authMode !== "trusted-proxy" && !tokenCanWin);
const remoteMode = gateway?.mode === "remote";
const remoteUrlConfigured = typeof remote?.url === "string" && remote.url.trim().length > 0;
const tailscale =
isRecord(gateway?.tailscale) && typeof gateway.tailscale.mode === "string"
? gateway.tailscale
: undefined;
const tailscaleRemoteExposure = tailscale?.mode === "serve" || tailscale?.mode === "funnel";
const remoteEnabled = remote?.enabled !== false;
const remoteConfiguredSurface = remoteMode || remoteUrlConfigured || tailscaleRemoteExposure;
const remoteTokenFallbackActive = localTokenCanWin && !envToken && !localTokenConfigured;
const remoteTokenActive = remoteEnabled && (remoteConfiguredSurface || remoteTokenFallbackActive);
const remotePasswordFallbackActive = !envPassword && !localPasswordConfigured && passwordCanWin;
const remotePasswordActive =
remoteEnabled && (remoteConfiguredSurface || remotePasswordFallbackActive);
const authPasswordReason = (() => {
if (!auth) {
return "gateway.auth is not configured.";
}
if (passwordCanWin) {
return authMode === "password"
? 'gateway.auth.mode is "password".'
: "no token source can win, so password auth can win.";
}
if (authMode === "token" || authMode === "none" || authMode === "trusted-proxy") {
return `gateway.auth.mode is "${authMode}".`;
}
if (envToken) {
return "gateway token env var is configured.";
}
if (localTokenConfigured) {
return "gateway.auth.token is configured.";
}
if (remoteTokenConfigured) {
return "gateway.remote.token is configured.";
}
return "token auth can win.";
})();
const remoteSurfaceReason = describeRemoteConfiguredSurface({
remoteMode,
remoteUrlConfigured,
tailscaleRemoteExposure,
});
const remoteTokenReason = (() => {
if (!remote) {
return "gateway.remote is not configured.";
}
if (!remoteEnabled) {
return "gateway.remote.enabled is false.";
}
if (remoteConfiguredSurface) {
return `remote surface is active: ${remoteSurfaceReason}.`;
}
if (remoteTokenFallbackActive) {
return "local token auth can win and no env/auth token is configured.";
}
if (!localTokenCanWin) {
return `token auth cannot win with gateway.auth.mode="${formatAuthMode(authMode)}".`;
}
if (envToken) {
return "gateway token env var is configured.";
}
if (localTokenConfigured) {
return "gateway.auth.token is configured.";
}
return "remote token fallback is not active.";
})();
const remotePasswordReason = (() => {
if (!remote) {
return "gateway.remote is not configured.";
}
if (!remoteEnabled) {
return "gateway.remote.enabled is false.";
}
if (remoteConfiguredSurface) {
return `remote surface is active: ${remoteSurfaceReason}.`;
}
if (remotePasswordFallbackActive) {
return "password auth can win and no env/auth password is configured.";
}
if (!passwordCanWin) {
if (authMode === "token" || authMode === "none" || authMode === "trusted-proxy") {
return `password auth cannot win with gateway.auth.mode="${authMode}".`;
}
return "a token source can win, so password auth cannot win.";
}
if (envPassword) {
return "gateway password env var is configured.";
}
if (localPasswordConfigured) {
return "gateway.auth.password is configured.";
}
return "remote password fallback is not active.";
})();
return {
"gateway.auth.password": createState({
path: "gateway.auth.password",
active: passwordCanWin,
reason: authPasswordReason,
hasSecretRef: hasAuthPasswordRef,
}),
"gateway.remote.token": createState({
path: "gateway.remote.token",
active: remoteTokenActive,
reason: remoteTokenReason,
hasSecretRef: hasRemoteTokenRef,
}),
"gateway.remote.password": createState({
path: "gateway.remote.password",
active: remotePasswordActive,
reason: remotePasswordReason,
hasSecretRef: hasRemotePasswordRef,
}),
};
}

View File

@@ -0,0 +1,146 @@
import type { OpenClawConfig } from "../config/config.js";
import { coerceSecretRef, type SecretRef } from "../config/types.secrets.js";
import { secretRefKey } from "./ref-contract.js";
import type { SecretRefResolveCache } from "./resolve.js";
import { assertExpectedResolvedSecretValue } from "./secret-value.js";
import { isRecord } from "./shared.js";
export type SecretResolverWarningCode =
| "SECRETS_REF_OVERRIDES_PLAINTEXT"
| "SECRETS_REF_IGNORED_INACTIVE_SURFACE";
export type SecretResolverWarning = {
code: SecretResolverWarningCode;
path: string;
message: string;
};
export type SecretAssignment = {
ref: SecretRef;
path: string;
expected: "string" | "string-or-object";
apply: (value: unknown) => void;
};
export type ResolverContext = {
sourceConfig: OpenClawConfig;
env: NodeJS.ProcessEnv;
cache: SecretRefResolveCache;
warnings: SecretResolverWarning[];
warningKeys: Set<string>;
assignments: SecretAssignment[];
};
export type SecretDefaults = NonNullable<OpenClawConfig["secrets"]>["defaults"];
export function createResolverContext(params: {
sourceConfig: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): ResolverContext {
return {
sourceConfig: params.sourceConfig,
env: params.env,
cache: {},
warnings: [],
warningKeys: new Set(),
assignments: [],
};
}
export function pushAssignment(context: ResolverContext, assignment: SecretAssignment): void {
context.assignments.push(assignment);
}
export function pushWarning(context: ResolverContext, warning: SecretResolverWarning): void {
const warningKey = `${warning.code}:${warning.path}:${warning.message}`;
if (context.warningKeys.has(warningKey)) {
return;
}
context.warningKeys.add(warningKey);
context.warnings.push(warning);
}
export function pushInactiveSurfaceWarning(params: {
context: ResolverContext;
path: string;
details?: string;
}): void {
pushWarning(params.context, {
code: "SECRETS_REF_IGNORED_INACTIVE_SURFACE",
path: params.path,
message:
params.details && params.details.trim().length > 0
? `${params.path}: ${params.details}`
: `${params.path}: secret ref is configured on an inactive surface; skipping resolution until it becomes active.`,
});
}
export function collectSecretInputAssignment(params: {
value: unknown;
path: string;
expected: SecretAssignment["expected"];
defaults: SecretDefaults | undefined;
context: ResolverContext;
active?: boolean;
inactiveReason?: string;
apply: (value: unknown) => void;
}): void {
const ref = coerceSecretRef(params.value, params.defaults);
if (!ref) {
return;
}
if (params.active === false) {
pushInactiveSurfaceWarning({
context: params.context,
path: params.path,
details: params.inactiveReason,
});
return;
}
pushAssignment(params.context, {
ref,
path: params.path,
expected: params.expected,
apply: params.apply,
});
}
export function applyResolvedAssignments(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);
assertExpectedResolvedSecretValue({
value,
expected: assignment.expected,
errorMessage:
assignment.expected === "string"
? `${assignment.path} resolved to a non-string or empty value.`
: `${assignment.path} resolved to an unsupported value type.`,
});
assignment.apply(value);
}
}
export function hasOwnProperty(record: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(record, key);
}
export function isEnabledFlag(value: unknown): boolean {
if (!isRecord(value)) {
return true;
}
return value.enabled !== false;
}
export function isChannelAccountEffectivelyEnabled(
channel: Record<string, unknown>,
account: Record<string, unknown>,
): boolean {
return isEnabledFlag(channel) && isEnabledFlag(account);
}

View File

@@ -0,0 +1,179 @@
import { afterEach, describe, expect, it } from "vitest";
import type { AuthProfileStore } from "../agents/auth-profiles.js";
import type { OpenClawConfig } from "../config/config.js";
import { getPath, setPathCreateStrict } from "./path-utils.js";
import { clearSecretsRuntimeSnapshot, prepareSecretsRuntimeSnapshot } from "./runtime.js";
import { listSecretTargetRegistryEntries } from "./target-registry.js";
type SecretRegistryEntry = ReturnType<typeof listSecretTargetRegistryEntries>[number];
function toConcretePathSegments(pathPattern: string): string[] {
const segments = pathPattern.split(".").filter(Boolean);
const out: string[] = [];
for (const segment of segments) {
if (segment === "*") {
out.push("sample");
continue;
}
if (segment.endsWith("[]")) {
out.push(segment.slice(0, -2), "0");
continue;
}
out.push(segment);
}
return out;
}
function buildConfigForOpenClawTarget(entry: SecretRegistryEntry, envId: string): OpenClawConfig {
const config = {} as OpenClawConfig;
const refTargetPath =
entry.secretShape === "sibling_ref" && entry.refPathPattern
? entry.refPathPattern
: entry.pathPattern;
setPathCreateStrict(config, toConcretePathSegments(refTargetPath), {
source: "env",
provider: "default",
id: envId,
});
if (entry.id === "gateway.auth.password") {
setPathCreateStrict(config, ["gateway", "auth", "mode"], "password");
}
if (entry.id === "gateway.remote.token" || entry.id === "gateway.remote.password") {
setPathCreateStrict(config, ["gateway", "mode"], "remote");
setPathCreateStrict(config, ["gateway", "remote", "url"], "wss://gateway.example");
}
if (entry.id === "channels.telegram.webhookSecret") {
setPathCreateStrict(config, ["channels", "telegram", "webhookUrl"], "https://example.com/hook");
}
if (entry.id === "channels.telegram.accounts.*.webhookSecret") {
setPathCreateStrict(
config,
["channels", "telegram", "accounts", "sample", "webhookUrl"],
"https://example.com/hook",
);
}
if (entry.id === "channels.slack.signingSecret") {
setPathCreateStrict(config, ["channels", "slack", "mode"], "http");
}
if (entry.id === "channels.slack.accounts.*.signingSecret") {
setPathCreateStrict(config, ["channels", "slack", "accounts", "sample", "mode"], "http");
}
if (entry.id === "channels.zalo.webhookSecret") {
setPathCreateStrict(config, ["channels", "zalo", "webhookUrl"], "https://example.com/hook");
}
if (entry.id === "channels.zalo.accounts.*.webhookSecret") {
setPathCreateStrict(
config,
["channels", "zalo", "accounts", "sample", "webhookUrl"],
"https://example.com/hook",
);
}
if (entry.id === "channels.feishu.verificationToken") {
setPathCreateStrict(config, ["channels", "feishu", "connectionMode"], "webhook");
}
if (entry.id === "channels.feishu.accounts.*.verificationToken") {
setPathCreateStrict(
config,
["channels", "feishu", "accounts", "sample", "connectionMode"],
"webhook",
);
}
if (entry.id === "tools.web.search.gemini.apiKey") {
setPathCreateStrict(config, ["tools", "web", "search", "provider"], "gemini");
}
if (entry.id === "tools.web.search.grok.apiKey") {
setPathCreateStrict(config, ["tools", "web", "search", "provider"], "grok");
}
if (entry.id === "tools.web.search.kimi.apiKey") {
setPathCreateStrict(config, ["tools", "web", "search", "provider"], "kimi");
}
if (entry.id === "tools.web.search.perplexity.apiKey") {
setPathCreateStrict(config, ["tools", "web", "search", "provider"], "perplexity");
}
return config;
}
function buildAuthStoreForTarget(entry: SecretRegistryEntry, envId: string): AuthProfileStore {
if (entry.authProfileType === "token") {
return {
version: 1 as const,
profiles: {
sample: {
type: "token" as const,
provider: "sample-provider",
token: "legacy-token",
tokenRef: {
source: "env" as const,
provider: "default",
id: envId,
},
},
},
};
}
return {
version: 1 as const,
profiles: {
sample: {
type: "api_key" as const,
provider: "sample-provider",
key: "legacy-key",
keyRef: {
source: "env" as const,
provider: "default",
id: envId,
},
},
},
};
}
describe("secrets runtime target coverage", () => {
afterEach(() => {
clearSecretsRuntimeSnapshot();
});
it("handles every openclaw.json registry target when configured as active", async () => {
const entries = listSecretTargetRegistryEntries().filter(
(entry) => entry.configFile === "openclaw.json",
);
for (const [index, entry] of entries.entries()) {
const envId = `OPENCLAW_SECRET_TARGET_${index}`;
const expectedValue = `resolved-${entry.id}`;
const snapshot = await prepareSecretsRuntimeSnapshot({
config: buildConfigForOpenClawTarget(entry, envId),
env: { [envId]: expectedValue },
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
});
const resolved = getPath(snapshot.config, toConcretePathSegments(entry.pathPattern));
if (entry.expectedResolvedValue === "string") {
expect(resolved).toBe(expectedValue);
} else {
expect(typeof resolved === "string" || (resolved && typeof resolved === "object")).toBe(
true,
);
}
}
});
it("handles every auth-profiles registry target", async () => {
const entries = listSecretTargetRegistryEntries().filter(
(entry) => entry.configFile === "auth-profiles.json",
);
for (const [index, entry] of entries.entries()) {
const envId = `OPENCLAW_AUTH_SECRET_TARGET_${index}`;
const expectedValue = `resolved-${entry.id}`;
const snapshot = await prepareSecretsRuntimeSnapshot({
config: {} as OpenClawConfig,
env: { [envId]: expectedValue },
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => buildAuthStoreForTarget(entry, envId),
});
const store = snapshot.authStores[0]?.store;
expect(store).toBeDefined();
const resolved = getPath(store, toConcretePathSegments(entry.pathPattern));
expect(resolved).toBe(expectedValue);
}
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
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 type { AuthProfileStore } from "../agents/auth-profiles.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
loadAuthProfileStoreForSecretsRuntime,
@@ -11,19 +11,21 @@ import {
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";
import {
collectCommandSecretAssignmentsFromSnapshot,
type CommandSecretAssignment,
} from "./command-config.js";
import { resolveSecretRefValues } from "./resolve.js";
import { collectAuthStoreAssignments } from "./runtime-auth-collectors.js";
import { collectConfigAssignments } from "./runtime-config-collectors.js";
import {
applyResolvedAssignments,
createResolverContext,
type SecretResolverWarning,
} from "./runtime-shared.js";
type SecretResolverWarningCode = "SECRETS_REF_OVERRIDES_PLAINTEXT";
export type SecretResolverWarning = {
code: SecretResolverWarningCode;
path: string;
message: string;
};
export type { SecretResolverWarning } from "./runtime-shared.js";
export type PreparedSecretsRuntimeSnapshot = {
sourceConfig: OpenClawConfig;
@@ -32,49 +34,6 @@ export type PreparedSecretsRuntimeSnapshot = {
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 {
@@ -89,266 +48,6 @@ function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecret
};
}
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 (inlineKeyRef && !keyRef) {
params.profile.keyRef = inlineKeyRef;
delete (params.profile as unknown as Record<string, unknown>).key;
}
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 (inlineTokenRef && !tokenRef) {
params.profile.tokenRef = inlineTokenRef;
delete (params.profile as unknown as Record<string, unknown>).token;
}
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()));
@@ -366,13 +65,10 @@ export async function prepareSecretsRuntimeSnapshot(params: {
}): Promise<PreparedSecretsRuntimeSnapshot> {
const sourceConfig = structuredClone(params.config);
const resolvedConfig = structuredClone(params.config);
const context: ResolverContext = {
const context = createResolverContext({
sourceConfig,
env: params.env ?? process.env,
cache: {},
warnings: [],
assignments: [],
};
});
collectConfigAssignments({
config: resolvedConfig,
@@ -402,7 +98,7 @@ export async function prepareSecretsRuntimeSnapshot(params: {
env: context.env,
cache: context.cache,
});
applyAssignments({
applyResolvedAssignments({
assignments: context.assignments,
resolved,
});
@@ -427,6 +123,37 @@ export function getActiveSecretsRuntimeSnapshot(): PreparedSecretsRuntimeSnapsho
return activeSnapshot ? cloneSnapshot(activeSnapshot) : null;
}
export function resolveCommandSecretsFromActiveRuntimeSnapshot(params: {
commandName: string;
targetIds: ReadonlySet<string>;
}): { assignments: CommandSecretAssignment[]; diagnostics: string[]; inactiveRefPaths: string[] } {
if (!activeSnapshot) {
throw new Error("Secrets runtime snapshot is not active.");
}
if (params.targetIds.size === 0) {
return { assignments: [], diagnostics: [], inactiveRefPaths: [] };
}
const inactiveRefPaths = [
...new Set(
activeSnapshot.warnings
.filter((warning) => warning.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE")
.map((warning) => warning.path),
),
];
const resolved = collectCommandSecretAssignmentsFromSnapshot({
sourceConfig: activeSnapshot.sourceConfig,
resolvedConfig: activeSnapshot.config,
commandName: params.commandName,
targetIds: params.targetIds,
inactiveRefPaths: new Set(inactiveRefPaths),
});
return {
assignments: resolved.assignments,
diagnostics: resolved.diagnostics,
inactiveRefPaths,
};
}
export function clearSecretsRuntimeSnapshot(): void {
activeSnapshot = null;
clearRuntimeConfigSnapshot();

View File

@@ -0,0 +1,33 @@
import { isNonEmptyString, isRecord } from "./shared.js";
export type SecretExpectedResolvedValue = "string" | "string-or-object";
export function isExpectedResolvedSecretValue(
value: unknown,
expected: SecretExpectedResolvedValue,
): boolean {
if (expected === "string") {
return isNonEmptyString(value);
}
return isNonEmptyString(value) || isRecord(value);
}
export function hasConfiguredPlaintextSecretValue(
value: unknown,
expected: SecretExpectedResolvedValue,
): boolean {
if (expected === "string") {
return isNonEmptyString(value);
}
return isNonEmptyString(value) || (isRecord(value) && Object.keys(value).length > 0);
}
export function assertExpectedResolvedSecretValue(params: {
value: unknown;
expected: SecretExpectedResolvedValue;
errorMessage: string;
}): void {
if (!isExpectedResolvedSecretValue(params.value, params.expected)) {
throw new Error(params.errorMessage);
}
}

View File

@@ -27,6 +27,17 @@ export function normalizePositiveInt(value: unknown, fallback: number): number {
return Math.max(1, Math.floor(fallback));
}
export function parseDotPath(pathname: string): string[] {
return pathname
.split(".")
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0);
}
export function toDotPath(segments: string[]): string {
return segments.join(".");
}
export function ensureDirForFile(filePath: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
}
@@ -51,3 +62,24 @@ export function writeTextFileAtomic(pathname: string, value: string, mode = 0o60
fs.chmodSync(tempPath, mode);
fs.renameSync(tempPath, pathname);
}
export 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;
}
if (typeof err === "number" || typeof err === "bigint") {
return err.toString();
}
if (typeof err === "boolean") {
return err ? "true" : "false";
}
try {
const serialized = JSON.stringify(err);
return serialized ?? "unknown error";
} catch {
return "unknown error";
}
}

View File

@@ -0,0 +1,87 @@
import fs from "node:fs";
import path from "node:path";
import { listAgentIds, resolveAgentDir } from "../agents/agent-scope.js";
import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveUserPath } from "../utils.js";
export function parseEnvAssignmentValue(raw: string): string {
const trimmed = raw.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}
export function listAuthProfileStorePaths(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 scans 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];
}
export function listLegacyAuthJsonPaths(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;
}
export function readJsonObjectIfExists(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 (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return { value: null };
}
return { value: parsed as Record<string, unknown> };
} catch (err) {
return {
value: null,
error: err instanceof Error ? err.message : String(err),
};
}
}

View File

@@ -0,0 +1,722 @@
import type { SecretTargetRegistryEntry } from "./target-registry-types.js";
const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [
{
id: "auth-profiles.api_key.key",
targetType: "auth-profiles.api_key.key",
configFile: "auth-profiles.json",
pathPattern: "profiles.*.key",
refPathPattern: "profiles.*.keyRef",
secretShape: "sibling_ref",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
authProfileType: "api_key",
},
{
id: "auth-profiles.token.token",
targetType: "auth-profiles.token.token",
configFile: "auth-profiles.json",
pathPattern: "profiles.*.token",
refPathPattern: "profiles.*.tokenRef",
secretShape: "sibling_ref",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
authProfileType: "token",
},
{
id: "agents.defaults.memorySearch.remote.apiKey",
targetType: "agents.defaults.memorySearch.remote.apiKey",
configFile: "openclaw.json",
pathPattern: "agents.defaults.memorySearch.remote.apiKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "agents.list[].memorySearch.remote.apiKey",
targetType: "agents.list[].memorySearch.remote.apiKey",
configFile: "openclaw.json",
pathPattern: "agents.list[].memorySearch.remote.apiKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.bluebubbles.accounts.*.password",
targetType: "channels.bluebubbles.accounts.*.password",
configFile: "openclaw.json",
pathPattern: "channels.bluebubbles.accounts.*.password",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.bluebubbles.password",
targetType: "channels.bluebubbles.password",
configFile: "openclaw.json",
pathPattern: "channels.bluebubbles.password",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.discord.accounts.*.pluralkit.token",
targetType: "channels.discord.accounts.*.pluralkit.token",
configFile: "openclaw.json",
pathPattern: "channels.discord.accounts.*.pluralkit.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.discord.accounts.*.token",
targetType: "channels.discord.accounts.*.token",
configFile: "openclaw.json",
pathPattern: "channels.discord.accounts.*.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.discord.accounts.*.voice.tts.elevenlabs.apiKey",
targetType: "channels.discord.accounts.*.voice.tts.elevenlabs.apiKey",
configFile: "openclaw.json",
pathPattern: "channels.discord.accounts.*.voice.tts.elevenlabs.apiKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.discord.accounts.*.voice.tts.openai.apiKey",
targetType: "channels.discord.accounts.*.voice.tts.openai.apiKey",
configFile: "openclaw.json",
pathPattern: "channels.discord.accounts.*.voice.tts.openai.apiKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.discord.pluralkit.token",
targetType: "channels.discord.pluralkit.token",
configFile: "openclaw.json",
pathPattern: "channels.discord.pluralkit.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.discord.token",
targetType: "channels.discord.token",
configFile: "openclaw.json",
pathPattern: "channels.discord.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.discord.voice.tts.elevenlabs.apiKey",
targetType: "channels.discord.voice.tts.elevenlabs.apiKey",
configFile: "openclaw.json",
pathPattern: "channels.discord.voice.tts.elevenlabs.apiKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.discord.voice.tts.openai.apiKey",
targetType: "channels.discord.voice.tts.openai.apiKey",
configFile: "openclaw.json",
pathPattern: "channels.discord.voice.tts.openai.apiKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.feishu.accounts.*.appSecret",
targetType: "channels.feishu.accounts.*.appSecret",
configFile: "openclaw.json",
pathPattern: "channels.feishu.accounts.*.appSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.feishu.accounts.*.verificationToken",
targetType: "channels.feishu.accounts.*.verificationToken",
configFile: "openclaw.json",
pathPattern: "channels.feishu.accounts.*.verificationToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.feishu.appSecret",
targetType: "channels.feishu.appSecret",
configFile: "openclaw.json",
pathPattern: "channels.feishu.appSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.feishu.verificationToken",
targetType: "channels.feishu.verificationToken",
configFile: "openclaw.json",
pathPattern: "channels.feishu.verificationToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.googlechat.accounts.*.serviceAccount",
targetType: "channels.googlechat.serviceAccount",
targetTypeAliases: ["channels.googlechat.accounts.*.serviceAccount"],
configFile: "openclaw.json",
pathPattern: "channels.googlechat.accounts.*.serviceAccount",
refPathPattern: "channels.googlechat.accounts.*.serviceAccountRef",
secretShape: "sibling_ref",
expectedResolvedValue: "string-or-object",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
accountIdPathSegmentIndex: 3,
},
{
id: "channels.googlechat.serviceAccount",
targetType: "channels.googlechat.serviceAccount",
configFile: "openclaw.json",
pathPattern: "channels.googlechat.serviceAccount",
refPathPattern: "channels.googlechat.serviceAccountRef",
secretShape: "sibling_ref",
expectedResolvedValue: "string-or-object",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.irc.accounts.*.nickserv.password",
targetType: "channels.irc.accounts.*.nickserv.password",
configFile: "openclaw.json",
pathPattern: "channels.irc.accounts.*.nickserv.password",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.irc.accounts.*.password",
targetType: "channels.irc.accounts.*.password",
configFile: "openclaw.json",
pathPattern: "channels.irc.accounts.*.password",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.irc.nickserv.password",
targetType: "channels.irc.nickserv.password",
configFile: "openclaw.json",
pathPattern: "channels.irc.nickserv.password",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.irc.password",
targetType: "channels.irc.password",
configFile: "openclaw.json",
pathPattern: "channels.irc.password",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.mattermost.accounts.*.botToken",
targetType: "channels.mattermost.accounts.*.botToken",
configFile: "openclaw.json",
pathPattern: "channels.mattermost.accounts.*.botToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.mattermost.botToken",
targetType: "channels.mattermost.botToken",
configFile: "openclaw.json",
pathPattern: "channels.mattermost.botToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.matrix.accounts.*.password",
targetType: "channels.matrix.accounts.*.password",
configFile: "openclaw.json",
pathPattern: "channels.matrix.accounts.*.password",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.matrix.password",
targetType: "channels.matrix.password",
configFile: "openclaw.json",
pathPattern: "channels.matrix.password",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.msteams.appPassword",
targetType: "channels.msteams.appPassword",
configFile: "openclaw.json",
pathPattern: "channels.msteams.appPassword",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.nextcloud-talk.accounts.*.apiPassword",
targetType: "channels.nextcloud-talk.accounts.*.apiPassword",
configFile: "openclaw.json",
pathPattern: "channels.nextcloud-talk.accounts.*.apiPassword",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.nextcloud-talk.accounts.*.botSecret",
targetType: "channels.nextcloud-talk.accounts.*.botSecret",
configFile: "openclaw.json",
pathPattern: "channels.nextcloud-talk.accounts.*.botSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.nextcloud-talk.apiPassword",
targetType: "channels.nextcloud-talk.apiPassword",
configFile: "openclaw.json",
pathPattern: "channels.nextcloud-talk.apiPassword",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.nextcloud-talk.botSecret",
targetType: "channels.nextcloud-talk.botSecret",
configFile: "openclaw.json",
pathPattern: "channels.nextcloud-talk.botSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.slack.accounts.*.appToken",
targetType: "channels.slack.accounts.*.appToken",
configFile: "openclaw.json",
pathPattern: "channels.slack.accounts.*.appToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.slack.accounts.*.botToken",
targetType: "channels.slack.accounts.*.botToken",
configFile: "openclaw.json",
pathPattern: "channels.slack.accounts.*.botToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.slack.accounts.*.signingSecret",
targetType: "channels.slack.accounts.*.signingSecret",
configFile: "openclaw.json",
pathPattern: "channels.slack.accounts.*.signingSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.slack.accounts.*.userToken",
targetType: "channels.slack.accounts.*.userToken",
configFile: "openclaw.json",
pathPattern: "channels.slack.accounts.*.userToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.slack.appToken",
targetType: "channels.slack.appToken",
configFile: "openclaw.json",
pathPattern: "channels.slack.appToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.slack.botToken",
targetType: "channels.slack.botToken",
configFile: "openclaw.json",
pathPattern: "channels.slack.botToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.slack.signingSecret",
targetType: "channels.slack.signingSecret",
configFile: "openclaw.json",
pathPattern: "channels.slack.signingSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.slack.userToken",
targetType: "channels.slack.userToken",
configFile: "openclaw.json",
pathPattern: "channels.slack.userToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.telegram.accounts.*.botToken",
targetType: "channels.telegram.accounts.*.botToken",
configFile: "openclaw.json",
pathPattern: "channels.telegram.accounts.*.botToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.telegram.accounts.*.webhookSecret",
targetType: "channels.telegram.accounts.*.webhookSecret",
configFile: "openclaw.json",
pathPattern: "channels.telegram.accounts.*.webhookSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.telegram.botToken",
targetType: "channels.telegram.botToken",
configFile: "openclaw.json",
pathPattern: "channels.telegram.botToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.telegram.webhookSecret",
targetType: "channels.telegram.webhookSecret",
configFile: "openclaw.json",
pathPattern: "channels.telegram.webhookSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.zalo.accounts.*.botToken",
targetType: "channels.zalo.accounts.*.botToken",
configFile: "openclaw.json",
pathPattern: "channels.zalo.accounts.*.botToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.zalo.accounts.*.webhookSecret",
targetType: "channels.zalo.accounts.*.webhookSecret",
configFile: "openclaw.json",
pathPattern: "channels.zalo.accounts.*.webhookSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.zalo.botToken",
targetType: "channels.zalo.botToken",
configFile: "openclaw.json",
pathPattern: "channels.zalo.botToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.zalo.webhookSecret",
targetType: "channels.zalo.webhookSecret",
configFile: "openclaw.json",
pathPattern: "channels.zalo.webhookSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "cron.webhookToken",
targetType: "cron.webhookToken",
configFile: "openclaw.json",
pathPattern: "cron.webhookToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "gateway.auth.password",
targetType: "gateway.auth.password",
configFile: "openclaw.json",
pathPattern: "gateway.auth.password",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "gateway.remote.password",
targetType: "gateway.remote.password",
configFile: "openclaw.json",
pathPattern: "gateway.remote.password",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "gateway.remote.token",
targetType: "gateway.remote.token",
configFile: "openclaw.json",
pathPattern: "gateway.remote.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "messages.tts.elevenlabs.apiKey",
targetType: "messages.tts.elevenlabs.apiKey",
configFile: "openclaw.json",
pathPattern: "messages.tts.elevenlabs.apiKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "messages.tts.openai.apiKey",
targetType: "messages.tts.openai.apiKey",
configFile: "openclaw.json",
pathPattern: "messages.tts.openai.apiKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "models.providers.*.apiKey",
targetType: "models.providers.apiKey",
targetTypeAliases: ["models.providers.*.apiKey"],
configFile: "openclaw.json",
pathPattern: "models.providers.*.apiKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
providerIdPathSegmentIndex: 2,
trackProviderShadowing: true,
},
{
id: "skills.entries.*.apiKey",
targetType: "skills.entries.apiKey",
targetTypeAliases: ["skills.entries.*.apiKey"],
configFile: "openclaw.json",
pathPattern: "skills.entries.*.apiKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "talk.apiKey",
targetType: "talk.apiKey",
configFile: "openclaw.json",
pathPattern: "talk.apiKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "talk.providers.*.apiKey",
targetType: "talk.providers.*.apiKey",
configFile: "openclaw.json",
pathPattern: "talk.providers.*.apiKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "tools.web.search.apiKey",
targetType: "tools.web.search.apiKey",
configFile: "openclaw.json",
pathPattern: "tools.web.search.apiKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "tools.web.search.gemini.apiKey",
targetType: "tools.web.search.gemini.apiKey",
configFile: "openclaw.json",
pathPattern: "tools.web.search.gemini.apiKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "tools.web.search.grok.apiKey",
targetType: "tools.web.search.grok.apiKey",
configFile: "openclaw.json",
pathPattern: "tools.web.search.grok.apiKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "tools.web.search.kimi.apiKey",
targetType: "tools.web.search.kimi.apiKey",
configFile: "openclaw.json",
pathPattern: "tools.web.search.kimi.apiKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "tools.web.search.perplexity.apiKey",
targetType: "tools.web.search.perplexity.apiKey",
configFile: "openclaw.json",
pathPattern: "tools.web.search.perplexity.apiKey",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
];
export { SECRET_TARGET_REGISTRY };

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import {
expandPathTokens,
matchPathTokens,
materializePathTokens,
parsePathPattern,
} from "./target-registry-pattern.js";
describe("target registry pattern helpers", () => {
it("matches wildcard and array tokens with stable capture ordering", () => {
const tokens = parsePathPattern("agents.list[].memorySearch.providers.*.apiKey");
const match = matchPathTokens(
["agents", "list", "2", "memorySearch", "providers", "openai", "apiKey"],
tokens,
);
expect(match).toEqual({
captures: ["2", "openai"],
});
expect(
matchPathTokens(
["agents", "list", "x", "memorySearch", "providers", "openai", "apiKey"],
tokens,
),
).toBeNull();
});
it("materializes sibling ref paths from wildcard and array captures", () => {
const refTokens = parsePathPattern("agents.list[].memorySearch.providers.*.apiKeyRef");
expect(materializePathTokens(refTokens, ["1", "anthropic"])).toEqual([
"agents",
"list",
"1",
"memorySearch",
"providers",
"anthropic",
"apiKeyRef",
]);
expect(materializePathTokens(refTokens, ["anthropic"])).toBeNull();
});
it("expands wildcard and array patterns over config objects", () => {
const root = {
agents: {
list: [
{ memorySearch: { remote: { apiKey: "a" } } },
{ memorySearch: { remote: { apiKey: "b" } } },
],
},
talk: {
providers: {
openai: { apiKey: "oa" },
anthropic: { apiKey: "an" },
},
},
};
const arrayMatches = expandPathTokens(
root,
parsePathPattern("agents.list[].memorySearch.remote.apiKey"),
);
expect(
arrayMatches.map((entry) => ({
segments: entry.segments.join("."),
captures: entry.captures,
value: entry.value,
})),
).toEqual([
{
segments: "agents.list.0.memorySearch.remote.apiKey",
captures: ["0"],
value: "a",
},
{
segments: "agents.list.1.memorySearch.remote.apiKey",
captures: ["1"],
value: "b",
},
]);
const wildcardMatches = expandPathTokens(root, parsePathPattern("talk.providers.*.apiKey"));
expect(
wildcardMatches
.map((entry) => ({
segments: entry.segments.join("."),
captures: entry.captures,
value: entry.value,
}))
.toSorted((left, right) => left.segments.localeCompare(right.segments)),
).toEqual([
{
segments: "talk.providers.anthropic.apiKey",
captures: ["anthropic"],
value: "an",
},
{
segments: "talk.providers.openai.apiKey",
captures: ["openai"],
value: "oa",
},
]);
});
});

View File

@@ -0,0 +1,213 @@
import { isRecord, parseDotPath } from "./shared.js";
import type { SecretTargetRegistryEntry } from "./target-registry-types.js";
export type PathPatternToken =
| { kind: "literal"; value: string }
| { kind: "wildcard" }
| { kind: "array"; field: string };
export type CompiledTargetRegistryEntry = SecretTargetRegistryEntry & {
pathTokens: PathPatternToken[];
pathDynamicTokenCount: number;
refPathTokens?: PathPatternToken[];
refPathDynamicTokenCount: number;
};
export type ExpandedPathMatch = {
segments: string[];
captures: string[];
value: unknown;
};
function countDynamicPatternTokens(tokens: PathPatternToken[]): number {
return tokens.filter((token) => token.kind === "wildcard" || token.kind === "array").length;
}
export function parsePathPattern(pathPattern: string): PathPatternToken[] {
const segments = parseDotPath(pathPattern);
return segments.map((segment) => {
if (segment === "*") {
return { kind: "wildcard" } as const;
}
if (segment.endsWith("[]")) {
const field = segment.slice(0, -2).trim();
if (!field) {
throw new Error(`Invalid target path pattern: ${pathPattern}`);
}
return { kind: "array", field } as const;
}
return { kind: "literal", value: segment } as const;
});
}
export function compileTargetRegistryEntry(
entry: SecretTargetRegistryEntry,
): CompiledTargetRegistryEntry {
const pathTokens = parsePathPattern(entry.pathPattern);
const pathDynamicTokenCount = countDynamicPatternTokens(pathTokens);
const refPathTokens = entry.refPathPattern ? parsePathPattern(entry.refPathPattern) : undefined;
const refPathDynamicTokenCount = refPathTokens ? countDynamicPatternTokens(refPathTokens) : 0;
if (entry.secretShape === "sibling_ref" && !refPathTokens) {
throw new Error(`Missing refPathPattern for sibling_ref target: ${entry.id}`);
}
if (refPathTokens && refPathDynamicTokenCount !== pathDynamicTokenCount) {
throw new Error(`Mismatched wildcard shape for target ref path: ${entry.id}`);
}
return {
...entry,
pathTokens,
pathDynamicTokenCount,
refPathTokens,
refPathDynamicTokenCount,
};
}
export function matchPathTokens(
segments: string[],
tokens: PathPatternToken[],
): {
captures: string[];
} | null {
const captures: string[] = [];
let index = 0;
for (const token of tokens) {
if (token.kind === "literal") {
if (segments[index] !== token.value) {
return null;
}
index += 1;
continue;
}
if (token.kind === "wildcard") {
const value = segments[index];
if (!value) {
return null;
}
captures.push(value);
index += 1;
continue;
}
if (segments[index] !== token.field) {
return null;
}
const next = segments[index + 1];
if (!next || !/^\d+$/.test(next)) {
return null;
}
captures.push(next);
index += 2;
}
return index === segments.length ? { captures } : null;
}
export function materializePathTokens(
tokens: PathPatternToken[],
captures: string[],
): string[] | null {
const out: string[] = [];
let captureIndex = 0;
for (const token of tokens) {
if (token.kind === "literal") {
out.push(token.value);
continue;
}
if (token.kind === "wildcard") {
const value = captures[captureIndex];
if (!value) {
return null;
}
out.push(value);
captureIndex += 1;
continue;
}
const arrayIndex = captures[captureIndex];
if (!arrayIndex || !/^\d+$/.test(arrayIndex)) {
return null;
}
out.push(token.field, arrayIndex);
captureIndex += 1;
}
return captureIndex === captures.length ? out : null;
}
export function expandPathTokens(root: unknown, tokens: PathPatternToken[]): ExpandedPathMatch[] {
const out: ExpandedPathMatch[] = [];
const walk = (
node: unknown,
tokenIndex: number,
segments: string[],
captures: string[],
): void => {
const token = tokens[tokenIndex];
if (!token) {
out.push({ segments, captures, value: node });
return;
}
const isLeaf = tokenIndex === tokens.length - 1;
if (token.kind === "literal") {
if (!isRecord(node)) {
return;
}
if (isLeaf) {
out.push({
segments: [...segments, token.value],
captures,
value: node[token.value],
});
return;
}
if (!Object.prototype.hasOwnProperty.call(node, token.value)) {
return;
}
walk(node[token.value], tokenIndex + 1, [...segments, token.value], captures);
return;
}
if (token.kind === "wildcard") {
if (!isRecord(node)) {
return;
}
for (const [key, value] of Object.entries(node)) {
if (isLeaf) {
out.push({
segments: [...segments, key],
captures: [...captures, key],
value,
});
continue;
}
walk(value, tokenIndex + 1, [...segments, key], [...captures, key]);
}
return;
}
if (!isRecord(node)) {
return;
}
const items = node[token.field];
if (!Array.isArray(items)) {
return;
}
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
const indexString = String(index);
if (isLeaf) {
out.push({
segments: [...segments, token.field, indexString],
captures: [...captures, indexString],
value: item,
});
continue;
}
walk(
item,
tokenIndex + 1,
[...segments, token.field, indexString],
[...captures, indexString],
);
}
};
walk(root, 0, [], []);
return out;
}

View File

@@ -0,0 +1,315 @@
import type { OpenClawConfig } from "../config/config.js";
import { getPath } from "./path-utils.js";
import { SECRET_TARGET_REGISTRY } from "./target-registry-data.js";
import {
compileTargetRegistryEntry,
expandPathTokens,
materializePathTokens,
matchPathTokens,
type CompiledTargetRegistryEntry,
} from "./target-registry-pattern.js";
import type {
DiscoveredConfigSecretTarget,
ResolvedPlanTarget,
SecretTargetRegistryEntry,
} from "./target-registry-types.js";
const COMPILED_SECRET_TARGET_REGISTRY = SECRET_TARGET_REGISTRY.map(compileTargetRegistryEntry);
const OPENCLAW_COMPILED_SECRET_TARGETS = COMPILED_SECRET_TARGET_REGISTRY.filter(
(entry) => entry.configFile === "openclaw.json",
);
const AUTH_PROFILES_COMPILED_SECRET_TARGETS = COMPILED_SECRET_TARGET_REGISTRY.filter(
(entry) => entry.configFile === "auth-profiles.json",
);
function buildTargetTypeIndex(): Map<string, CompiledTargetRegistryEntry[]> {
const byType = new Map<string, CompiledTargetRegistryEntry[]>();
const append = (type: string, entry: CompiledTargetRegistryEntry) => {
const existing = byType.get(type);
if (existing) {
existing.push(entry);
return;
}
byType.set(type, [entry]);
};
for (const entry of COMPILED_SECRET_TARGET_REGISTRY) {
append(entry.targetType, entry);
for (const alias of entry.targetTypeAliases ?? []) {
append(alias, entry);
}
}
return byType;
}
const TARGETS_BY_TYPE = buildTargetTypeIndex();
const KNOWN_TARGET_IDS = new Set(COMPILED_SECRET_TARGET_REGISTRY.map((entry) => entry.id));
function buildConfigTargetIdIndex(): Map<string, CompiledTargetRegistryEntry[]> {
const byId = new Map<string, CompiledTargetRegistryEntry[]>();
for (const entry of OPENCLAW_COMPILED_SECRET_TARGETS) {
const existing = byId.get(entry.id);
if (existing) {
existing.push(entry);
continue;
}
byId.set(entry.id, [entry]);
}
return byId;
}
const OPENCLAW_TARGETS_BY_ID = buildConfigTargetIdIndex();
function buildAuthProfileTargetIdIndex(): Map<string, CompiledTargetRegistryEntry[]> {
const byId = new Map<string, CompiledTargetRegistryEntry[]>();
for (const entry of AUTH_PROFILES_COMPILED_SECRET_TARGETS) {
const existing = byId.get(entry.id);
if (existing) {
existing.push(entry);
continue;
}
byId.set(entry.id, [entry]);
}
return byId;
}
const AUTH_PROFILES_TARGETS_BY_ID = buildAuthProfileTargetIdIndex();
function toResolvedPlanTarget(
entry: CompiledTargetRegistryEntry,
pathSegments: string[],
captures: string[],
): ResolvedPlanTarget | null {
const providerId =
entry.providerIdPathSegmentIndex !== undefined
? pathSegments[entry.providerIdPathSegmentIndex]
: undefined;
const accountId =
entry.accountIdPathSegmentIndex !== undefined
? pathSegments[entry.accountIdPathSegmentIndex]
: undefined;
const refPathSegments = entry.refPathTokens
? materializePathTokens(entry.refPathTokens, captures)
: undefined;
if (entry.refPathTokens && !refPathSegments) {
return null;
}
return {
entry,
pathSegments,
...(refPathSegments ? { refPathSegments } : {}),
...(providerId ? { providerId } : {}),
...(accountId ? { accountId } : {}),
};
}
export function listSecretTargetRegistryEntries(): SecretTargetRegistryEntry[] {
return COMPILED_SECRET_TARGET_REGISTRY.map((entry) => ({
id: entry.id,
targetType: entry.targetType,
...(entry.targetTypeAliases ? { targetTypeAliases: [...entry.targetTypeAliases] } : {}),
configFile: entry.configFile,
pathPattern: entry.pathPattern,
...(entry.refPathPattern ? { refPathPattern: entry.refPathPattern } : {}),
secretShape: entry.secretShape,
expectedResolvedValue: entry.expectedResolvedValue,
includeInPlan: entry.includeInPlan,
includeInConfigure: entry.includeInConfigure,
includeInAudit: entry.includeInAudit,
...(entry.providerIdPathSegmentIndex !== undefined
? { providerIdPathSegmentIndex: entry.providerIdPathSegmentIndex }
: {}),
...(entry.accountIdPathSegmentIndex !== undefined
? { accountIdPathSegmentIndex: entry.accountIdPathSegmentIndex }
: {}),
...(entry.authProfileType ? { authProfileType: entry.authProfileType } : {}),
...(entry.trackProviderShadowing ? { trackProviderShadowing: true } : {}),
}));
}
export function isKnownSecretTargetType(value: unknown): value is string {
return typeof value === "string" && TARGETS_BY_TYPE.has(value);
}
export function isKnownSecretTargetId(value: unknown): value is string {
return typeof value === "string" && KNOWN_TARGET_IDS.has(value);
}
export function resolvePlanTargetAgainstRegistry(candidate: {
type: string;
pathSegments: string[];
providerId?: string;
accountId?: string;
}): ResolvedPlanTarget | null {
const entries = TARGETS_BY_TYPE.get(candidate.type);
if (!entries || entries.length === 0) {
return null;
}
for (const entry of entries) {
if (!entry.includeInPlan) {
continue;
}
const matched = matchPathTokens(candidate.pathSegments, entry.pathTokens);
if (!matched) {
continue;
}
const resolved = toResolvedPlanTarget(entry, candidate.pathSegments, matched.captures);
if (!resolved) {
continue;
}
if (candidate.providerId && candidate.providerId.trim().length > 0) {
if (!resolved.providerId || resolved.providerId !== candidate.providerId) {
continue;
}
}
if (candidate.accountId && candidate.accountId.trim().length > 0) {
if (!resolved.accountId || resolved.accountId !== candidate.accountId) {
continue;
}
}
return resolved;
}
return null;
}
export function discoverConfigSecretTargets(
config: OpenClawConfig,
): DiscoveredConfigSecretTarget[] {
return discoverConfigSecretTargetsByIds(config);
}
export function discoverConfigSecretTargetsByIds(
config: OpenClawConfig,
targetIds?: Iterable<string>,
): DiscoveredConfigSecretTarget[] {
const allowedTargetIds =
targetIds === undefined
? null
: new Set(
Array.from(targetIds)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0),
);
const out: DiscoveredConfigSecretTarget[] = [];
const seen = new Set<string>();
const discoveryEntries =
allowedTargetIds === null
? OPENCLAW_COMPILED_SECRET_TARGETS
: Array.from(allowedTargetIds).flatMap(
(targetId) => OPENCLAW_TARGETS_BY_ID.get(targetId) ?? [],
);
for (const entry of discoveryEntries) {
const expanded = expandPathTokens(config, entry.pathTokens);
for (const match of expanded) {
const resolved = toResolvedPlanTarget(entry, match.segments, match.captures);
if (!resolved) {
continue;
}
const key = `${entry.id}:${resolved.pathSegments.join(".")}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
const refValue = resolved.refPathSegments
? getPath(config, resolved.refPathSegments)
: undefined;
out.push({
entry,
path: resolved.pathSegments.join("."),
pathSegments: resolved.pathSegments,
...(resolved.refPathSegments
? {
refPathSegments: resolved.refPathSegments,
refPath: resolved.refPathSegments.join("."),
}
: {}),
value: match.value,
...(resolved.providerId ? { providerId: resolved.providerId } : {}),
...(resolved.accountId ? { accountId: resolved.accountId } : {}),
...(resolved.refPathSegments ? { refValue } : {}),
});
}
}
return out;
}
export function discoverAuthProfileSecretTargets(store: unknown): DiscoveredConfigSecretTarget[] {
return discoverAuthProfileSecretTargetsByIds(store);
}
export function discoverAuthProfileSecretTargetsByIds(
store: unknown,
targetIds?: Iterable<string>,
): DiscoveredConfigSecretTarget[] {
const allowedTargetIds =
targetIds === undefined
? null
: new Set(
Array.from(targetIds)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0),
);
const out: DiscoveredConfigSecretTarget[] = [];
const seen = new Set<string>();
const discoveryEntries =
allowedTargetIds === null
? AUTH_PROFILES_COMPILED_SECRET_TARGETS
: Array.from(allowedTargetIds).flatMap(
(targetId) => AUTH_PROFILES_TARGETS_BY_ID.get(targetId) ?? [],
);
for (const entry of discoveryEntries) {
const expanded = expandPathTokens(store, entry.pathTokens);
for (const match of expanded) {
const resolved = toResolvedPlanTarget(entry, match.segments, match.captures);
if (!resolved) {
continue;
}
const key = `${entry.id}:${resolved.pathSegments.join(".")}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
const refValue = resolved.refPathSegments
? getPath(store, resolved.refPathSegments)
: undefined;
out.push({
entry,
path: resolved.pathSegments.join("."),
pathSegments: resolved.pathSegments,
...(resolved.refPathSegments
? {
refPathSegments: resolved.refPathSegments,
refPath: resolved.refPathSegments.join("."),
}
: {}),
value: match.value,
...(resolved.providerId ? { providerId: resolved.providerId } : {}),
...(resolved.accountId ? { accountId: resolved.accountId } : {}),
...(resolved.refPathSegments ? { refValue } : {}),
});
}
}
return out;
}
export function listAuthProfileSecretTargetEntries(): SecretTargetRegistryEntry[] {
return COMPILED_SECRET_TARGET_REGISTRY.filter(
(entry) => entry.configFile === "auth-profiles.json" && entry.includeInAudit,
);
}
export type {
AuthProfileType,
DiscoveredConfigSecretTarget,
ResolvedPlanTarget,
SecretTargetConfigFile,
SecretTargetExpected,
SecretTargetRegistryEntry,
SecretTargetShape,
} from "./target-registry-types.js";

View File

@@ -0,0 +1,42 @@
export type SecretTargetConfigFile = "openclaw.json" | "auth-profiles.json";
export type SecretTargetShape = "secret_input" | "sibling_ref";
export type SecretTargetExpected = "string" | "string-or-object";
export type AuthProfileType = "api_key" | "token";
export type SecretTargetRegistryEntry = {
id: string;
targetType: string;
targetTypeAliases?: string[];
configFile: SecretTargetConfigFile;
pathPattern: string;
refPathPattern?: string;
secretShape: SecretTargetShape;
expectedResolvedValue: SecretTargetExpected;
includeInPlan: boolean;
includeInConfigure: boolean;
includeInAudit: boolean;
providerIdPathSegmentIndex?: number;
accountIdPathSegmentIndex?: number;
authProfileType?: AuthProfileType;
trackProviderShadowing?: boolean;
};
export type ResolvedPlanTarget = {
entry: SecretTargetRegistryEntry;
pathSegments: string[];
refPathSegments?: string[];
providerId?: string;
accountId?: string;
};
export type DiscoveredConfigSecretTarget = {
entry: SecretTargetRegistryEntry;
path: string;
pathSegments: string[];
refPath?: string;
refPathSegments?: string[];
value: unknown;
refValue?: unknown;
providerId?: string;
accountId?: string;
};

View File

@@ -0,0 +1,99 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { buildSecretRefCredentialMatrix } from "./credential-matrix.js";
import { discoverConfigSecretTargetsByIds } from "./target-registry.js";
describe("secret target registry", () => {
it("stays in sync with docs/reference/secretref-user-supplied-credentials-matrix.json", () => {
const pathname = path.join(
process.cwd(),
"docs",
"reference",
"secretref-user-supplied-credentials-matrix.json",
);
const raw = fs.readFileSync(pathname, "utf8");
const parsed = JSON.parse(raw) as unknown;
expect(parsed).toEqual(buildSecretRefCredentialMatrix());
});
it("stays in sync with docs/reference/secretref-credential-surface.md", () => {
const matrixPath = path.join(
process.cwd(),
"docs",
"reference",
"secretref-user-supplied-credentials-matrix.json",
);
const matrixRaw = fs.readFileSync(matrixPath, "utf8");
const matrix = JSON.parse(matrixRaw) as ReturnType<typeof buildSecretRefCredentialMatrix>;
const surfacePath = path.join(
process.cwd(),
"docs",
"reference",
"secretref-credential-surface.md",
);
const surface = fs.readFileSync(surfacePath, "utf8");
const readMarkedCredentialList = (params: { start: string; end: string }): Set<string> => {
const startIndex = surface.indexOf(params.start);
const endIndex = surface.indexOf(params.end);
expect(startIndex).toBeGreaterThanOrEqual(0);
expect(endIndex).toBeGreaterThan(startIndex);
const block = surface.slice(startIndex + params.start.length, endIndex);
const credentials = new Set<string>();
for (const line of block.split(/\r?\n/)) {
const match = line.match(/^- `([^`]+)`/);
if (!match) {
continue;
}
const candidate = match[1];
if (!candidate.includes(".")) {
continue;
}
credentials.add(candidate);
}
return credentials;
};
const supportedFromDocs = readMarkedCredentialList({
start: "<!-- secretref-supported-list-start -->",
end: "<!-- secretref-supported-list-end -->",
});
const unsupportedFromDocs = readMarkedCredentialList({
start: "<!-- secretref-unsupported-list-start -->",
end: "<!-- secretref-unsupported-list-end -->",
});
const supportedFromMatrix = new Set(
matrix.entries.map((entry) =>
entry.configFile === "auth-profiles.json" && entry.refPath ? entry.refPath : entry.path,
),
);
const unsupportedFromMatrix = new Set(matrix.excludedMutableOrRuntimeManaged);
expect([...supportedFromDocs].toSorted()).toEqual([...supportedFromMatrix].toSorted());
expect([...unsupportedFromDocs].toSorted()).toEqual([...unsupportedFromMatrix].toSorted());
});
it("supports filtered discovery by target ids", () => {
const targets = discoverConfigSecretTargetsByIds(
{
talk: {
apiKey: { source: "env", provider: "default", id: "TALK_API_KEY" },
},
gateway: {
remote: {
token: { source: "env", provider: "default", id: "REMOTE_TOKEN" },
},
},
} as unknown as OpenClawConfig,
new Set(["talk.apiKey"]),
);
expect(targets).toHaveLength(1);
expect(targets[0]?.entry.id).toBe("talk.apiKey");
expect(targets[0]?.path).toBe("talk.apiKey");
});
});

View File

@@ -0,0 +1 @@
export * from "./target-registry-query.js";