mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 14:00:26 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
142
src/agents/auth-profiles/policy.ts
Normal file
142
src/agents/auth-profiles/policy.ts
Normal 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"));
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
129
src/config/validation.policy.test.ts
Normal file
129
src/config/validation.policy.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
74
src/secrets/runtime-auth-profiles-oauth-policy.test.ts
Normal file
74
src/secrets/runtime-auth-profiles-oauth-policy.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
73
src/secrets/unsupported-surface-policy.test.ts
Normal file
73
src/secrets/unsupported-surface-policy.test.ts
Normal 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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
128
src/secrets/unsupported-surface-policy.ts
Normal file
128
src/secrets/unsupported-surface-policy.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user