Secrets: hard-fail unsupported SecretRef policy and fix gateway restart token drift (#58141)

* Secrets: enforce C2 SecretRef policy and drift resolution

* Tests: add gateway auth startup/reload SecretRef runtime coverage

* Docs: sync C2 SecretRef policy and coverage matrix

* Config: hard-fail parent SecretRef policy writes

* Secrets: centralize unsupported SecretRef policy metadata

* Daemon: test service-env precedence for token drift refs

* Config: keep per-ref dry-run resolvability errors

* Docs: clarify config-set parent-object policy checks

* Gateway: fix drift fallback and schema-key filtering

* Gateway: align drift fallback with credential planner

* changelog

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>

---------

Signed-off-by: joshavant <830519+joshavant@users.noreply.github.com>
This commit is contained in:
Josh Avant
2026-03-31 02:37:31 -05:00
committed by GitHub
parent 8d942000c9
commit 788f56f30f
27 changed files with 1244 additions and 56 deletions

View File

@@ -364,6 +364,26 @@ describe("resolveApiKeyForProfile secret refs", () => {
});
});
it("hard-fails when oauth mode is combined with token SecretRef input", async () => {
const profileId = "anthropic:oauth-secretref-token";
await expect(
resolveApiKeyForProfile({
cfg: cfgFor(profileId, "anthropic", "oauth"),
store: {
version: 1,
profiles: {
[profileId]: {
type: "token",
provider: "anthropic",
tokenRef: { source: "env", provider: "default", id: "ANTHROPIC_TOKEN" },
},
},
},
profileId,
}),
).rejects.toThrow(/mode is "oauth"/i);
});
it("resolves inline ${ENV} api_key values", async () => {
const profileId = "openai:inline-env";
const previous = process.env.OPENAI_API_KEY;

View File

@@ -17,6 +17,7 @@ import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js";
import { resolveTokenExpiryState } from "./credential-state.js";
import { formatAuthDoctorHint } from "./doctor.js";
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
import { assertNoOAuthSecretRefPolicyViolations } from "./policy.js";
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
import { ensureAuthProfileStore, saveAuthProfileStore } from "./store.js";
import type { AuthProfileStore, OAuthCredential } from "./types.js";
@@ -339,6 +340,12 @@ export async function resolveApiKeyForProfile(
const refResolveCache: SecretRefResolveCache = {};
const configForRefResolution = cfg ?? loadConfig();
const refDefaults = configForRefResolution.secrets?.defaults;
assertNoOAuthSecretRefPolicyViolations({
store,
cfg: configForRefResolution,
profileIds: [profileId],
context: `auth profile ${profileId}`,
});
if (cred.type === "api_key") {
const key = await resolveProfileSecretString({

View File

@@ -0,0 +1,142 @@
import type { OpenClawConfig } from "../../config/config.js";
import { coerceSecretRef, resolveSecretInputRef } from "../../config/types.secrets.js";
import type { AuthProfileCredential, AuthProfileStore } from "./types.js";
type SecretDefaults = NonNullable<OpenClawConfig["secrets"]>["defaults"];
type OAuthSecretRefPolicyViolation = {
profileId: string;
path: string;
reason: string;
};
function pushViolation(
violations: OAuthSecretRefPolicyViolation[],
profileId: string,
field: string,
reason: string,
): void {
violations.push({
profileId,
path: `profiles.${profileId}.${field}`,
reason,
});
}
function hasSecretRefInput(params: {
value: unknown;
refValue?: unknown;
defaults: SecretDefaults | undefined;
}): boolean {
return (
resolveSecretInputRef({
value: params.value,
refValue: params.refValue,
defaults: params.defaults,
}).ref !== null
);
}
function collectTypeOAuthSecretRefViolations(params: {
profileId: string;
credential: AuthProfileCredential;
defaults: SecretDefaults | undefined;
violations: OAuthSecretRefPolicyViolation[];
}): void {
if (params.credential.type !== "oauth") {
return;
}
const reason =
'SecretRef is not allowed for type="oauth" auth profiles (OAuth credentials are runtime-mutable).';
const record = params.credential as Record<string, unknown>;
for (const field of ["access", "refresh", "token", "tokenRef", "key", "keyRef"] as const) {
if (coerceSecretRef(record[field], params.defaults) === null) {
continue;
}
pushViolation(params.violations, params.profileId, field, reason);
}
}
function collectOAuthModeSecretRefViolations(params: {
profileId: string;
credential: AuthProfileCredential;
defaults: SecretDefaults | undefined;
configuredMode?: "api_key" | "oauth" | "token";
violations: OAuthSecretRefPolicyViolation[];
}): void {
if (params.configuredMode !== "oauth") {
return;
}
const reason =
`SecretRef is not allowed when auth.profiles.${params.profileId}.mode is "oauth" ` +
"(OAuth credentials are runtime-mutable).";
if (params.credential.type === "api_key") {
if (
hasSecretRefInput({
value: params.credential.key,
refValue: params.credential.keyRef,
defaults: params.defaults,
})
) {
pushViolation(params.violations, params.profileId, "key", reason);
}
return;
}
if (params.credential.type === "token") {
if (
hasSecretRefInput({
value: params.credential.token,
refValue: params.credential.tokenRef,
defaults: params.defaults,
})
) {
pushViolation(params.violations, params.profileId, "token", reason);
}
}
}
export function collectOAuthSecretRefPolicyViolations(params: {
store: AuthProfileStore;
cfg?: OpenClawConfig;
profileIds?: Iterable<string>;
}): OAuthSecretRefPolicyViolation[] {
const defaults = params.cfg?.secrets?.defaults;
const profileFilter = params.profileIds ? new Set(params.profileIds) : null;
const violations: OAuthSecretRefPolicyViolation[] = [];
for (const [profileId, credential] of Object.entries(params.store.profiles)) {
if (profileFilter && !profileFilter.has(profileId)) {
continue;
}
collectTypeOAuthSecretRefViolations({
profileId,
credential,
defaults,
violations,
});
collectOAuthModeSecretRefViolations({
profileId,
credential,
defaults,
configuredMode: params.cfg?.auth?.profiles?.[profileId]?.mode,
violations,
});
}
return violations;
}
export function assertNoOAuthSecretRefPolicyViolations(params: {
store: AuthProfileStore;
cfg?: OpenClawConfig;
profileIds?: Iterable<string>;
context?: string;
}): void {
const violations = collectOAuthSecretRefPolicyViolations(params);
if (violations.length === 0) {
return;
}
const lines = [
`${params.context ?? "auth-profiles"} policy validation failed: OAuth + SecretRef is not supported.`,
...violations.map((violation) => `- ${violation.path}: ${violation.reason}`),
];
throw new Error(lines.join("\n"));
}

View File

@@ -620,6 +620,56 @@ describe("config cli", () => {
});
});
it("fails early when unsupported mutable paths are assigned SecretRef objects (builder mode)", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
};
setSnapshot(resolved, resolved);
await expect(
runConfigCommand([
"config",
"set",
"hooks.token",
"--ref-provider",
"default",
"--ref-source",
"env",
"--ref-id",
"HOOK_TOKEN",
]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("Config policy validation failed: unsupported SecretRef usage"),
);
expect(mockError).toHaveBeenCalledWith(expect.stringContaining("hooks.token"));
});
it("fails early when parent-object writes include unsupported SecretRef objects", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
};
setSnapshot(resolved, resolved);
await expect(
runConfigCommand([
"config",
"set",
"hooks",
'{"token":{"source":"env","provider":"default","id":"HOOK_TOKEN"}}',
"--strict-json",
]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("Config policy validation failed: unsupported SecretRef usage"),
);
expect(mockError).toHaveBeenCalledWith(expect.stringContaining("hooks.token"));
});
it("supports provider builder mode under secrets.providers.<alias>", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
@@ -708,6 +758,93 @@ describe("config cli", () => {
);
});
it("fails dry-run when unsupported mutable paths receive SecretRef objects in value/json mode", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
default: { source: "env" },
},
},
};
setSnapshot(resolved, resolved);
await expect(
runConfigCommand([
"config",
"set",
"hooks.token",
'{"source":"env","provider":"default","id":"HOOK_TOKEN"}',
"--strict-json",
"--dry-run",
]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("Dry run failed: config schema validation failed."),
);
expect(mockError).toHaveBeenCalledWith(expect.stringContaining("hooks.token"));
});
it("aggregates policy failures across batch entries", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
};
setSnapshot(resolved, resolved);
await expect(
runConfigCommand([
"config",
"set",
"--batch-json",
'[{"path":"hooks.token","ref":{"source":"env","provider":"default","id":"HOOK_TOKEN"}},{"path":"commands.ownerDisplaySecret","ref":{"source":"env","provider":"default","id":"OWNER_DISPLAY_SECRET"}}]',
"--dry-run",
]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockError).toHaveBeenCalledWith(expect.stringContaining("hooks.token"));
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("commands.ownerDisplaySecret"),
);
});
it("does not duplicate policy errors in --dry-run --json mode for parent-object writes", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
};
setSnapshot(resolved, resolved);
await expect(
runConfigCommand([
"config",
"set",
"hooks",
'{"token":{"source":"env","provider":"default","id":"HOOK_TOKEN"}}',
"--strict-json",
"--dry-run",
"--json",
]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
const raw = mockLog.mock.calls.at(-1)?.[0];
expect(typeof raw).toBe("string");
const payload = JSON.parse(String(raw)) as {
ok: boolean;
checks: { schema: boolean; resolvability: boolean; resolvabilityComplete: boolean };
errors?: Array<{ kind: string; message: string; ref?: string }>;
};
expect(payload.ok).toBe(false);
expect(payload.checks.schema).toBe(true);
const hooksTokenErrors =
payload.errors?.filter(
(entry) => entry.kind === "schema" && entry.message.includes("hooks.token"),
) ?? [];
expect(hooksTokenErrors).toHaveLength(1);
});
it("logs a dry-run note when value mode performs no validation checks", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
@@ -1208,6 +1345,46 @@ describe("config cli", () => {
).toBe(true);
});
it("keeps distinct resolvability failures when messages are identical but refs differ", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
default: { source: "env" },
},
},
};
setSnapshot(resolved, resolved);
await expect(
runConfigCommand([
"config",
"set",
"--batch-json",
'[{"path":"channels.discord.token","ref":{"source":"exec","provider":"default","id":"DISCORD_BOT_TOKEN"}},{"path":"channels.telegram.botToken","ref":{"source":"exec","provider":"default","id":"TELEGRAM_BOT_TOKEN"}}]',
"--dry-run",
"--json",
]),
).rejects.toThrow("__exit__:1");
const raw = mockLog.mock.calls.at(-1)?.[0];
expect(typeof raw).toBe("string");
const payload = JSON.parse(String(raw)) as {
ok: boolean;
errors?: Array<{ kind: string; message: string; ref?: string }>;
};
expect(payload.ok).toBe(false);
const resolvabilityErrors =
payload.errors?.filter((entry) => entry.kind === "resolvability") ?? [];
expect(resolvabilityErrors).toHaveLength(2);
expect(
resolvabilityErrors.some((entry) => entry.ref === "exec:default:DISCORD_BOT_TOKEN"),
).toBe(true);
expect(
resolvabilityErrors.some((entry) => entry.ref === "exec:default:TELEGRAM_BOT_TOKEN"),
).toBe(true);
});
it("aggregates schema and resolvability failures in --dry-run --json mode", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },

