diff --git a/CHANGELOG.md b/CHANGELOG.md index 5de1f1b05f5..5653cc86e54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146) - Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts. - Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`. +- Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. Thanks @vincentkoc. - Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. Thanks @vincentkoc. - Webhooks/runtime: move auth earlier and tighten pre-auth body limits and timeouts across bundled webhook handlers, including slow-body handling for Mattermost slash commands. Thanks @vincentkoc. - Subagents/follow-ups: require the same controller ownership checks for `/subagents send` as other control actions, so leaf sessions cannot message nested child runs they do not control. Thanks @vincentkoc. @@ -56,6 +57,7 @@ Docs: https://docs.openclaw.ai - CLI: avoid loading provider discovery during startup model normalization. (#46522) Thanks @ItsAditya-xyz and @vincentkoc. - Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc. - ACP: require admin scope for mutating internal actions. (#46789) Thanks @tdjackey and @vincentkoc. +- Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman. ## 2026.3.13 diff --git a/src/channels/account-snapshot-fields.test.ts b/src/channels/account-snapshot-fields.test.ts index 6ccd03ccc21..b6cf92a7836 100644 --- a/src/channels/account-snapshot-fields.test.ts +++ b/src/channels/account-snapshot-fields.test.ts @@ -24,4 +24,14 @@ describe("projectSafeChannelAccountSnapshotFields", () => { signingSecretStatus: "configured_unavailable", // pragma: allowlist secret }); }); + + it("strips embedded credentials from baseUrl fields", () => { + const snapshot = projectSafeChannelAccountSnapshotFields({ + baseUrl: "https://bob:secret@chat.example.test", + }); + + expect(snapshot).toEqual({ + baseUrl: "https://chat.example.test/", + }); + }); }); diff --git a/src/channels/account-snapshot-fields.ts b/src/channels/account-snapshot-fields.ts index 72d745beac0..bfdc7ed6381 100644 --- a/src/channels/account-snapshot-fields.ts +++ b/src/channels/account-snapshot-fields.ts @@ -1,3 +1,4 @@ +import { stripUrlUserInfo } from "../shared/net/url-userinfo.js"; import type { ChannelAccountSnapshot } from "./plugins/types.core.js"; // Read-only status commands project a safe subset of account fields into snapshots @@ -203,7 +204,7 @@ export function projectSafeChannelAccountSnapshotFields( : {}), ...projectCredentialSnapshotFields(account), ...(readTrimmedString(record, "baseUrl") - ? { baseUrl: readTrimmedString(record, "baseUrl") } + ? { baseUrl: stripUrlUserInfo(readTrimmedString(record, "baseUrl")!) } : {}), ...(readBoolean(record, "allowUnmentionedGroups") !== undefined ? { allowUnmentionedGroups: readBoolean(record, "allowUnmentionedGroups") } diff --git a/src/commands/auth-choice.preferred-provider.ts b/src/commands/auth-choice.preferred-provider.ts index 959754625bc..49251a88f87 100644 --- a/src/commands/auth-choice.preferred-provider.ts +++ b/src/commands/auth-choice.preferred-provider.ts @@ -1,6 +1,4 @@ import type { OpenClawConfig } from "../config/config.js"; -import { resolveProviderPluginChoice } from "../plugins/provider-wizard.js"; -import { resolvePluginProviders } from "../plugins/providers.js"; import type { AuthChoice } from "./onboard-types.js"; const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { @@ -53,17 +51,21 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial> = { vllm: "vllm", }; -export function resolvePreferredProviderForAuthChoice(params: { +export async function resolvePreferredProviderForAuthChoice(params: { choice: AuthChoice; config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; -}): string | undefined { +}): Promise { const preferred = PREFERRED_PROVIDER_BY_AUTH_CHOICE[params.choice]; if (preferred) { return preferred; } + const [{ resolveProviderPluginChoice }, { resolvePluginProviders }] = await Promise.all([ + import("../plugins/provider-wizard.js"), + import("../plugins/providers.js"), + ]); const providers = resolvePluginProviders({ config: params.config, workspaceDir: params.workspaceDir, diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index d5a59e48d46..e74c0e1c31f 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -1352,7 +1352,7 @@ describe("applyAuthChoice", () => { }); describe("resolvePreferredProviderForAuthChoice", () => { - it("maps known and unknown auth choices", () => { + it("maps known and unknown auth choices", async () => { const scenarios = [ { authChoice: "github-copilot" as const, expectedProvider: "github-copilot" }, { authChoice: "qwen-portal" as const, expectedProvider: "qwen-portal" }, @@ -1361,9 +1361,9 @@ describe("resolvePreferredProviderForAuthChoice", () => { { authChoice: "unknown" as AuthChoice, expectedProvider: undefined }, ] as const; for (const scenario of scenarios) { - expect(resolvePreferredProviderForAuthChoice({ choice: scenario.authChoice })).toBe( - scenario.expectedProvider, - ); + await expect( + resolvePreferredProviderForAuthChoice({ choice: scenario.authChoice }), + ).resolves.toBe(scenario.expectedProvider); } }); }); diff --git a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts index b27e52fcf7c..0657a77b3e1 100644 --- a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts +++ b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts @@ -23,7 +23,7 @@ vi.mock("./auth-choice-prompt.js", () => ({ vi.mock("./auth-choice.js", () => ({ applyAuthChoice: mocks.applyAuthChoice, - resolvePreferredProviderForAuthChoice: vi.fn(() => undefined), + resolvePreferredProviderForAuthChoice: vi.fn(async () => undefined), })); vi.mock("./model-picker.js", async (importActual) => { diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index 78bcc88ca5f..ca56ee25275 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -110,7 +110,7 @@ export async function promptAuthConfig( allowKeep: true, ignoreAllowlist: true, includeProviderPluginSetups: true, - preferredProvider: resolvePreferredProviderForAuthChoice({ + preferredProvider: await resolvePreferredProviderForAuthChoice({ choice: authChoice, config: next, }), diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts index 01007aa7aa2..d6e1440eb20 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts @@ -64,11 +64,11 @@ export async function applyNonInteractivePluginProviderChoice(params: { : undefined; const preferredProviderId = prefixedProviderId || - resolvePreferredProviderForAuthChoice({ + (await resolvePreferredProviderForAuthChoice({ choice: params.authChoice, config: params.nextConfig, workspaceDir, - }); + })); const resolutionConfig = buildIsolatedProviderResolutionConfig( params.nextConfig, preferredProviderId, diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index 51d38b1a9af..f7f5539eb5a 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -173,6 +173,24 @@ describe("config plugin validation", () => { } }); + it("does not fail validation for the implicit default memory slot when plugins config is explicit", async () => { + const res = validateConfigObjectWithPlugins( + { + agents: { list: [{ id: "pi" }] }, + plugins: { + entries: { acpx: { enabled: true } }, + }, + }, + { + env: { + ...suiteEnv(), + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(suiteHome, "missing-bundled-plugins"), + }, + }, + ); + expect(res.ok).toBe(true); + }); + it("warns for removed legacy plugin ids instead of failing validation", async () => { const removedId = "google-antigravity-auth"; const res = validateInSuite({ diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index e173be34ec8..89aa4e1d121 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -163,6 +163,36 @@ describe("redactConfigSnapshot", () => { expect(result.config).toEqual(snapshot.config); }); + it("removes embedded credentials from URL-valued endpoint fields", () => { + const raw = `{ + models: { + providers: { + openai: { + baseUrl: "https://alice:secret@example.test/v1", + }, + }, + }, +}`; + const snapshot = makeSnapshot( + { + models: { + providers: { + openai: { + baseUrl: "https://alice:secret@example.test/v1", + }, + }, + }, + }, + raw, + ); + + const result = redactConfigSnapshot(snapshot); + const cfg = result.config as typeof snapshot.config; + expect(cfg.models.providers.openai.baseUrl).toBe(REDACTED_SENTINEL); + expect(result.raw).toContain(REDACTED_SENTINEL); + expect(result.raw).not.toContain("alice:secret@"); + }); + it("does not redact maxTokens-style fields", () => { const snapshot = makeSnapshot({ maxTokens: 16384, @@ -890,6 +920,25 @@ describe("redactConfigSnapshot", () => { }); describe("restoreRedactedValues", () => { + it("restores redacted URL endpoint fields on round-trip", () => { + const incoming = { + models: { + providers: { + openai: { baseUrl: REDACTED_SENTINEL }, + }, + }, + }; + const original = { + models: { + providers: { + openai: { baseUrl: "https://alice:secret@example.test/v1" }, + }, + }, + }; + const result = restoreRedactedValues(incoming, original, mainSchemaHints); + expect(result.models.providers.openai.baseUrl).toBe("https://alice:secret@example.test/v1"); + }); + it("restores sentinel values from original config", () => { const incoming = { gateway: { auth: { token: REDACTED_SENTINEL } }, diff --git a/src/config/redact-snapshot.ts b/src/config/redact-snapshot.ts index a80d1debb03..7c4eb5e50c5 100644 --- a/src/config/redact-snapshot.ts +++ b/src/config/redact-snapshot.ts @@ -1,5 +1,6 @@ import JSON5 from "json5"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { stripUrlUserInfo } from "../shared/net/url-userinfo.js"; import { replaceSensitiveValuesInRaw, shouldFallbackToStructuredRawRedaction, @@ -28,6 +29,10 @@ function isWholeObjectSensitivePath(path: string): boolean { return lowered.endsWith("serviceaccount") || lowered.endsWith("serviceaccountref"); } +function isUserInfoUrlPath(path: string): boolean { + return path.endsWith(".baseUrl") || path.endsWith(".httpUrl"); +} + function collectSensitiveStrings(value: unknown, values: string[]): void { if (typeof value === "string") { if (!isEnvVarPlaceholder(value)) { @@ -212,6 +217,14 @@ function redactObjectWithLookup( ) { // Keep primitives at explicitly-sensitive paths fully redacted. result[key] = REDACTED_SENTINEL; + } else if (typeof value === "string" && isUserInfoUrlPath(path)) { + const scrubbed = stripUrlUserInfo(value); + if (scrubbed !== value) { + values.push(value); + result[key] = REDACTED_SENTINEL; + } else { + result[key] = value; + } } break; } @@ -229,6 +242,14 @@ function redactObjectWithLookup( ) { result[key] = REDACTED_SENTINEL; values.push(value); + } else if (typeof value === "string" && isUserInfoUrlPath(path)) { + const scrubbed = stripUrlUserInfo(value); + if (scrubbed !== value) { + values.push(value); + result[key] = REDACTED_SENTINEL; + } else { + result[key] = value; + } } else if (typeof value === "object" && value !== null) { result[key] = redactObjectGuessing(value, path, values, hints); } @@ -293,6 +314,14 @@ function redactObjectGuessing( ) { collectSensitiveStrings(value, values); result[key] = REDACTED_SENTINEL; + } else if (typeof value === "string" && isUserInfoUrlPath(dotPath)) { + const scrubbed = stripUrlUserInfo(value); + if (scrubbed !== value) { + values.push(value); + result[key] = REDACTED_SENTINEL; + } else { + result[key] = value; + } } else if (typeof value === "object" && value !== null) { result[key] = redactObjectGuessing(value, dotPath, values, hints); } else { @@ -624,7 +653,10 @@ function restoreRedactedValuesWithLookup( for (const candidate of [path, wildcardPath]) { if (lookup.has(candidate)) { matched = true; - if (value === REDACTED_SENTINEL) { + if ( + value === REDACTED_SENTINEL && + (hints[candidate]?.sensitive === true || isUserInfoUrlPath(path)) + ) { result[key] = restoreOriginalValueOrThrow({ key, path: candidate, original: orig }); } else if (typeof value === "object" && value !== null) { result[key] = restoreRedactedValuesWithLookup(value, orig[key], lookup, candidate, hints); @@ -634,7 +666,11 @@ function restoreRedactedValuesWithLookup( } if (!matched) { const markedNonSensitive = isExplicitlyNonSensitivePath(hints, [path, wildcardPath]); - if (!markedNonSensitive && isSensitivePath(path) && value === REDACTED_SENTINEL) { + if ( + !markedNonSensitive && + value === REDACTED_SENTINEL && + (isSensitivePath(path) || isUserInfoUrlPath(path)) + ) { result[key] = restoreOriginalValueOrThrow({ key, path, original: orig }); } else if (typeof value === "object" && value !== null) { result[key] = restoreRedactedValuesGuessing(value, orig[key], path, hints); @@ -674,8 +710,8 @@ function restoreRedactedValuesGuessing( const wildcardPath = prefix ? `${prefix}.*` : "*"; if ( !isExplicitlyNonSensitivePath(hints, [path, wildcardPath]) && - isSensitivePath(path) && - value === REDACTED_SENTINEL + value === REDACTED_SENTINEL && + (isSensitivePath(path) || isUserInfoUrlPath(path)) ) { result[key] = restoreOriginalValueOrThrow({ key, path, original: orig }); } else if (typeof value === "object" && value !== null) { diff --git a/src/config/validation.ts b/src/config/validation.ts index 686dbb0ed43..1486ea07182 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -528,8 +528,17 @@ function validateConfigObjectWithPluginsBase( } } + // The default memory slot is inferred; only a user-configured slot should block startup. + const pluginSlots = pluginsConfig?.slots; + const hasExplicitMemorySlot = + pluginSlots !== undefined && Object.prototype.hasOwnProperty.call(pluginSlots, "memory"); const memorySlot = normalizedPlugins.slots.memory; - if (typeof memorySlot === "string" && memorySlot.trim() && !knownIds.has(memorySlot)) { + if ( + hasExplicitMemorySlot && + typeof memorySlot === "string" && + memorySlot.trim() && + !knownIds.has(memorySlot) + ) { pushMissingPluginIssue("plugins.slots.memory", memorySlot); } diff --git a/src/shared/net/url-userinfo.ts b/src/shared/net/url-userinfo.ts new file mode 100644 index 00000000000..d9374a3d4c2 --- /dev/null +++ b/src/shared/net/url-userinfo.ts @@ -0,0 +1,13 @@ +export function stripUrlUserInfo(value: string): string { + try { + const parsed = new URL(value); + if (!parsed.username && !parsed.password) { + return value; + } + parsed.username = ""; + parsed.password = ""; + return parsed.toString(); + } catch { + return value; + } +} diff --git a/src/wizard/onboarding.test.ts b/src/wizard/onboarding.test.ts index e6bbfd146fa..14c3183c323 100644 --- a/src/wizard/onboarding.test.ts +++ b/src/wizard/onboarding.test.ts @@ -11,7 +11,7 @@ import type { WizardPrompter, WizardSelectParams } from "./prompts.js"; const ensureAuthProfileStore = vi.hoisted(() => vi.fn(() => ({ profiles: {} }))); const promptAuthChoiceGrouped = vi.hoisted(() => vi.fn(async () => "skip")); const applyAuthChoice = vi.hoisted(() => vi.fn(async (args) => ({ config: args.config }))); -const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(() => "openai")); +const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(async () => "openai")); const warnIfModelConfigLooksOff = vi.hoisted(() => vi.fn(async () => {})); const applyPrimaryModel = vi.hoisted(() => vi.fn((cfg) => cfg)); const promptDefaultModel = vi.hoisted(() => vi.fn(async () => ({ config: null, model: null }))); diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index e8265efd49e..d2c35a022da 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -464,7 +464,7 @@ export async function runOnboardingWizard( allowKeep: true, ignoreAllowlist: true, includeProviderPluginSetups: true, - preferredProvider: resolvePreferredProviderForAuthChoice({ + preferredProvider: await resolvePreferredProviderForAuthChoice({ choice: authChoice, config: nextConfig, workspaceDir,