Merge branch 'main' into vincentkoc-code/ghsa-pf4r-media-trust
This commit is contained in:
commit
1c49103178
@ -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
|
||||
|
||||
|
||||
@ -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/",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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") }
|
||||
|
||||
@ -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<Record<AuthChoice, string>> = {
|
||||
@ -53,17 +51,21 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
|
||||
vllm: "vllm",
|
||||
};
|
||||
|
||||
export function resolvePreferredProviderForAuthChoice(params: {
|
||||
export async function resolvePreferredProviderForAuthChoice(params: {
|
||||
choice: AuthChoice;
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): string | undefined {
|
||||
}): Promise<string | undefined> {
|
||||
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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -110,7 +110,7 @@ export async function promptAuthConfig(
|
||||
allowKeep: true,
|
||||
ignoreAllowlist: true,
|
||||
includeProviderPluginSetups: true,
|
||||
preferredProvider: resolvePreferredProviderForAuthChoice({
|
||||
preferredProvider: await resolvePreferredProviderForAuthChoice({
|
||||
choice: authChoice,
|
||||
config: next,
|
||||
}),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 } },
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
13
src/shared/net/url-userinfo.ts
Normal file
13
src/shared/net/url-userinfo.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 })));
|
||||
|
||||
@ -464,7 +464,7 @@ export async function runOnboardingWizard(
|
||||
allowKeep: true,
|
||||
ignoreAllowlist: true,
|
||||
includeProviderPluginSetups: true,
|
||||
preferredProvider: resolvePreferredProviderForAuthChoice({
|
||||
preferredProvider: await resolvePreferredProviderForAuthChoice({
|
||||
choice: authChoice,
|
||||
config: nextConfig,
|
||||
workspaceDir,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user