View File

@@ -15,7 +15,10 @@ import {
type SecretRef,
type SecretRefSource,
} from "../config/types.secrets.js";
import { validateConfigObjectRaw } from "../config/validation.js";
import {
collectUnsupportedSecretRefPolicyIssues,
validateConfigObjectRaw,
} from "../config/validation.js";
import { SecretProviderSchema } from "../config/zod-schema.core.js";
import { danger, info, success } from "../globals.js";
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
@@ -90,6 +93,7 @@ const CONFIG_SET_DESCRIPTION = [
CONFIG_SET_EXAMPLE_PROVIDER,
CONFIG_SET_EXAMPLE_BATCH,
].join("\n");
const CONFIG_SET_POLICY_ERROR_MAX_ISSUES = 5;
class ConfigSetDryRunValidationError extends Error {
constructor(readonly result: ConfigSetDryRunResult) {
@@ -179,6 +183,17 @@ function formatDoctorHint(message: string): string {
return `Run \`${formatCliCommand("openclaw doctor")}\` ${message}`;
}
function formatUnsupportedSecretRefPolicyFailureMessage(issues: string[]): string {
const lines = [
"Config policy validation failed: unsupported SecretRef usage was detected.",
...issues.slice(0, CONFIG_SET_POLICY_ERROR_MAX_ISSUES).map((issue) => `- ${issue}`),
];
if (issues.length > CONFIG_SET_POLICY_ERROR_MAX_ISSUES) {
lines.push(`- ... ${issues.length - CONFIG_SET_POLICY_ERROR_MAX_ISSUES} more`);
}
return lines.join("\n");
}
function validatePathSegments(path: PathSegment[]): void {
for (const segment of path) {
if (!isIndexSegment(segment) && isBlockedObjectKey(segment)) {
@@ -911,6 +926,23 @@ function collectDryRunSchemaErrors(config: OpenClawConfig): ConfigSetDryRunError
}));
}
function dedupeDryRunErrors(errors: ConfigSetDryRunError[]): ConfigSetDryRunError[] {
const deduped: ConfigSetDryRunError[] = [];
const seen = new Set<string>();
for (const error of errors) {
const key =
error.kind === "resolvability"
? `${error.kind}\u0000${error.ref ?? ""}\u0000${error.message}`
: `${error.kind}\u0000${error.message}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
deduped.push(error);
}
return deduped;
}
function formatDryRunFailureMessage(params: {
errors: ConfigSetDryRunError[];
skippedExecRefs: number;
@@ -992,6 +1024,10 @@ export async function runConfigSet(opts: {
operations,
});
const nextConfig = next as OpenClawConfig;
const policyIssues = collectUnsupportedSecretRefPolicyIssues(nextConfig);
const policyIssueLines = formatConfigIssueLines(policyIssues, "", { normalizeRoot: true }).map(
(line) => line.trim(),
);
if (opts.cliOptions.dryRun) {
const hasJsonMode = operations.some((operation) => operation.inputMode === "json");
@@ -1008,6 +1044,14 @@ export async function runConfigSet(opts: {
allowExecInDryRun: Boolean(opts.cliOptions.allowExec),
});
const errors: ConfigSetDryRunError[] = [];
if (!hasJsonMode && policyIssueLines.length > 0) {
errors.push(
...policyIssueLines.map((message) => ({
kind: "schema" as const,
message,
})),
);
}
if (hasJsonMode) {
errors.push(...collectDryRunSchemaErrors(nextConfig));
}
@@ -1025,28 +1069,29 @@ export async function runConfigSet(opts: {
})),
);
}
const dedupedErrors = dedupeDryRunErrors(errors);
const dryRunResult: ConfigSetDryRunResult = {
ok: errors.length === 0,
ok: dedupedErrors.length === 0,
operations: operations.length,
configPath: shortenHomePath(snapshot.path),
inputModes: [...new Set(operations.map((operation) => operation.inputMode))],
checks: {
schema: hasJsonMode,
schema: hasJsonMode || policyIssueLines.length > 0,
resolvability: hasJsonMode || hasBuilderMode,
resolvabilityComplete:
(hasJsonMode || hasBuilderMode) && selectedDryRunRefs.skippedExecRefs.length === 0,
},
refsChecked: selectedDryRunRefs.refsToResolve.length,
skippedExecRefs: selectedDryRunRefs.skippedExecRefs.length,
...(errors.length > 0 ? { errors } : {}),
...(dedupedErrors.length > 0 ? { errors: dedupedErrors } : {}),
};
if (errors.length > 0) {
if (dedupedErrors.length > 0) {
if (opts.cliOptions.json) {
throw new ConfigSetDryRunValidationError(dryRunResult);
}
throw new Error(
formatDryRunFailureMessage({
errors,
errors: dedupedErrors,
skippedExecRefs: selectedDryRunRefs.skippedExecRefs.length,
}),
);
@@ -1076,6 +1121,9 @@ export async function runConfigSet(opts: {
}
return;
}
if (policyIssueLines.length > 0) {
throw new Error(formatUnsupportedSecretRefPolicyFailureMessage(policyIssueLines));
}
await replaceConfigFile({
nextConfig: next,

View File

@@ -3,8 +3,8 @@ import type { OpenClawConfig } from "../../config/config.js";
import { resolveGatewayTokenForDriftCheck } from "./gateway-token-drift.js";
describe("resolveGatewayTokenForDriftCheck", () => {
it("prefers persisted config token over shell env", () => {
const token = resolveGatewayTokenForDriftCheck({
it("prefers persisted config token over shell env", async () => {
const token = await resolveGatewayTokenForDriftCheck({
cfg: {
gateway: {
mode: "local",
@@ -21,31 +21,32 @@ describe("resolveGatewayTokenForDriftCheck", () => {
expect(token).toBe("config-token");
});
it("does not fall back to caller env for unresolved config token refs", () => {
expect(() =>
resolveGatewayTokenForDriftCheck({
cfg: {
secrets: {
providers: {
default: { source: "env" },
},
it("resolves env-backed local gateway token refs from the provided env", async () => {
const token = await resolveGatewayTokenForDriftCheck({
cfg: {
secrets: {
providers: {
default: { source: "env" },
},
gateway: {
mode: "local",
auth: {
token: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" },
},
},
gateway: {
mode: "local",
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "SERVICE_GATEWAY_TOKEN" },
},
} as OpenClawConfig,
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
} as NodeJS.ProcessEnv,
}),
).toThrow(/gateway\.auth\.token/i);
},
} as OpenClawConfig,
env: {
SERVICE_GATEWAY_TOKEN: "service-token",
} as NodeJS.ProcessEnv,
});
expect(token).toBe("service-token");
});
it("does not fall back to gateway.remote token for unresolved local token refs", () => {
expect(() =>
it("throws when an active local token ref is unresolved", async () => {
await expect(
resolveGatewayTokenForDriftCheck({
cfg: {
secrets: {
@@ -66,6 +67,65 @@ describe("resolveGatewayTokenForDriftCheck", () => {
} as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
}),
).toThrow(/gateway\.auth\.token/i);
).rejects.toThrow(/gateway\.auth\.token/i);
});
it("returns undefined when token auth is disabled by mode", async () => {
const token = await resolveGatewayTokenForDriftCheck({
cfg: {
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
auth: {
mode: "password",
token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" },
},
},
} as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
});
expect(token).toBeUndefined();
});
it("returns undefined when password fallback is active with mode unset and no token candidate", async () => {
const token = await resolveGatewayTokenForDriftCheck({
cfg: {
gateway: {
auth: {
password: "config-password",
},
},
} as OpenClawConfig,
env: {
OPENCLAW_GATEWAY_PASSWORD: "env-password",
} as NodeJS.ProcessEnv,
});
expect(token).toBeUndefined();
});
it("does not skip token resolution when mode is unset and token can win", async () => {
await expect(
resolveGatewayTokenForDriftCheck({
cfg: {
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
auth: {
token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" },
},
},
} as OpenClawConfig,
env: {
OPENCLAW_GATEWAY_PASSWORD: "env-password",
} as NodeJS.ProcessEnv,
}),
).rejects.toThrow(/gateway\.auth\.token/i);
});
});

View File

@@ -1,10 +1,58 @@
import type { OpenClawConfig } from "../../config/config.js";
import { resolveGatewayDriftCheckCredentialsFromConfig } from "../../gateway/credentials.js";
import { resolveSecretInputRef } from "../../config/types.secrets.js";
import { createGatewayCredentialPlan, trimToUndefined } from "../../gateway/credential-planner.js";
import { GatewaySecretRefUnavailableError } from "../../gateway/credentials.js";
import { resolveConfiguredSecretInputString } from "../../gateway/resolve-configured-secret-input-string.js";
export function resolveGatewayTokenForDriftCheck(params: {
function authModeDisablesToken(mode: string | undefined): boolean {
return mode === "password" || mode === "none" || mode === "trusted-proxy";
}
function isPasswordFallbackActive(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): boolean {
const plan = createGatewayCredentialPlan({
config: params.cfg,
env: params.env,
});
if (plan.authMode !== undefined) {
return false;
}
return plan.passwordCanWin && !plan.tokenCanWin;
}
export async function resolveGatewayTokenForDriftCheck(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}) {
void params.env;
return resolveGatewayDriftCheckCredentialsFromConfig({ cfg: params.cfg }).token;
}): Promise<string | undefined> {
const env = params.env ?? process.env;
const mode = params.cfg.gateway?.auth?.mode;
if (authModeDisablesToken(mode)) {
return undefined;
}
if (isPasswordFallbackActive({ cfg: params.cfg, env })) {
return undefined;
}
const tokenInput = params.cfg.gateway?.auth?.token;
const tokenRef = resolveSecretInputRef({
value: tokenInput,
defaults: params.cfg.secrets?.defaults,
}).ref;
if (!tokenRef) {
return trimToUndefined(tokenInput);
}
const resolved = await resolveConfiguredSecretInputString({
config: params.cfg,
env,
value: tokenInput,
path: "gateway.auth.token",
unresolvedReasonStyle: "detailed",
});
if (resolved.value) {
return resolved.value;
}
throw new GatewaySecretRefUnavailableError("gateway.auth.token");
}

View File

@@ -1,4 +1,5 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import {
defaultRuntime,
resetLifecycleRuntimeLogs,
@@ -8,7 +9,7 @@ import {
stubEmptyGatewayEnv,
} from "./test-helpers/lifecycle-core-harness.js";
const loadConfig = vi.fn(() => ({
const loadConfig = vi.fn<() => OpenClawConfig>(() => ({
gateway: {
auth: {
token: "config-token",
@@ -119,6 +120,71 @@ describe("runServiceRestart token drift", () => {
);
});
it("resolves config token SecretRefs using service command env before drift checks", async () => {
loadConfig.mockReturnValue({
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
auth: {
mode: "token",
token: {
source: "env",
provider: "default",
id: "SERVICE_GATEWAY_TOKEN",
},
},
},
});
service.readCommand.mockResolvedValue({
programArguments: [],
environment: {
OPENCLAW_GATEWAY_TOKEN: "service-token",
SERVICE_GATEWAY_TOKEN: "service-token",
},
});
await runServiceRestart(createServiceRunArgs(true));
const payload = readJsonLog<{ warnings?: string[] }>();
expect(payload.warnings).toBeUndefined();
});
it("prefers service command env over process env for SecretRef token drift resolution", async () => {
loadConfig.mockReturnValue({
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
auth: {
mode: "token",
token: {
source: "env",
provider: "default",
id: "SERVICE_GATEWAY_TOKEN",
},
},
},
});
service.readCommand.mockResolvedValue({
programArguments: [],
environment: {
OPENCLAW_GATEWAY_TOKEN: "service-token",
SERVICE_GATEWAY_TOKEN: "service-token",
},
});
vi.stubEnv("SERVICE_GATEWAY_TOKEN", "process-token");
await runServiceRestart(createServiceRunArgs(true));
const payload = readJsonLog<{ warnings?: string[] }>();
expect(payload.warnings).toBeUndefined();
});
it("skips drift warning when disabled", async () => {
await runServiceRestart({
serviceNoun: "Node",

View File

@@ -400,7 +400,11 @@ export async function runServiceRestart(params: {
const command = await params.service.readCommand(process.env);
const serviceToken = command?.environment?.OPENCLAW_GATEWAY_TOKEN;
const cfg = await readBestEffortConfig();
const configToken = resolveGatewayTokenForDriftCheck({ cfg, env: process.env });
const driftEnv = {
...process.env,
...command?.environment,
};
const configToken = await resolveGatewayTokenForDriftCheck({ cfg, env: driftEnv });
const driftIssue = checkTokenDrift({ serviceToken, configToken });
if (driftIssue) {
const warning = driftIssue.detail

View File

@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import { validateConfigObjectRaw } from "./validation.js";
describe("config validation SecretRef policy guards", () => {
it("surfaces a policy error for hooks.token SecretRef objects", () => {
const result = validateConfigObjectRaw({
hooks: {
token: {
source: "env",
provider: "default",
id: "HOOK_TOKEN",
},
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
const issue = result.issues.find((entry) => entry.path === "hooks.token");
expect(issue).toBeDefined();
expect(issue?.message).toContain("SecretRef objects are not supported at hooks.token");
expect(issue?.message).toContain(
"https://docs.openclaw.ai/reference/secretref-credential-surface",
);
expect(
result.issues.some(
(entry) =>
entry.path === "hooks.token" &&
entry.message.includes("Invalid input: expected string, received object"),
),
).toBe(false);
}
});
it("keeps standard schema errors for non-SecretRef objects", () => {
const result = validateConfigObjectRaw({
hooks: {
token: {
unexpected: "value",
},
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
const issue = result.issues.find((entry) => entry.path === "hooks.token");
expect(issue).toBeDefined();
expect(issue?.message).toBe("Invalid input: expected string, received object");
}
});
it("allows env-template strings on unsupported mutable paths", () => {
const result = validateConfigObjectRaw({
hooks: {
token: "${HOOK_TOKEN}",
},
});
expect(result.ok).toBe(true);
});
it("replaces derived unrecognized-key errors with policy guidance for discord thread binding webhookToken", () => {
const result = validateConfigObjectRaw({
channels: {
discord: {
threadBindings: {
webhookToken: {
source: "env",
provider: "default",
id: "DISCORD_THREAD_BINDING_WEBHOOK_TOKEN",
},
},
},
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
const policyIssue = result.issues.find(
(entry) => entry.path === "channels.discord.threadBindings.webhookToken",
);
expect(policyIssue).toBeDefined();
expect(policyIssue?.message).toContain(
"SecretRef objects are not supported at channels.discord.threadBindings.webhookToken",
);
expect(
result.issues.some(
(entry) =>
entry.path === "channels.discord.threadBindings" &&
entry.message.includes('Unrecognized key: "webhookToken"'),
),
).toBe(false);
}
});
it("preserves unrelated unknown-key errors when policy and typos coexist", () => {
const result = validateConfigObjectRaw({
channels: {
discord: {
threadBindings: {
webhookToken: {
source: "env",
provider: "default",
id: "DISCORD_THREAD_BINDING_WEBHOOK_TOKEN",
},
webhookTokne: "typo",
},
},
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(
result.issues.some(
(entry) =>
entry.path === "channels.discord.threadBindings.webhookToken" &&
entry.message.includes("SecretRef objects are not supported"),
),
).toBe(true);
expect(
result.issues.some(
(entry) =>
entry.path === "channels.discord.threadBindings" &&
entry.message.includes("webhookTokne"),
),
).toBe(true);
}
});
});

View File

@@ -11,6 +11,7 @@ import {
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
import { hasKind } from "../plugins/slots.js";
import { collectUnsupportedSecretRefConfigCandidates } from "../secrets/unsupported-surface-policy.js";
import {
hasAvatarUriScheme,
isAvatarDataUrl,
@@ -31,6 +32,7 @@ import {
import { findLegacyConfigIssues } from "./legacy.js";
import { materializeRuntimeConfig } from "./materialize.js";
import type { OpenClawConfig, ConfigValidationIssue } from "./types.js";
import { coerceSecretRef } from "./types.secrets.js";
import { OpenClawSchema } from "./zod-schema.js";
const LEGACY_REMOVED_PLUGIN_IDS = new Set(["google-antigravity-auth", "google-gemini-cli-auth"]);
@@ -45,6 +47,7 @@ type AllowedValuesCollection = {
type JsonSchemaLike = Record<string, unknown>;
const CUSTOM_EXPECTED_ONE_OF_RE = /expected one of ((?:"[^"]+"(?:\|"?[^"]+"?)*)+)/i;
const SECRETREF_POLICY_DOC_URL = "https://docs.openclaw.ai/reference/secretref-credential-surface";
const bundledChannelSchemaById = new Map<string, unknown>(
GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA.map(
(entry) => [entry.channelId, entry.schema] as const,
@@ -256,6 +259,94 @@ function collectAllowedValuesFromUnknownIssue(issue: unknown): unknown[] {
return collection.values;
}
function isObjectSecretRefCandidate(value: unknown): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
return coerceSecretRef(value) !== null;
}
function formatUnsupportedMutableSecretRefMessage(path: string): string {
return [
`SecretRef objects are not supported at ${path}.`,
"This credential is runtime-mutable or runtime-managed and must stay a plain string value.",
'Use a plain string (env template strings like "${MY_VAR}" are allowed).',
`See ${SECRETREF_POLICY_DOC_URL}.`,
].join(" ");
}
function pushUnsupportedMutableSecretRefIssue(
issues: ConfigValidationIssue[],
path: string,
value: unknown,
): void {
if (!isObjectSecretRefCandidate(value)) {
return;
}
issues.push({
path,
message: formatUnsupportedMutableSecretRefMessage(path),
});
}
function collectUnsupportedMutableSecretRefIssues(raw: unknown): ConfigValidationIssue[] {
const issues: ConfigValidationIssue[] = [];
for (const candidate of collectUnsupportedSecretRefConfigCandidates(raw)) {
pushUnsupportedMutableSecretRefIssue(issues, candidate.path, candidate.value);
}
return issues;
}
function isUnsupportedMutableSecretRefSchemaIssue(params: {
issue: ConfigValidationIssue;
policyIssue: ConfigValidationIssue;
}): boolean {
const { issue, policyIssue } = params;
if (issue.path === policyIssue.path) {
return /expected string, received object/i.test(issue.message);
}
if (!issue.path || !policyIssue.path || !policyIssue.path.startsWith(`${issue.path}.`)) {
return false;
}
const remainder = policyIssue.path.slice(issue.path.length + 1);
const childKey = remainder.split(".")[0];
if (!childKey) {
return false;
}
if (!/Unrecognized key/i.test(issue.message)) {
return false;
}
const unrecognizedKeys = [...issue.message.matchAll(/"([^"]+)"/g)].map((match) => match[1]);
if (unrecognizedKeys.length === 0) {
return false;
}
return unrecognizedKeys.length === 1 && unrecognizedKeys[0] === childKey;
}
function mergeUnsupportedMutableSecretRefIssues(
policyIssues: ConfigValidationIssue[],
schemaIssues: ConfigValidationIssue[],
): ConfigValidationIssue[] {
if (policyIssues.length === 0) {
return schemaIssues;
}
const filteredSchemaIssues = schemaIssues.filter(
(issue) =>
!policyIssues.some((policyIssue) =>
isUnsupportedMutableSecretRefSchemaIssue({ issue, policyIssue }),
),
);
return [...policyIssues, ...filteredSchemaIssues];
}
export function collectUnsupportedSecretRefPolicyIssues(raw: unknown): ConfigValidationIssue[] {
return collectUnsupportedMutableSecretRefIssues(raw);
}
function mapZodIssueToConfigIssue(issue: unknown): ConfigValidationIssue {
const record = toIssueRecord(issue);
const path = formatConfigPath(toConfigPathSegments(record?.path));
@@ -365,6 +456,7 @@ export function validateConfigObjectRaw(
raw: unknown,
): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } {
const normalizedRaw = normalizeLegacyWebSearchConfig(raw);
const policyIssues = collectUnsupportedSecretRefPolicyIssues(normalizedRaw);
const legacyIssues = findLegacyConfigIssues(normalizedRaw);
if (legacyIssues.length > 0) {
return {
@@ -377,11 +469,15 @@ export function validateConfigObjectRaw(
}
const validated = OpenClawSchema.safeParse(normalizedRaw);
if (!validated.success) {
const schemaIssues = validated.error.issues.map((issue) => mapZodIssueToConfigIssue(issue));
return {
ok: false,
issues: validated.error.issues.map((issue) => mapZodIssueToConfigIssue(issue)),
issues: mergeUnsupportedMutableSecretRefIssues(policyIssues, schemaIssues),
};
}
if (policyIssues.length > 0) {
return { ok: false, issues: policyIssues };
}
const validatedConfig = validated.data as OpenClawConfig;
const duplicates = findDuplicateAgentDirs(validatedConfig);
if (duplicates.length > 0) {

View File

@@ -1,4 +1,5 @@
import { listSecretTargetRegistryEntries } from "./target-registry.js";
import { UNSUPPORTED_SECRETREF_SURFACE_PATTERNS } from "./unsupported-surface-policy.js";
type CredentialMatrixEntry = {
id: string;
@@ -20,16 +21,6 @@ export type SecretRefCredentialMatrixDocument = {
entries: CredentialMatrixEntry[];
};
const EXCLUDED_MUTABLE_OR_RUNTIME_MANAGED = [
"commands.ownerDisplaySecret",
"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) => ({
@@ -52,7 +43,7 @@ export function buildSecretRefCredentialMatrix(): SecretRefCredentialMatrixDocum
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],
excludedMutableOrRuntimeManaged: [...UNSUPPORTED_SECRETREF_SURFACE_PATTERNS],
entries,
};
}

View File

@@ -1,4 +1,5 @@
import type { AuthProfileCredential, AuthProfileStore } from "../agents/auth-profiles.js";
import { assertNoOAuthSecretRefPolicyViolations } from "../agents/auth-profiles/policy.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import {
pushAssignment,
@@ -103,6 +104,12 @@ export function collectAuthStoreAssignments(params: {
context: ResolverContext;
agentDir: string;
}): void {
assertNoOAuthSecretRefPolicyViolations({
store: params.store,
cfg: params.context.sourceConfig,
context: `auth-profiles ${params.agentDir}`,
});
const defaults = params.context.sourceConfig.secrets?.defaults;
for (const [profileId, profile] of Object.entries(params.store.profiles)) {
if (profile.type === "api_key") {

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import type { AuthProfileStore } from "../agents/auth-profiles.js";
import type { OpenClawConfig } from "../config/config.js";
import { prepareSecretsRuntimeSnapshot } from "./runtime.js";
function withAuthProfileMode(mode: "api_key" | "oauth" | "token"): OpenClawConfig {
return {
auth: {
profiles: {
"anthropic:default": {
provider: "anthropic",
mode,
},
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as OpenClawConfig;
}
describe("secrets runtime oauth auth-profile SecretRef policy", () => {
it("fails startup snapshot when oauth mode profile uses token SecretRef", async () => {
const store: AuthProfileStore = {
version: 1,
profiles: {
"anthropic:default": {
type: "token",
provider: "anthropic",
tokenRef: { source: "env", provider: "default", id: "ANTHROPIC_TOKEN" },
},
},
};
await expect(
prepareSecretsRuntimeSnapshot({
config: withAuthProfileMode("oauth"),
env: { ANTHROPIC_TOKEN: "token-value" } as NodeJS.ProcessEnv,
loadAuthStore: () => store,
loadablePluginOrigins: new Map(),
agentDirs: ["/tmp/openclaw-secrets-runtime-main"],
}),
).rejects.toThrow(/OAuth \+ SecretRef is not supported/i);
});
it("keeps token SecretRef support when the profile mode is token", async () => {
const store: AuthProfileStore = {
version: 1,
profiles: {
"anthropic:default": {
type: "token",
provider: "anthropic",
tokenRef: { source: "env", provider: "default", id: "ANTHROPIC_TOKEN" },
},
},
};
const snapshot = await prepareSecretsRuntimeSnapshot({
config: withAuthProfileMode("token"),
env: { ANTHROPIC_TOKEN: "token-value" } as NodeJS.ProcessEnv,
loadAuthStore: () => store,
loadablePluginOrigins: new Map(),
agentDirs: ["/tmp/openclaw-secrets-runtime-main"],
});
const resolved = snapshot.authStores[0]?.store.profiles["anthropic:default"];
expect(resolved).toMatchObject({
type: "token",
token: "token-value",
});
});
});

View File

@@ -255,6 +255,97 @@ describe("secrets runtime snapshot integration", () => {
});
});
it("fails fast at startup when gateway auth SecretRef is active and unresolved", async () => {
await withEnvAsync(
{
OPENCLAW_BUNDLED_PLUGINS_DIR: undefined,
OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1",
OPENCLAW_VERSION: undefined,
},
async () => {
await expect(
prepareSecretsRuntimeSnapshot({
config: asConfig({
gateway: {
auth: {
mode: "token",
token: {
source: "env",
provider: "default",
id: "MISSING_GATEWAY_AUTH_TOKEN",
},
},
},
}),
env: {},
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
}),
).rejects.toThrow(/MISSING_GATEWAY_AUTH_TOKEN/i);
},
);
});
it(
"keeps last-known-good runtime snapshot active when reload introduces unresolved active gateway auth refs",
async () => {
await withTempHome("openclaw-secrets-runtime-gateway-auth-reload-lkg-", async (home) => {
const initialTokenRef = {
source: "env",
provider: "default",
id: "GATEWAY_AUTH_TOKEN",
} as const;
const missingTokenRef = {
source: "env",
provider: "default",
id: "MISSING_GATEWAY_AUTH_TOKEN",
} as const;
const prepared = await prepareSecretsRuntimeSnapshot({
config: asConfig({
gateway: {
auth: {
mode: "token",
token: initialTokenRef,
},
},
}),
env: {
GATEWAY_AUTH_TOKEN: "gateway-runtime-token",
},
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => ({ version: 1, profiles: {} }),
});
activateSecretsRuntimeSnapshot(prepared);
expect(loadConfig().gateway?.auth?.token).toBe("gateway-runtime-token");
await expect(
writeConfigFile({
...loadConfig(),
gateway: {
auth: {
mode: "token",
token: missingTokenRef,
},
},
}),
).rejects.toThrow(/runtime snapshot refresh failed: .*MISSING_GATEWAY_AUTH_TOKEN/i);
const activeAfterFailure = getActiveSecretsRuntimeSnapshot();
expect(activeAfterFailure).not.toBeNull();
expect(loadConfig().gateway?.auth?.token).toBe("gateway-runtime-token");
expect(activeAfterFailure?.sourceConfig.gateway?.auth?.token).toEqual(initialTokenRef);
const persistedConfig = JSON.parse(
await fs.readFile(path.join(home, ".openclaw", "openclaw.json"), "utf8"),
) as OpenClawConfig;
expect(persistedConfig.gateway?.auth?.token).toEqual(missingTokenRef);
});
},
SECRETS_RUNTIME_INTEGRATION_TIMEOUT_MS,
);
it(
"keeps last-known-good web runtime snapshot when reload introduces unresolved active web refs",
async () => {

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import {
collectUnsupportedSecretRefConfigCandidates,
UNSUPPORTED_SECRETREF_SURFACE_PATTERNS,
} from "./unsupported-surface-policy.js";
describe("unsupported SecretRef surface policy metadata", () => {
it("exposes the canonical unsupported surface patterns", () => {
expect(UNSUPPORTED_SECRETREF_SURFACE_PATTERNS).toEqual([
"commands.ownerDisplaySecret",
"hooks.token",
"hooks.gmail.pushToken",
"hooks.mappings[].sessionKey",
"auth-profiles.oauth.*",
"channels.discord.threadBindings.webhookToken",
"channels.discord.accounts.*.threadBindings.webhookToken",
"channels.whatsapp.creds.json",
"channels.whatsapp.accounts.*.creds.json",
]);
});
it("discovers concrete config candidates for unsupported mutable surfaces", () => {
const candidates = collectUnsupportedSecretRefConfigCandidates({
commands: { ownerDisplaySecret: { source: "env", provider: "default", id: "OWNER" } },
hooks: {
token: { source: "env", provider: "default", id: "HOOK_TOKEN" },
gmail: { pushToken: { source: "env", provider: "default", id: "GMAIL_PUSH" } },
mappings: [{ sessionKey: { source: "env", provider: "default", id: "S0" } }],
},
channels: {
discord: {
threadBindings: {
webhookToken: { source: "env", provider: "default", id: "DISCORD_WEBHOOK" },
},
accounts: {
ops: {
threadBindings: {
webhookToken: {
source: "env",
provider: "default",
id: "DISCORD_WEBHOOK_OPS",
},
},
},
},
},
whatsapp: {
creds: { json: { source: "env", provider: "default", id: "WHATSAPP_JSON" } },
accounts: {
ops: {
creds: {
json: { source: "env", provider: "default", id: "WHATSAPP_JSON_OPS" },
},
},
},
},
},
});
expect(candidates.map((candidate) => candidate.path).toSorted()).toEqual(
[
"commands.ownerDisplaySecret",
"hooks.token",
"hooks.gmail.pushToken",
"hooks.mappings.0.sessionKey",
"channels.discord.threadBindings.webhookToken",
"channels.discord.accounts.ops.threadBindings.webhookToken",
"channels.whatsapp.creds.json",
"channels.whatsapp.accounts.ops.creds.json",
].toSorted(),
);
});
});

View File

@@ -0,0 +1,128 @@
import { isRecord } from "../utils.js";
export const UNSUPPORTED_SECRETREF_SURFACE_PATTERNS = [
"commands.ownerDisplaySecret",
"hooks.token",
"hooks.gmail.pushToken",
"hooks.mappings[].sessionKey",
"auth-profiles.oauth.*",
"channels.discord.threadBindings.webhookToken",
"channels.discord.accounts.*.threadBindings.webhookToken",
"channels.whatsapp.creds.json",
"channels.whatsapp.accounts.*.creds.json",
] as const;
export type UnsupportedSecretRefConfigCandidate = {
path: string;
value: unknown;
};
export function collectUnsupportedSecretRefConfigCandidates(
raw: unknown,
): UnsupportedSecretRefConfigCandidate[] {
if (!isRecord(raw)) {
return [];
}
const candidates: UnsupportedSecretRefConfigCandidate[] = [];
const commands = isRecord(raw.commands) ? raw.commands : null;
if (commands) {
candidates.push({
path: "commands.ownerDisplaySecret",
value: commands.ownerDisplaySecret,
});
}
const hooks = isRecord(raw.hooks) ? raw.hooks : null;
if (hooks) {
candidates.push({ path: "hooks.token", value: hooks.token });
const gmail = isRecord(hooks.gmail) ? hooks.gmail : null;
if (gmail) {
candidates.push({
path: "hooks.gmail.pushToken",
value: gmail.pushToken,
});
}
const mappings = hooks.mappings;
if (Array.isArray(mappings)) {
for (const [index, mapping] of mappings.entries()) {
if (!isRecord(mapping)) {
continue;
}
candidates.push({
path: `hooks.mappings.${index}.sessionKey`,
value: mapping.sessionKey,
});
}
}
}
const channels = isRecord(raw.channels) ? raw.channels : null;
if (!channels) {
return candidates;
}
const discord = isRecord(channels.discord) ? channels.discord : null;
if (discord) {
const threadBindings = isRecord(discord.threadBindings) ? discord.threadBindings : null;
if (threadBindings) {
candidates.push({
path: "channels.discord.threadBindings.webhookToken",
value: threadBindings.webhookToken,
});
}
const accounts = isRecord(discord.accounts) ? discord.accounts : null;
if (accounts) {
for (const [accountId, account] of Object.entries(accounts)) {
if (!isRecord(account)) {
continue;
}
const accountThreadBindings = isRecord(account.threadBindings)
? account.threadBindings
: null;
if (!accountThreadBindings) {
continue;
}
candidates.push({
path: `channels.discord.accounts.${accountId}.threadBindings.webhookToken`,
value: accountThreadBindings.webhookToken,
});
}
}
}
const whatsapp = isRecord(channels.whatsapp) ? channels.whatsapp : null;
if (!whatsapp) {
return candidates;
}
const creds = isRecord(whatsapp.creds) ? whatsapp.creds : null;
if (creds) {
candidates.push({
path: "channels.whatsapp.creds.json",
value: creds.json,
});
}
const accounts = isRecord(whatsapp.accounts) ? whatsapp.accounts : null;
if (!accounts) {
return candidates;
}
for (const [accountId, account] of Object.entries(accounts)) {
if (!isRecord(account)) {
continue;
}
const accountCreds = isRecord(account.creds) ? account.creds : null;
if (!accountCreds) {
continue;
}
candidates.push({
path: `channels.whatsapp.accounts.${accountId}.creds.json`,
value: accountCreds.json,
});
}
return candidates;
}