mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 02:10:23 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
123
src/secrets/auth-profiles-scan.ts
Normal file
123
src/secrets/auth-profiles-scan.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
91
src/secrets/command-config.test.ts
Normal file
91
src/secrets/command-config.test.ts
Normal 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.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
67
src/secrets/command-config.ts
Normal file
67
src/secrets/command-config.ts
Normal 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 };
|
||||
}
|
||||
209
src/secrets/configure-plan.test.ts
Normal file
209
src/secrets/configure-plan.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
259
src/secrets/configure-plan.ts
Normal file
259
src/secrets/configure-plan.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
56
src/secrets/configure.test.ts
Normal file
56
src/secrets/configure.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
61
src/secrets/credential-matrix.ts
Normal file
61
src/secrets/credential-matrix.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
90
src/secrets/path-utils.test.ts
Normal file
90
src/secrets/path-utils.test.ts
Normal 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
204
src/secrets/path-utils.ts
Normal 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
85
src/secrets/plan.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}".`,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
128
src/secrets/runtime-auth-collectors.ts
Normal file
128
src/secrets/runtime-auth-collectors.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
1044
src/secrets/runtime-config-collectors-channels.ts
Normal file
1044
src/secrets/runtime-config-collectors-channels.ts
Normal file
File diff suppressed because it is too large
Load Diff
374
src/secrets/runtime-config-collectors-core.ts
Normal file
374
src/secrets/runtime-config-collectors-core.ts
Normal 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);
|
||||
}
|
||||
46
src/secrets/runtime-config-collectors-tts.ts
Normal file
46
src/secrets/runtime-config-collectors-tts.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
23
src/secrets/runtime-config-collectors.ts
Normal file
23
src/secrets/runtime-config-collectors.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
129
src/secrets/runtime-gateway-auth-surfaces.test.ts
Normal file
129
src/secrets/runtime-gateway-auth-surfaces.test.ts
Normal 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".',
|
||||
});
|
||||
});
|
||||
});
|
||||
247
src/secrets/runtime-gateway-auth-surfaces.ts
Normal file
247
src/secrets/runtime-gateway-auth-surfaces.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
146
src/secrets/runtime-shared.ts
Normal file
146
src/secrets/runtime-shared.ts
Normal 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);
|
||||
}
|
||||
179
src/secrets/runtime.coverage.test.ts
Normal file
179
src/secrets/runtime.coverage.test.ts
Normal 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
@@ -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();
|
||||
|
||||
33
src/secrets/secret-value.ts
Normal file
33
src/secrets/secret-value.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
87
src/secrets/storage-scan.ts
Normal file
87
src/secrets/storage-scan.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
722
src/secrets/target-registry-data.ts
Normal file
722
src/secrets/target-registry-data.ts
Normal 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 };
|
||||
103
src/secrets/target-registry-pattern.test.ts
Normal file
103
src/secrets/target-registry-pattern.test.ts
Normal 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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
213
src/secrets/target-registry-pattern.ts
Normal file
213
src/secrets/target-registry-pattern.ts
Normal 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;
|
||||
}
|
||||
315
src/secrets/target-registry-query.ts
Normal file
315
src/secrets/target-registry-query.ts
Normal 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";
|
||||
42
src/secrets/target-registry-types.ts
Normal file
42
src/secrets/target-registry-types.ts
Normal 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;
|
||||
};
|
||||
99
src/secrets/target-registry.test.ts
Normal file
99
src/secrets/target-registry.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
1
src/secrets/target-registry.ts
Normal file
1
src/secrets/target-registry.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./target-registry-query.js";
|
||||
Reference in New Issue
Block a user