diff --git a/CHANGELOG.md b/CHANGELOG.md index a626f62fa18..9c5f732eaec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,6 +128,7 @@ Docs: https://docs.openclaw.ai - Matrix/direct rooms: recover fresh auto-joined 1:1 DMs without eagerly persisting invite-only `m.direct` mappings, while keeping named, aliased, and explicitly configured rooms on the room path. (#58024) Thanks @gumadeiras. - TTS: Restore 3.28 schema compatibility and fallback observability. (#57953) Thanks @joshavant. - Telegram/forum topics: restore reply routing to the active topic and keep ACP `sessions_spawn(..., thread=true, mode="session")` bound to that same topic instead of falling back to root chat or losing follow-up routing. (#56060) Thanks @one27001. +- Config/SecretRef + Control UI: harden SecretRef redaction round-trip restore, block unsafe raw fallback (force Form mode when raw is unavailable), and preflight submitted-config SecretRefs before config write RPC persistence. (#58044) Thanks @joshavant. ## 2026.3.28 diff --git a/docs/cli/index.md b/docs/cli/index.md index a07fb449ee3..3a0637abc01 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -905,12 +905,14 @@ Subcommands: Common RPCs: +- `config.set` (validate + write full config; use `baseHash` for optimistic concurrency) - `config.apply` (validate + write config + restart + wake) - `config.patch` (merge a partial update + restart + wake) - `update.run` (run update + restart + wake) Tip: when calling `config.set`/`config.apply`/`config.patch` directly, pass `baseHash` from `config.get` if a config already exists. +Tip: these config write RPCs preflight active SecretRef resolution for refs in the submitted config payload and reject writes when an effectively active submitted ref is unresolved. ## Models diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index edf8eb2f530..c5a920e8f33 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -364,6 +364,7 @@ Runtime-minted or rotating credentials and OAuth refresh material are intentiona - Field without a ref: unchanged. - Field with a ref: required on active surfaces during activation. - If both plaintext and ref are present, ref takes precedence on supported precedence paths. +- The redaction sentinel `__OPENCLAW_REDACTED__` is reserved for internal config redaction/restore and is rejected as literal submitted config data. Warning and audit signals: @@ -383,12 +384,14 @@ Secret activation runs on: - Config reload hot-apply path - Config reload restart-check path - Manual reload via `secrets.reload` +- Gateway config write RPC preflight (`config.set` / `config.apply` / `config.patch`) for active-surface SecretRef resolvability within the submitted config payload before persisting edits Activation contract: - Success swaps the snapshot atomically. - Startup failure aborts gateway startup. - Runtime reload failure keeps the last-known-good snapshot. +- Write-RPC preflight failure rejects the submitted config and keeps both disk config and active runtime snapshot unchanged. - Providing an explicit per-call channel token to an outbound helper/tool call does not trigger SecretRef activation; activation points remain startup, reload, and explicit `secrets.reload`. ## Degraded and recovered signals diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 9de259a7ef4..6b79e12c53d 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -87,7 +87,10 @@ The Control UI can localize itself on first load based on your browser locale, a - Config: view/edit `~/.openclaw/openclaw.json` (`config.get`, `config.set`) - Config: apply + restart with validation (`config.apply`) and wake the last active session - Config writes include a base-hash guard to prevent clobbering concurrent edits -- Config schema + form rendering (`config.schema`, including plugin + channel schemas); Raw JSON editor remains available +- Config writes (`config.set`/`config.apply`/`config.patch`) also preflight active SecretRef resolution for refs in the submitted config payload; unresolved active submitted refs are rejected before write +- Config schema + form rendering (`config.schema`, including plugin + channel schemas); Raw JSON editor is available only when the snapshot has a safe raw round-trip +- If a snapshot cannot safely round-trip raw text, Control UI forces Form mode and disables Raw mode for that snapshot +- Structured SecretRef object values are rendered read-only in form text inputs to prevent accidental object-to-string corruption - Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`) - Logs: live tail of gateway file logs with filter/export (`logs.tail`) - Update: run a package/git update + restart (`update.run`) with a restart report diff --git a/extensions/discord/src/config-ui-hints.ts b/extensions/discord/src/config-ui-hints.ts index 6b30de4bfcd..f738d5d4d55 100644 --- a/extensions/discord/src/config-ui-hints.ts +++ b/extensions/discord/src/config-ui-hints.ts @@ -192,5 +192,6 @@ export const discordChannelConfigUiHints = { token: { label: "Discord Bot Token", help: "Discord bot token used for gateway and REST API authentication for this provider account. Keep this secret out of committed config and rotate immediately after any leak.", + sensitive: true, }, } satisfies Record; diff --git a/src/config/redact-snapshot.restore.test.ts b/src/config/redact-snapshot.restore.test.ts index 072a0398a17..dddc5f558ac 100644 --- a/src/config/redact-snapshot.restore.test.ts +++ b/src/config/redact-snapshot.restore.test.ts @@ -121,7 +121,7 @@ describe("restoreRedactedValues", () => { expect(result.humanReadableMessage).toContain("channels.newChannel.token"); }); - it("keeps unmatched wildcard array entries unchanged outside extension paths", () => { + it("rejects sentinel literals that survive restore", () => { const hints: ConfigUiHints = { "custom.*": { sensitive: true }, }; @@ -131,8 +131,9 @@ describe("restoreRedactedValues", () => { const original = { custom: { items: ["original-secret-value"] }, }; - const result = restoreRedactedValues(incoming, original, hints) as typeof incoming; - expect(result.custom.items[0]).toBe(REDACTED_SENTINEL); + const result = restoreRedactedValues_orig(incoming, original, hints); + expect(result.ok).toBe(false); + expect(result.humanReadableMessage).toContain("Reserved redaction sentinel"); }); it("round-trips config through redact → restore", () => { @@ -183,7 +184,7 @@ describe("restoreRedactedValues", () => { expect(restored).toEqual(originalConfig); }); - it("restores with uiHints respecting sensitive:false override", () => { + it("rejects sentinel literals even when uiHints mark the path non-sensitive", () => { const hints: ConfigUiHints = { "gateway.auth.token": { sensitive: false }, }; @@ -193,8 +194,9 @@ describe("restoreRedactedValues", () => { const original = { gateway: { auth: { token: "real-secret" } }, }; - const result = restoreRedactedValues(incoming, original, hints) as typeof incoming; - expect(result.gateway.auth.token).toBe(REDACTED_SENTINEL); + const result = restoreRedactedValues_orig(incoming, original, hints); + expect(result.ok).toBe(false); + expect(result.humanReadableMessage).toContain("Reserved redaction sentinel"); }); it("restores array items using wildcard uiHints", () => { @@ -225,4 +227,100 @@ describe("restoreRedactedValues", () => { expect(result.channels.slack.accounts[0].botToken).toBe("original-token-first-account"); expect(result.channels.slack.accounts[1].botToken).toBe("user-provided-new-token-value"); }); + + it("restores redacted SecretRef ids for channels token paths", () => { + const hints: ConfigUiHints = { + "channels.discord.token": { sensitive: true }, + }; + const incoming = { + channels: { + discord: { + token: { + source: "env", + provider: "default", + id: REDACTED_SENTINEL, + }, + }, + }, + }; + const original = { + channels: { + discord: { + token: { + source: "env", + provider: "default", + id: "DISCORD_BOT_TOKEN", + }, + }, + }, + }; + const result = restoreRedactedValues(incoming, original, hints); + expect(result.channels.discord.token).toEqual({ + source: "env", + provider: "default", + id: "DISCORD_BOT_TOKEN", + }); + }); + + it("rejects SecretRef source/provider changes when id is still redacted", () => { + const incoming = { + models: { + providers: { + default: { + apiKey: { + source: "file", + provider: "vault", + id: REDACTED_SENTINEL, + }, + }, + }, + }, + }; + const original = { + models: { + providers: { + default: { + apiKey: { + source: "env", + provider: "default", + id: "OPENAI_API_KEY", + }, + }, + }, + }, + }; + const result = restoreRedactedValues_orig(incoming, original, mainSchemaHints); + expect(result.ok).toBe(false); + expect(result.humanReadableMessage).toContain("changed source/provider"); + }); + + it("reports a provider-focused error when original SecretRefs lack provider", () => { + const incoming = { + models: { + providers: { + default: { + apiKey: { + source: "env", + id: REDACTED_SENTINEL, + }, + }, + }, + }, + }; + const original = { + models: { + providers: { + default: { + apiKey: { + source: "env", + id: "OPENAI_API_KEY", + }, + }, + }, + }, + }; + const result = restoreRedactedValues_orig(incoming, original, mainSchemaHints); + expect(result.ok).toBe(false); + expect(result.humanReadableMessage).toContain("requires a provider field"); + }); }); diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index 2d1691b89f7..e50c148d5b6 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -317,7 +317,7 @@ describe("redactConfigSnapshot", () => { expect(result.raw).toContain(REDACTED_SENTINEL); }); - it("keeps non-sensitive raw fields intact when secret values overlap", () => { + it("drops raw text when overlap fallback triggers", () => { const config = { gateway: { mode: "local", @@ -326,12 +326,13 @@ describe("redactConfigSnapshot", () => { }; const snapshot = makeSnapshot(config, JSON.stringify(config)); const result = redactConfigSnapshot(snapshot, mainSchemaHints); - const parsed: { + expect(result.raw).toBeNull(); + const cfg = result.config as { gateway?: { mode?: string; auth?: { password?: string } }; - } = JSON5.parse(result.raw ?? "{}"); - expect(parsed.gateway?.mode).toBe("local"); - expect(parsed.gateway?.auth?.password).toBe(REDACTED_SENTINEL); - const restored = restoreRedactedValues(parsed, snapshot.config, mainSchemaHints); + }; + expect(cfg.gateway?.mode).toBe("local"); + expect(cfg.gateway?.auth?.password).toBe(REDACTED_SENTINEL); + const restored = restoreRedactedValues(result.config, snapshot.config, mainSchemaHints); expect(restored.gateway.mode).toBe("local"); expect(restored.gateway.auth.password).toBe("local"); }); @@ -373,13 +374,19 @@ describe("redactConfigSnapshot", () => { }; const snapshot = makeSnapshot(config, JSON.stringify(config, null, 2)); const result = redactConfigSnapshot(snapshot, mainSchemaHints); - const parsed = JSON5.parse(result.raw ?? "{}"); - expect(parsed.gateway?.mode).toBe("default"); - expect(parsed.gateway?.auth?.password).toBe(REDACTED_SENTINEL); - expect(parsed.models?.providers?.default?.apiKey?.source).toBe("env"); - expect(parsed.models?.providers?.default?.apiKey?.provider).toBe("default"); - expect(result.raw).not.toContain("OPENAI_API_KEY"); - const restored = restoreRedactedValues(parsed, snapshot.config, mainSchemaHints); + expect(result.raw).toBeNull(); + const cfg = result.config as { + gateway?: { mode?: string; auth?: { password?: string } }; + models?: { + providers?: { default?: { apiKey?: { source?: string; provider?: string; id?: string } } }; + }; + }; + expect(cfg.gateway?.mode).toBe("default"); + expect(cfg.gateway?.auth?.password).toBe(REDACTED_SENTINEL); + expect(cfg.models?.providers?.default?.apiKey?.source).toBe("env"); + expect(cfg.models?.providers?.default?.apiKey?.provider).toBe("default"); + expect(cfg.models?.providers?.default?.apiKey?.id).toBe(REDACTED_SENTINEL); + const restored = restoreRedactedValues(result.config, snapshot.config, mainSchemaHints); expect(restored).toEqual(snapshot.config); }); diff --git a/src/config/redact-snapshot.ts b/src/config/redact-snapshot.ts index 2043f4a2d3f..09f16869f28 100644 --- a/src/config/redact-snapshot.ts +++ b/src/config/redact-snapshot.ts @@ -1,4 +1,3 @@ -import JSON5 from "json5"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { hasSensitiveUrlHintTag, @@ -88,6 +87,12 @@ function isExplicitlyNonSensitivePath(hints: ConfigUiHints | undefined, paths: s */ export const REDACTED_SENTINEL = "__OPENCLAW_REDACTED__"; +function isSecretRefWithProvider( + value: Record, +): value is Record & { source: string; provider: string; id: string } { + return isSecretRefShape(value) && typeof value.provider === "string"; +} + // ConfigUiHints' keys look like this: // - path.subpath.key (nested objects) // - path.subpath[].key (object in array in object) @@ -437,7 +442,7 @@ export function redactConfigSnapshot( ), }) ) { - redactedRaw = JSON5.stringify(redactedParsed ?? redactedConfig, null, 2); + redactedRaw = null; } // Also redact the resolved config (contains values after ${ENV} substitution) const redactedResolved = redactConfigObject(snapshot.resolved, uiHints); @@ -477,24 +482,24 @@ export function restoreRedactedValues( return { ok: false, error: "input not an object" }; } try { + let restored: unknown; if (hints) { const lookup = buildRedactionLookup(hints); if (lookup.has("")) { - return { - ok: true, - result: restoreRedactedValuesWithLookup(incoming, original, lookup, "", hints), - }; + restored = restoreRedactedValuesWithLookup(incoming, original, lookup, "", hints); } else { - return { ok: true, result: restoreRedactedValuesGuessing(incoming, original, "", hints) }; + restored = restoreRedactedValuesGuessing(incoming, original, "", hints); } } else { - return { ok: true, result: restoreRedactedValuesGuessing(incoming, original, "") }; + restored = restoreRedactedValuesGuessing(incoming, original, ""); } + assertNoRedactedSentinel(restored, ""); + return { ok: true, result: restored }; } catch (err) { if (err instanceof RedactionError) { return { ok: false, - humanReadableMessage: `Sentinel value "${REDACTED_SENTINEL}" in key ${err.key} is not valid as real data`, + humanReadableMessage: err.humanReadableMessage, }; } throw err; // some coding error, pass through @@ -503,10 +508,14 @@ export function restoreRedactedValues( class RedactionError extends Error { public readonly key: string; + public readonly humanReadableMessage: string; - constructor(key: string) { + constructor(key: string, humanReadableMessage?: string) { super("internal error class---should never escape"); this.key = key; + this.humanReadableMessage = + humanReadableMessage ?? + `Sentinel value "${REDACTED_SENTINEL}" in key ${key} is not valid as real data`; this.name = "RedactionError"; } } @@ -525,6 +534,69 @@ function restoreOriginalValueOrThrow(params: { throw new RedactionError(params.path); } +function assertNoRedactedSentinel(value: unknown, path: string): void { + if (typeof value === "string" && value === REDACTED_SENTINEL) { + const pathLabel = path || ""; + throw new RedactionError( + pathLabel, + `Reserved redaction sentinel "${REDACTED_SENTINEL}" is not valid config data (${pathLabel}).`, + ); + } + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) { + const nextPath = path ? `${path}[${index}]` : `[${index}]`; + assertNoRedactedSentinel(value[index], nextPath); + } + return; + } + if (value && typeof value === "object") { + for (const [key, item] of Object.entries(value as Record)) { + assertNoRedactedSentinel(item, path ? `${path}.${key}` : key); + } + } +} + +function maybeRestoreSecretRefId(params: { + incoming: unknown; + original: unknown; + path: string; +}): { handled: false } | { handled: true; value: unknown } { + const incomingObj = toObjectRecord(params.incoming); + if (!isSecretRefShape(incomingObj) || incomingObj.id !== REDACTED_SENTINEL) { + return { handled: false }; + } + + const originalObj = toObjectRecord(params.original); + if (!isSecretRefWithProvider(originalObj)) { + if (isSecretRefShape(originalObj)) { + throw new RedactionError( + params.path, + `SecretRef at ${params.path} requires a provider field to restore the redacted id automatically (original ref lacks provider).`, + ); + } + throw new RedactionError( + params.path, + `SecretRef at ${params.path} contains a redacted id placeholder with no matching original value.`, + ); + } + + if (!isSecretRefWithProvider(incomingObj)) { + throw new RedactionError( + params.path, + `SecretRef at ${params.path} must include source, provider, and id when redacted placeholders are present.`, + ); + } + + if (incomingObj.source !== originalObj.source || incomingObj.provider !== originalObj.provider) { + throw new RedactionError( + params.path, + `SecretRef at ${params.path} changed source/provider while id is redacted. Provide an explicit id when changing source/provider.`, + ); + } + + return { handled: true, value: { ...incomingObj, id: originalObj.id } }; +} + function mapRedactedArray(params: { incoming: unknown[]; original: unknown; @@ -682,7 +754,14 @@ function restoreRedactedValuesWithLookup( ) { result[key] = restoreOriginalValueOrThrow({ key, path: candidate, original: orig }); } else if (typeof value === "object" && value !== null) { - result[key] = restoreRedactedValuesWithLookup(value, orig[key], lookup, candidate, hints); + const restoredSecretRef = maybeRestoreSecretRefId({ + incoming: value, + original: orig[key], + path, + }); + result[key] = restoredSecretRef.handled + ? restoredSecretRef.value + : restoreRedactedValuesWithLookup(value, orig[key], lookup, candidate, hints); } break; } @@ -698,7 +777,23 @@ function restoreRedactedValuesWithLookup( ) { result[key] = restoreOriginalValueOrThrow({ key, path, original: orig }); } else if (typeof value === "object" && value !== null) { - result[key] = restoreRedactedValuesGuessing(value, orig[key], path, hints); + const canRestoreSecretRef = + !markedNonSensitive && + (isSensitivePath(path) || + hasSensitiveUrlHintPath(hints, [path, wildcardPath]) || + isSensitiveUrlPath(path)); + if (canRestoreSecretRef) { + const restoredSecretRef = maybeRestoreSecretRefId({ + incoming: value, + original: orig[key], + path, + }); + result[key] = restoredSecretRef.handled + ? restoredSecretRef.value + : restoreRedactedValuesGuessing(value, orig[key], path, hints); + } else { + result[key] = restoreRedactedValuesGuessing(value, orig[key], path, hints); + } } } } @@ -742,7 +837,23 @@ function restoreRedactedValuesGuessing( ) { result[key] = restoreOriginalValueOrThrow({ key, path, original: orig }); } else if (typeof value === "object" && value !== null) { - result[key] = restoreRedactedValuesGuessing(value, orig[key], path, hints); + const canRestoreSecretRef = + !isExplicitlyNonSensitivePath(hints, [path, wildcardPath]) && + (isSensitivePath(path) || + hasSensitiveUrlHintPath(hints, [path, wildcardPath]) || + isSensitiveUrlPath(path)); + if (canRestoreSecretRef) { + const restoredSecretRef = maybeRestoreSecretRefId({ + incoming: value, + original: orig[key], + path, + }); + result[key] = restoredSecretRef.handled + ? restoredSecretRef.value + : restoreRedactedValuesGuessing(value, orig[key], path, hints); + } else { + result[key] = restoreRedactedValuesGuessing(value, orig[key], path, hints); + } } else { result[key] = value; } diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index abdf4ad36fa..83414b9a5a3 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -26,6 +26,7 @@ import { writeRestartSentinel, } from "../../infra/restart-sentinel.js"; import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; +import { prepareSecretsRuntimeSnapshot } from "../../secrets/runtime.js"; import { diffConfigPaths } from "../config-reload.js"; import { formatControlPlaneActor, @@ -233,6 +234,30 @@ function summarizeConfigValidationIssues(issues: ReadonlyArray { + try { + await prepareSecretsRuntimeSnapshot({ + config: params.config, + includeAuthStoreRefs: false, + }); + return true; + } catch (error) { + const details = error instanceof Error ? error.message : String(error); + params.respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid config: active SecretRef resolution failed (${details})`, + ), + ); + return false; + } +} + function resolveConfigRestartRequest(params: unknown): { sessionKey: string | undefined; note: string | undefined; @@ -358,6 +383,9 @@ export const configHandlers: GatewayRequestHandlers = { if (!parsed) { return; } + if (!(await ensureResolvableSecretRefsOrRespond({ config: parsed.config, respond }))) { + return; + } await writeConfigFile(parsed.config, writeOptions); respond( true, @@ -443,6 +471,9 @@ export const configHandlers: GatewayRequestHandlers = { ); return; } + if (!(await ensureResolvableSecretRefsOrRespond({ config: validated.config, respond }))) { + return; + } const changedPaths = diffConfigPaths(snapshot.config, validated.config); const actor = resolveControlPlaneActor(client); context?.logGateway?.info( @@ -503,6 +534,9 @@ export const configHandlers: GatewayRequestHandlers = { if (!parsed) { return; } + if (!(await ensureResolvableSecretRefsOrRespond({ config: parsed.config, respond }))) { + return; + } const changedPaths = diffConfigPaths(snapshot.config, parsed.config); const actor = resolveControlPlaneActor(client); context?.logGateway?.info( diff --git a/src/gateway/server.config-apply.test.ts b/src/gateway/server.config-apply.test.ts index 85a55caaefe..55d271ba0fd 100644 --- a/src/gateway/server.config-apply.test.ts +++ b/src/gateway/server.config-apply.test.ts @@ -1,5 +1,9 @@ +import fs from "node:fs/promises"; +import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { WebSocket } from "ws"; +import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; +import { AUTH_PROFILE_FILENAME } from "../agents/auth-profiles/constants.js"; import { connectOk, getFreePort, @@ -46,7 +50,98 @@ const sendConfigApply = async (ws: WebSocket, id: string, raw: unknown) => { }); }; +const sendConfigGet = async (ws: WebSocket, id: string) => { + ws.send( + JSON.stringify({ + type: "req", + id, + method: "config.get", + params: {}, + }), + ); + return onceMessage<{ + ok: boolean; + payload?: { hash?: string; raw?: string | null; config?: Record }; + }>(ws, (o) => { + const msg = o as { type?: string; id?: string }; + return msg.type === "res" && msg.id === id; + }); +}; + describe("gateway config.apply", () => { + it("rejects config.apply when SecretRef resolution fails", async () => { + const ws = await openClient(); + try { + const missingEnvVar = `OPENCLAW_MISSING_SECRETREF_APPLY_${Date.now()}`; + delete process.env[missingEnvVar]; + const current = await sendConfigGet(ws, "req-secretref-get-before"); + expect(current.ok).toBe(true); + expect(typeof current.payload?.hash).toBe("string"); + const nextConfig = structuredClone(current.payload?.config ?? {}); + const channels = (nextConfig.channels ??= {}) as Record; + const telegram = (channels.telegram ??= {}) as Record; + telegram.botToken = { source: "env", provider: "default", id: missingEnvVar }; + const telegramAccounts = (telegram.accounts ??= {}) as Record; + const defaultTelegramAccount = (telegramAccounts.default ??= {}) as Record; + defaultTelegramAccount.enabled = true; + + const id = "req-secretref-apply"; + const res = await sendConfigApply(ws, id, JSON.stringify(nextConfig, null, 2)); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("active SecretRef resolution failed"); + + const after = await sendConfigGet(ws, "req-secretref-get-after"); + expect(after.ok).toBe(true); + expect(after.payload?.hash).toBe(current.payload?.hash); + expect(after.payload?.raw).toBe(current.payload?.raw); + } finally { + ws.close(); + } + }); + + it("does not reject config.apply for unresolved auth-profile refs outside submitted config", async () => { + const ws = await openClient(); + try { + const missingEnvVar = `OPENCLAW_MISSING_AUTH_PROFILE_REF_APPLY_${Date.now()}`; + delete process.env[missingEnvVar]; + + const authStorePath = path.join(resolveOpenClawAgentDir(), AUTH_PROFILE_FILENAME); + await fs.mkdir(path.dirname(authStorePath), { recursive: true }); + await fs.writeFile( + authStorePath, + `${JSON.stringify( + { + version: 1, + profiles: { + "custom:token": { + type: "token", + provider: "custom", + tokenRef: { source: "env", provider: "default", id: missingEnvVar }, + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + + const current = await sendConfigGet(ws, "req-auth-profile-get-before"); + expect(current.ok).toBe(true); + expect(current.payload?.config).toBeTruthy(); + + const res = await sendConfigApply( + ws, + "req-auth-profile-apply", + JSON.stringify(current.payload?.config ?? {}, null, 2), + ); + expect(res.ok).toBe(true); + expect(res.error).toBeUndefined(); + } finally { + ws.close(); + } + }); + it("rejects invalid raw config", async () => { const ws = await openClient(); try { diff --git a/src/gateway/server.config-patch.test.ts b/src/gateway/server.config-patch.test.ts index bc8b4fab75a..55ce5060b25 100644 --- a/src/gateway/server.config-patch.test.ts +++ b/src/gateway/server.config-patch.test.ts @@ -2,6 +2,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; +import { AUTH_PROFILE_FILENAME } from "../agents/auth-profiles/constants.js"; import { connectOk, installGatewayTestHooks, @@ -62,6 +64,39 @@ async function expectSchemaLookupInvalid(path: unknown) { } describe("gateway config methods", () => { + it("rejects config.set when SecretRef resolution fails", async () => { + const missingEnvVar = `OPENCLAW_MISSING_SECRETREF_${Date.now()}`; + delete process.env[missingEnvVar]; + const current = await rpcReq<{ + hash?: string; + config?: Record; + }>(requireWs(), "config.get", {}); + expect(current.ok).toBe(true); + expect(typeof current.payload?.hash).toBe("string"); + expect(current.payload?.config).toBeTruthy(); + + const nextConfig = structuredClone(current.payload?.config ?? {}); + const channels = (nextConfig.channels ??= {}) as Record; + const telegram = (channels.telegram ??= {}) as Record; + telegram.botToken = { source: "env", provider: "default", id: missingEnvVar }; + const telegramAccounts = (telegram.accounts ??= {}) as Record; + const defaultTelegramAccount = (telegramAccounts.default ??= {}) as Record; + defaultTelegramAccount.enabled = true; + + const res = await rpcReq<{ ok?: boolean; error?: { message?: string } }>( + requireWs(), + "config.set", + { + raw: JSON.stringify(nextConfig, null, 2), + baseHash: current.payload?.hash, + }, + ); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("active SecretRef resolution failed"); + const afterHash = await getConfigHash(); + expect(afterHash).toBe(current.payload?.hash); + }); + it("round-trips config.set and returns the live config path", async () => { const { createConfigIO } = await import("../config/config.js"); const current = await rpcReq<{ @@ -87,6 +122,52 @@ describe("gateway config methods", () => { expect(res.payload?.config).toBeTruthy(); }); + it("does not reject config.set for unresolved auth-profile refs outside submitted config", async () => { + const missingEnvVar = `OPENCLAW_MISSING_AUTH_PROFILE_REF_${Date.now()}`; + delete process.env[missingEnvVar]; + + const authStorePath = path.join(resolveOpenClawAgentDir(), AUTH_PROFILE_FILENAME); + await fs.mkdir(path.dirname(authStorePath), { recursive: true }); + await fs.writeFile( + authStorePath, + `${JSON.stringify( + { + version: 1, + profiles: { + "custom:token": { + type: "token", + provider: "custom", + tokenRef: { source: "env", provider: "default", id: missingEnvVar }, + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + + const current = await rpcReq<{ + hash?: string; + config?: Record; + }>(requireWs(), "config.get", {}); + expect(current.ok).toBe(true); + expect(typeof current.payload?.hash).toBe("string"); + expect(current.payload?.config).toBeTruthy(); + + const res = await rpcReq<{ ok?: boolean; error?: { message?: string } }>( + requireWs(), + "config.set", + { + raw: JSON.stringify(current.payload?.config ?? {}, null, 2), + baseHash: current.payload?.hash, + }, + ); + + expect(res.ok).toBe(true); + expect(res.error).toBeUndefined(); + }); + it("returns config.set validation details in the top-level error message", async () => { const res = await rpcReq<{ ok?: boolean; @@ -179,6 +260,39 @@ describe("gateway config methods", () => { expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("raw must be an object"); }); + + it("rejects config.patch when merged SecretRefs cannot resolve", async () => { + const missingEnvVar = `OPENCLAW_MISSING_SECRETREF_PATCH_${Date.now()}`; + delete process.env[missingEnvVar]; + const beforeHash = await getConfigHash(); + const res = await rpcReq<{ ok?: boolean; error?: { message?: string } }>( + requireWs(), + "config.patch", + { + raw: JSON.stringify({ + channels: { + telegram: { + botToken: { + source: "env", + provider: "default", + id: missingEnvVar, + }, + accounts: { + default: { + enabled: true, + }, + }, + }, + }, + }), + baseHash: beforeHash, + }, + ); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("active SecretRef resolution failed"); + const afterHash = await getConfigHash(); + expect(afterHash).toBe(beforeHash); + }); }); describe("gateway server sessions", () => { diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index d0fa574e5a6..d6a62a62cab 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -348,6 +348,39 @@ describe("secrets runtime snapshot", () => { expect(snapshot.config.channels?.matrix?.accessToken).toBe("default-matrix-token"); }); + it("can skip auth-profile SecretRef resolution when includeAuthStoreRefs is false", async () => { + const missingEnvVar = `OPENCLAW_MISSING_AUTH_PROFILE_SECRET_${Date.now()}`; + delete process.env[missingEnvVar]; + + const loadAuthStore = () => + loadAuthStoreWithProfiles({ + "custom:token": { + type: "token", + provider: "custom", + tokenRef: { source: "env", provider: "default", id: missingEnvVar }, + }, + }); + + await expect( + prepareSecretsRuntimeSnapshot({ + config: asConfig({}), + env: {}, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore, + }), + ).rejects.toThrow(`Environment variable "${missingEnvVar}" is missing or empty.`); + + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({}), + env: {}, + includeAuthStoreRefs: false, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore, + }); + + expect(snapshot.authStores).toEqual([]); + }); + it("ignores Matrix password refs that are shadowed by scoped env access tokens", async () => { const snapshot = await prepareSecretsRuntimeSnapshot({ config: asConfig({ diff --git a/src/secrets/runtime.ts b/src/secrets/runtime.ts index 5300baa19ef..b6c8272af85 100644 --- a/src/secrets/runtime.ts +++ b/src/secrets/runtime.ts @@ -162,6 +162,7 @@ export async function prepareSecretsRuntimeSnapshot(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv; agentDirs?: string[]; + includeAuthStoreRefs?: boolean; loadAuthStore?: (agentDir?: string) => AuthProfileStore; /** Test override for discovered loadable plugins and their origins. */ loadablePluginOrigins?: ReadonlyMap; @@ -185,20 +186,22 @@ export async function prepareSecretsRuntimeSnapshot(params: { loadablePluginOrigins, }); + const includeAuthStoreRefs = params.includeAuthStoreRefs ?? true; + const authStores: Array<{ agentDir: string; store: AuthProfileStore }> = []; const loadAuthStore = params.loadAuthStore ?? loadAuthProfileStoreForSecretsRuntime; const candidateDirs = params.agentDirs?.length ? [...new Set(params.agentDirs.map((entry) => resolveUserPath(entry, runtimeEnv)))] : collectCandidateAgentDirs(resolvedConfig, runtimeEnv); - - const authStores: Array<{ agentDir: string; store: AuthProfileStore }> = []; - for (const agentDir of candidateDirs) { - const store = structuredClone(loadAuthStore(agentDir)); - collectAuthStoreAssignments({ - store, - context, - agentDir, - }); - authStores.push({ agentDir, store }); + if (includeAuthStoreRefs) { + for (const agentDir of candidateDirs) { + const store = structuredClone(loadAuthStore(agentDir)); + collectAuthStoreAssignments({ + store, + context, + agentDir, + }); + authStores.push({ agentDir, store }); + } } if (context.assignments.length > 0) { diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index bb52a5c4eab..c6f4f31d8e4 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1602,6 +1602,7 @@ export function renderApp(state: AppViewState) { gatewayUrl: state.settings.gatewayUrl, assistantName: state.assistantName, configPath: state.configSnapshot?.path ?? null, + rawAvailable: typeof state.configSnapshot?.raw === "string", excludeSections: [ ...COMMUNICATION_SECTION_KEYS, ...AUTOMATION_SECTION_KEYS, @@ -1672,6 +1673,7 @@ export function renderApp(state: AppViewState) { gatewayUrl: state.settings.gatewayUrl, assistantName: state.assistantName, configPath: state.configSnapshot?.path ?? null, + rawAvailable: typeof state.configSnapshot?.raw === "string", navRootLabel: "Communication", includeSections: [...COMMUNICATION_SECTION_KEYS], includeVirtualSections: false, @@ -1736,6 +1738,7 @@ export function renderApp(state: AppViewState) { gatewayUrl: state.settings.gatewayUrl, assistantName: state.assistantName, configPath: state.configSnapshot?.path ?? null, + rawAvailable: typeof state.configSnapshot?.raw === "string", navRootLabel: "Appearance", includeSections: [...APPEARANCE_SECTION_KEYS], includeVirtualSections: true, @@ -1800,6 +1803,7 @@ export function renderApp(state: AppViewState) { gatewayUrl: state.settings.gatewayUrl, assistantName: state.assistantName, configPath: state.configSnapshot?.path ?? null, + rawAvailable: typeof state.configSnapshot?.raw === "string", navRootLabel: "Automation", includeSections: [...AUTOMATION_SECTION_KEYS], includeVirtualSections: false, @@ -1864,6 +1868,7 @@ export function renderApp(state: AppViewState) { gatewayUrl: state.settings.gatewayUrl, assistantName: state.assistantName, configPath: state.configSnapshot?.path ?? null, + rawAvailable: typeof state.configSnapshot?.raw === "string", navRootLabel: "Infrastructure", includeSections: [...INFRASTRUCTURE_SECTION_KEYS], includeVirtualSections: false, @@ -1924,6 +1929,7 @@ export function renderApp(state: AppViewState) { gatewayUrl: state.settings.gatewayUrl, assistantName: state.assistantName, configPath: state.configSnapshot?.path ?? null, + rawAvailable: typeof state.configSnapshot?.raw === "string", navRootLabel: "AI & Agents", includeSections: [...AI_AGENTS_SECTION_KEYS], includeVirtualSections: false, diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts index 471342a3012..513dd69d251 100644 --- a/ui/src/ui/controllers/config.test.ts +++ b/ui/src/ui/controllers/config.test.ts @@ -110,6 +110,21 @@ describe("applyConfigSnapshot", () => { expect(state.configRawOriginal).toBe('{ "original": true }'); expect(state.configFormOriginal).toEqual({ original: true }); }); + + it("forces form mode when the snapshot does not include raw text", () => { + const state = createState(); + state.configFormMode = "raw"; + + applyConfigSnapshot(state, { + config: { gateway: { mode: "local" } }, + valid: true, + issues: [], + raw: null, + }); + + expect(state.configFormMode).toBe("form"); + expect(state.configRaw).toBe('{\n "gateway": {\n "mode": "local"\n }\n}\n'); + }); }); describe("updateConfigFormValue", () => { @@ -242,6 +257,7 @@ describe("applyConfig", () => { state.configRaw = '{\n agent: { workspace: "~/openclaw" }\n}\n'; state.configSnapshot = { hash: "hash-123", + raw: "{\n}\n", }; await applyConfig(state); diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index a019c14cee3..5155d2b2dcc 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -78,7 +78,11 @@ export function applyConfigSchema(state: ConfigState, res: ConfigSchemaResponse) export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot) { state.configSnapshot = snapshot; - const rawFromSnapshot = + const rawAvailable = typeof snapshot.raw === "string"; + if (!rawAvailable && state.configFormMode === "raw") { + state.configFormMode = "form"; + } + const rawFromSnapshot: string = typeof snapshot.raw === "string" ? snapshot.raw : snapshot.config && typeof snapshot.config === "object" @@ -117,6 +121,9 @@ function asJsonSchema(value: unknown): JsonSchema | null { * gateway's Zod validation always sees correctly typed values. */ function serializeFormForSubmit(state: ConfigState): string { + if (state.configFormMode === "raw" && typeof state.configSnapshot?.raw !== "string") { + throw new Error("Raw config editing is unavailable for this snapshot. Switch to Form mode."); + } if (state.configFormMode !== "form" || !state.configForm) { return state.configRaw; } diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts index f2fac6c036a..7c2a30b2b3b 100644 --- a/ui/src/ui/views/config-form.node.ts +++ b/ui/src/ui/views/config-form.node.ts @@ -79,9 +79,7 @@ const icons = { stroke-linejoin="round" > - + `, edit: html` @@ -105,6 +103,21 @@ type FieldMeta = { tags: string[]; }; +function isSecretRefObject(value: unknown): value is { + source: string; + id: string; + provider?: string; +} { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const candidate = value as Record; + if (typeof candidate.source !== "string" || typeof candidate.id !== "string") { + return false; + } + return candidate.provider === undefined || typeof candidate.provider === "string"; +} + type SensitiveRenderParams = { path: Array; value: unknown; @@ -153,16 +166,20 @@ function renderSensitiveToggleButton(params: { type="button" class="btn btn--icon ${state.isRevealed ? "active" : ""}" style="width:28px;height:28px;padding:0;" - title=${state.canReveal - ? state.isRevealed - ? "Hide value" - : "Reveal value" - : "Disable stream mode to reveal value"} - aria-label=${state.canReveal - ? state.isRevealed - ? "Hide value" - : "Reveal value" - : "Disable stream mode to reveal value"} + title=${ + state.canReveal + ? state.isRevealed + ? "Hide value" + : "Reveal value" + : "Disable stream mode to reveal value" + } + aria-label=${ + state.canReveal + ? state.isRevealed + ? "Hide value" + : "Reveal value" + : "Disable stream mode to reveal value" + } aria-pressed=${state.isRevealed} ?disabled=${params.disabled || !state.canReveal} @click=${() => params.onToggleSensitivePath?.(params.path)} @@ -392,6 +409,7 @@ export function renderNode(params: { value: unknown; path: Array; hints: ConfigUiHints; + rawAvailable?: boolean; unsupported: Set; disabled: boolean; showLabel?: boolean; @@ -537,10 +555,9 @@ export function renderNode(params: { (opt) => html` ` - : nothing} + : nothing + } `; @@ -879,6 +919,7 @@ function renderObject(params: { value: unknown; path: Array; hints: ConfigUiHints; + rawAvailable?: boolean; unsupported: Set; disabled: boolean; showLabel?: boolean; @@ -897,6 +938,7 @@ function renderObject(params: { disabled, onPatch, searchCriteria, + rawAvailable, revealSensitive, isSensitivePathRevealed, onToggleSensitivePath, @@ -938,6 +980,7 @@ function renderObject(params: { value: obj[propKey], path: [...path, propKey], hints, + rawAvailable, unsupported, disabled, searchCriteria: childSearchCriteria, @@ -947,22 +990,25 @@ function renderObject(params: { onPatch, }), )} - ${allowExtra - ? renderMapField({ - schema: additional, - value: obj, - path, - hints, - unsupported, - disabled, - reservedKeys: reserved, - searchCriteria: childSearchCriteria, - revealSensitive, - isSensitivePathRevealed, - onToggleSensitivePath, - onPatch, - }) - : nothing} + ${ + allowExtra + ? renderMapField({ + schema: additional, + value: obj, + path, + hints, + rawAvailable, + unsupported, + disabled, + reservedKeys: reserved, + searchCriteria: childSearchCriteria, + revealSensitive, + isSensitivePathRevealed, + onToggleSensitivePath, + onPatch, + }) + : nothing + } `; // For top-level, don't wrap in collapsible @@ -995,6 +1041,7 @@ function renderArray(params: { value: unknown; path: Array; hints: ConfigUiHints; + rawAvailable?: boolean; unsupported: Set; disabled: boolean; showLabel?: boolean; @@ -1013,6 +1060,7 @@ function renderArray(params: { disabled, onPatch, searchCriteria, + rawAvailable, revealSensitive, isSensitivePathRevealed, onToggleSensitivePath, @@ -1059,9 +1107,12 @@ function renderArray(params: { ${help ? html`
${help}
` : nothing} - ${arr.length === 0 - ? html`
No items yet. Click "Add" to create one.
` - : html` + ${ + arr.length === 0 + ? html` +
No items yet. Click "Add" to create one.
+ ` + : html`
${arr.map( (item, idx) => html` @@ -1088,6 +1139,7 @@ function renderArray(params: { value: item, path: [...path, idx], hints, + rawAvailable, unsupported, disabled, searchCriteria: childSearchCriteria, @@ -1102,7 +1154,8 @@ function renderArray(params: { `, )}
- `} + ` + } `; } @@ -1112,6 +1165,7 @@ function renderMapField(params: { value: Record; path: Array; hints: ConfigUiHints; + rawAvailable?: boolean; unsupported: Set; disabled: boolean; reservedKeys: Set; @@ -1126,6 +1180,7 @@ function renderMapField(params: { value, path, hints, + rawAvailable, unsupported, disabled, reservedKeys, @@ -1175,9 +1230,12 @@ function renderMapField(params: { - ${visibleEntries.length === 0 - ? html`
No custom entries.
` - : html` + ${ + visibleEntries.length === 0 + ? html` +
No custom entries.
+ ` + : html`
${visibleEntries.map(([key, entryValue]) => { const valuePath = [...path, key]; @@ -1229,16 +1287,17 @@ function renderMapField(params: {
- ${anySchema - ? html` + ${ + anySchema + ? html`
- `} + ` + }
`; - })()} + })() + }
- ${props.issues.length > 0 - ? html`
+ ${ + props.issues.length > 0 + ? html`
${JSON.stringify(props.issues, null, 2)}
` - : nothing} + : nothing + }
`;