Merge remote-tracking branch 'origin/main' into fix/codex-transport-path

This commit is contained in:
xaeon2026 2026-03-20 18:44:52 -04:00
commit a33b992a47
27 changed files with 2560 additions and 315 deletions

View File

@ -153,6 +153,7 @@ Docs: https://docs.openclaw.ai
- Hardening: refresh stale device pairing requests and pending metadata (#50695) Thanks @smaeljaish771 and @joshavant.
- Gateway: harden OpenResponses file-context escaping (#50782) Thanks @YLChen-007 and @joshavant.
- LINE: harden Express webhook parsing to verified raw body (#51202) Thanks @gladiator9797 and @joshavant.
- Exec: harden host env override handling across gateway and node (#51207) Thanks @gladiator9797 and @joshavant.
- xAI/models: rename the bundled Grok 4.20 catalog entries to the GA IDs and normalize saved deprecated beta IDs at runtime so existing configs and sessions keep resolving. (#50772) thanks @Jaaneek
### Fixes
@ -186,6 +187,7 @@ Docs: https://docs.openclaw.ai
- Agents/compaction: add an opt-in post-compaction session JSONL truncation step that drops summarized transcript entries while preserving the retained branch tail and live session metadata. (#41021) thanks @thirumaleshp.
- Telegram/routing: fail loud when `message send` targets an unknown non-default Telegram `accountId`, instead of silently falling back to the channel-level bot token and sending through the wrong bot. (#50853) Thanks @hclsys.
- Web search: align onboarding, configure, and finalize with plugin-owned provider contracts, including disabled-provider recovery, config-aware credential hooks, and runtime-visible summaries. (#50935) Thanks @gumadeiras.
- Agents/replay: sanitize malformed assistant tool-call replay blocks before provider replay so follow-up Anthropic requests do not inherit the downstream `replace` crash. (#50005) Thanks @jalehman.
### Breaking

View File

@ -1,5 +1,10 @@
import Foundation
struct HostEnvOverrideDiagnostics: Equatable {
var blockedKeys: [String]
var invalidKeys: [String]
}
enum HostEnvSanitizer {
/// Generated from src/infra/host-env-security-policy.json via scripts/generate-host-env-security-policy-swift.mjs.
/// Parity is validated by src/infra/host-env-security.policy-parity.test.ts.
@ -41,6 +46,67 @@ enum HostEnvSanitizer {
return filtered.isEmpty ? nil : filtered
}
private static func isPortableHead(_ scalar: UnicodeScalar) -> Bool {
let value = scalar.value
return value == 95 || (65...90).contains(value) || (97...122).contains(value)
}
private static func isPortableTail(_ scalar: UnicodeScalar) -> Bool {
let value = scalar.value
return self.isPortableHead(scalar) || (48...57).contains(value)
}
private static func normalizeOverrideKey(_ rawKey: String) -> String? {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return nil }
guard let first = key.unicodeScalars.first, self.isPortableHead(first) else {
return nil
}
for scalar in key.unicodeScalars.dropFirst() {
if self.isPortableTail(scalar) || scalar == "(" || scalar == ")" {
continue
}
return nil
}
return key
}
private static func sortedUnique(_ values: [String]) -> [String] {
Array(Set(values)).sorted()
}
static func inspectOverrides(
overrides: [String: String]?,
blockPathOverrides: Bool = true) -> HostEnvOverrideDiagnostics
{
guard let overrides else {
return HostEnvOverrideDiagnostics(blockedKeys: [], invalidKeys: [])
}
var blocked: [String] = []
var invalid: [String] = []
for (rawKey, _) in overrides {
let candidate = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard let normalized = self.normalizeOverrideKey(rawKey) else {
invalid.append(candidate.isEmpty ? rawKey : candidate)
continue
}
let upper = normalized.uppercased()
if blockPathOverrides, upper == "PATH" {
blocked.append(upper)
continue
}
if self.isBlockedOverride(upper) || self.isBlocked(upper) {
blocked.append(upper)
continue
}
}
return HostEnvOverrideDiagnostics(
blockedKeys: self.sortedUnique(blocked),
invalidKeys: self.sortedUnique(invalid))
}
static func sanitize(overrides: [String: String]?, shellWrapper: Bool = false) -> [String: String] {
var merged: [String: String] = [:]
for (rawKey, value) in ProcessInfo.processInfo.environment {
@ -57,8 +123,7 @@ enum HostEnvSanitizer {
guard let effectiveOverrides else { return merged }
for (rawKey, value) in effectiveOverrides {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
guard let key = self.normalizeOverrideKey(rawKey) else { continue }
let upper = key.uppercased()
// PATH is part of the security boundary (command resolution + safe-bin checks). Never
// allow request-scoped PATH overrides from agents/gateways.

View File

@ -63,7 +63,23 @@ enum HostEnvSecurityPolicy {
"OPENSSL_ENGINES",
"PYTHONSTARTUP",
"WGETRC",
"CURL_HOME"
"CURL_HOME",
"CLASSPATH",
"CGO_CFLAGS",
"CGO_LDFLAGS",
"GOFLAGS",
"CORECLR_PROFILER_PATH",
"PHPRC",
"PHP_INI_SCAN_DIR",
"DENO_DIR",
"BUN_CONFIG_REGISTRY",
"LUA_PATH",
"LUA_CPATH",
"GEM_HOME",
"GEM_PATH",
"BUNDLE_GEMFILE",
"COMPOSER_HOME",
"XDG_CONFIG_HOME"
]
static let blockedOverridePrefixes: [String] = [

View File

@ -465,6 +465,23 @@ actor MacNodeRuntime {
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
: self.mainSessionKey
let runId = UUID().uuidString
let envOverrideDiagnostics = HostEnvSanitizer.inspectOverrides(
overrides: params.env,
blockPathOverrides: true)
if !envOverrideDiagnostics.blockedKeys.isEmpty || !envOverrideDiagnostics.invalidKeys.isEmpty {
var details: [String] = []
if !envOverrideDiagnostics.blockedKeys.isEmpty {
details.append("blocked override keys: \(envOverrideDiagnostics.blockedKeys.joined(separator: ", "))")
}
if !envOverrideDiagnostics.invalidKeys.isEmpty {
details.append(
"invalid non-portable override keys: \(envOverrideDiagnostics.invalidKeys.joined(separator: ", "))")
}
return Self.errorResponse(
req,
code: .invalidRequest,
message: "SYSTEM_RUN_DENIED: environment override rejected (\(details.joined(separator: "; ")))")
}
let evaluation = await ExecApprovalEvaluator.evaluate(
command: command,
rawCommand: params.rawCommand,

View File

@ -33,4 +33,24 @@ struct HostEnvSanitizerTests {
let env = HostEnvSanitizer.sanitize(overrides: ["OPENCLAW_TOKEN": "secret"])
#expect(env["OPENCLAW_TOKEN"] == "secret")
}
@Test func `inspect overrides rejects blocked and invalid keys`() {
let diagnostics = HostEnvSanitizer.inspectOverrides(overrides: [
"CLASSPATH": "/tmp/evil-classpath",
"BAD-KEY": "x",
"ProgramFiles(x86)": "C:\\Program Files (x86)",
])
#expect(diagnostics.blockedKeys == ["CLASSPATH"])
#expect(diagnostics.invalidKeys == ["BAD-KEY"])
}
@Test func `sanitize accepts Windows-style override key names`() {
let env = HostEnvSanitizer.sanitize(overrides: [
"ProgramFiles(x86)": "D:\\SDKs",
"CommonProgramFiles(x86)": "D:\\Common",
])
#expect(env["ProgramFiles(x86)"] == "D:\\SDKs")
#expect(env["CommonProgramFiles(x86)"] == "D:\\Common")
}
}

View File

@ -21,6 +21,32 @@ struct MacNodeRuntimeTests {
#expect(response.ok == false)
}
@Test func `handle invoke rejects blocked system run env override before execution`() async throws {
let runtime = MacNodeRuntime()
let params = OpenClawSystemRunParams(
command: ["/bin/sh", "-lc", "echo ok"],
env: ["CLASSPATH": "/tmp/evil-classpath"])
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(
BridgeInvokeRequest(id: "req-2c", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json))
#expect(response.ok == false)
#expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true)
#expect(response.error?.message.contains("CLASSPATH") == true)
}
@Test func `handle invoke rejects invalid system run env override key before execution`() async throws {
let runtime = MacNodeRuntime()
let params = OpenClawSystemRunParams(
command: ["/bin/sh", "-lc", "echo ok"],
env: ["BAD-KEY": "x"])
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(
BridgeInvokeRequest(id: "req-2d", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json))
#expect(response.ok == false)
#expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true)
#expect(response.error?.message.contains("BAD-KEY") == true)
}
@Test func `handle invoke rejects empty system which`() async throws {
let runtime = MacNodeRuntime()
let params = OpenClawSystemWhichParams(bins: [])

View File

@ -0,0 +1,101 @@
import OpenAI from "openai";
import { describe, expect, it } from "vitest";
import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js";
import plugin from "./index.js";
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? "";
const LIVE_MODEL_ID =
process.env.OPENCLAW_LIVE_OPENROUTER_PLUGIN_MODEL?.trim() || "openai/gpt-5.4-nano";
const liveEnabled = OPENROUTER_API_KEY.trim().length > 0 && process.env.OPENCLAW_LIVE_TEST === "1";
const describeLive = liveEnabled ? describe : describe.skip;
function registerOpenRouterPlugin() {
const providers: unknown[] = [];
const speechProviders: unknown[] = [];
const mediaProviders: unknown[] = [];
const imageProviders: unknown[] = [];
plugin.register(
createTestPluginApi({
id: "openrouter",
name: "OpenRouter Provider",
source: "test",
config: {},
runtime: {} as never,
registerProvider: (provider) => {
providers.push(provider);
},
registerSpeechProvider: (provider) => {
speechProviders.push(provider);
},
registerMediaUnderstandingProvider: (provider) => {
mediaProviders.push(provider);
},
registerImageGenerationProvider: (provider) => {
imageProviders.push(provider);
},
}),
);
return { providers, speechProviders, mediaProviders, imageProviders };
}
describe("openrouter plugin", () => {
it("registers the expected provider surfaces", () => {
const { providers, speechProviders, mediaProviders, imageProviders } =
registerOpenRouterPlugin();
expect(providers).toHaveLength(1);
expect(
providers.map(
(provider) =>
// oxlint-disable-next-line typescript/no-explicit-any
(provider as any).id,
),
).toEqual(["openrouter"]);
expect(speechProviders).toHaveLength(0);
expect(mediaProviders).toHaveLength(0);
expect(imageProviders).toHaveLength(0);
});
});
describeLive("openrouter plugin live", () => {
it("registers an OpenRouter provider that can complete a live request", async () => {
const { providers } = registerOpenRouterPlugin();
const provider =
// oxlint-disable-next-line typescript/no-explicit-any
providers.find((entry) => (entry as any).id === "openrouter");
expect(provider).toBeDefined();
// oxlint-disable-next-line typescript/no-explicit-any
const resolved = (provider as any).resolveDynamicModel?.({
provider: "openrouter",
modelId: LIVE_MODEL_ID,
modelRegistry: {
find() {
return null;
},
},
});
expect(resolved).toMatchObject({
provider: "openrouter",
id: LIVE_MODEL_ID,
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
});
const client = new OpenAI({
apiKey: OPENROUTER_API_KEY,
baseURL: resolved?.baseUrl,
});
const response = await client.chat.completions.create({
model: resolved?.id ?? LIVE_MODEL_ID,
messages: [{ role: "user", content: "Reply with exactly OK." }],
max_tokens: 16,
});
expect(response.choices[0]?.message?.content?.trim()).toMatch(/^OK[.!]?$/);
}, 30_000);
});

View File

@ -297,7 +297,7 @@ const defaultHeavyUnitFileLimit =
: isMacMiniProfile
? 90
: testProfile === "low"
? 32
? 36
: highMemLocalHost
? 80
: 60;
@ -307,7 +307,7 @@ const defaultHeavyUnitLaneCount =
: isMacMiniProfile
? 6
: testProfile === "low"
? 3
? 4
: highMemLocalHost
? 5
: 4;
@ -437,6 +437,22 @@ const unitSingletonEntries = unitSingletonBuckets.map((files, index) => ({
unitSingletonBuckets.length === 1 ? "unit-singleton" : `unit-singleton-${String(index + 1)}`,
args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=forks", ...files],
}));
const unitThreadEntries =
unitThreadSingletonFiles.length > 0
? [
{
name: "unit-threads",
args: [
"vitest",
"run",
"--config",
"vitest.unit.config.ts",
"--pool=threads",
...unitThreadSingletonFiles,
],
},
]
: [];
const baseRuns = [
...(shouldSplitUnitRuns
? [
@ -469,10 +485,7 @@ const baseRuns = [
file,
],
})),
...unitThreadSingletonFiles.map((file) => ({
name: `${path.basename(file, ".test.ts")}-threads`,
args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=threads", file],
})),
...unitThreadEntries,
...unitVmForkSingletonFiles.map((file) => ({
name: `${path.basename(file, ".test.ts")}-vmforks`,
args: [
@ -695,7 +708,9 @@ const defaultTopLevelParallelLimit =
testProfile === "serial"
? 1
: testProfile === "low"
? 2
? lowMemLocalHost
? 2
: 3
: testProfile === "max"
? 5
: highMemLocalHost
@ -1287,9 +1302,16 @@ if (serialPrefixRuns.length > 0) {
if (failedSerialPrefix !== undefined) {
process.exit(failedSerialPrefix);
}
const deferredRunConcurrency = isMacMiniProfile ? 3 : testProfile === "low" ? 2 : undefined;
const failedDeferredParallel = isMacMiniProfile
? await runEntriesWithLimit(deferredParallelRuns, passthroughOptionArgs, 3)
: await runEntries(deferredParallelRuns, passthroughOptionArgs);
? await runEntriesWithLimit(deferredParallelRuns, passthroughOptionArgs, deferredRunConcurrency)
: deferredRunConcurrency
? await runEntriesWithLimit(
deferredParallelRuns,
passthroughOptionArgs,
deferredRunConcurrency,
)
: await runEntries(deferredParallelRuns, passthroughOptionArgs);
if (failedDeferredParallel !== undefined) {
process.exit(failedDeferredParallel);
}

View File

@ -25,14 +25,25 @@ const readJson = (filePath, fallback) => {
};
const normalizeRepoPath = (value) => value.split(path.sep).join("/");
const repoRoot = path.resolve(process.cwd());
const normalizeTrackedRepoPath = (value) => {
const normalizedValue = typeof value === "string" ? value : String(value ?? "");
const repoRelative = path.isAbsolute(normalizedValue)
? path.relative(repoRoot, path.resolve(normalizedValue))
: normalizedValue;
if (path.isAbsolute(repoRelative) || repoRelative.startsWith("..") || repoRelative === "") {
return normalizeRepoPath(normalizedValue);
}
return normalizeRepoPath(repoRelative);
};
const normalizeManifestEntries = (entries) =>
entries
.map((entry) =>
typeof entry === "string"
? { file: normalizeRepoPath(entry), reason: "" }
? { file: normalizeTrackedRepoPath(entry), reason: "" }
: {
file: normalizeRepoPath(String(entry?.file ?? "")),
file: normalizeTrackedRepoPath(String(entry?.file ?? "")),
reason: typeof entry?.reason === "string" ? entry.reason : "",
},
)
@ -60,7 +71,7 @@ export function loadUnitTimingManifest() {
const files = Object.fromEntries(
Object.entries(raw.files ?? {})
.map(([file, value]) => {
const normalizedFile = normalizeRepoPath(file);
const normalizedFile = normalizeTrackedRepoPath(file);
const durationMs =
Number.isFinite(value?.durationMs) && value.durationMs >= 0 ? value.durationMs : null;
const testCount =
@ -97,7 +108,7 @@ export function loadUnitMemoryHotspotManifest() {
const files = Object.fromEntries(
Object.entries(raw.files ?? {})
.map(([file, value]) => {
const normalizedFile = normalizeRepoPath(file);
const normalizedFile = normalizeTrackedRepoPath(file);
const deltaKb =
Number.isFinite(value?.deltaKb) && value.deltaKb > 0 ? Math.round(value.deltaKb) : null;
const sources = Array.isArray(value?.sources)

View File

@ -57,10 +57,24 @@ function parseArgs(argv) {
return args;
}
const normalizeRepoPath = (value) => value.split(path.sep).join("/");
const repoRoot = path.resolve(process.cwd());
const normalizeTrackedRepoPath = (value) => {
const normalizedValue = typeof value === "string" ? value : String(value ?? "");
const repoRelative = path.isAbsolute(normalizedValue)
? path.relative(repoRoot, path.resolve(normalizedValue))
: normalizedValue;
if (path.isAbsolute(repoRelative) || repoRelative.startsWith("..") || repoRelative === "") {
return normalizeRepoPath(normalizedValue);
}
return normalizeRepoPath(repoRelative);
};
function mergeHotspotEntry(aggregated, file, value) {
if (!(Number.isFinite(value?.deltaKb) && value.deltaKb > 0)) {
return;
}
const normalizedFile = normalizeTrackedRepoPath(file);
const normalizeSourceLabel = (source) => {
const separator = source.lastIndexOf(":");
if (separator === -1) {
@ -75,9 +89,9 @@ function mergeHotspotEntry(aggregated, file, value) {
.filter((source) => typeof source === "string" && source.length > 0)
.map(normalizeSourceLabel)
: [];
const previous = aggregated.get(file);
const previous = aggregated.get(normalizedFile);
if (!previous) {
aggregated.set(file, {
aggregated.set(normalizedFile, {
deltaKb: Math.round(value.deltaKb),
sources: [...new Set(nextSources)],
});

View File

@ -9,7 +9,7 @@ function parseArgs(argv) {
config: "vitest.unit.config.ts",
out: unitTimingManifestPath,
reportPath: "",
limit: 128,
limit: 256,
defaultDurationMs: 250,
};
for (let i = 0; i < argv.length; i += 1) {
@ -50,6 +50,17 @@ function parseArgs(argv) {
}
const normalizeRepoPath = (value) => value.split(path.sep).join("/");
const repoRoot = path.resolve(process.cwd());
const normalizeTrackedRepoPath = (value) => {
const normalizedValue = typeof value === "string" ? value : String(value ?? "");
const repoRelative = path.isAbsolute(normalizedValue)
? path.relative(repoRoot, path.resolve(normalizedValue))
: normalizedValue;
if (path.isAbsolute(repoRelative) || repoRelative.startsWith("..") || repoRelative === "") {
return normalizeRepoPath(normalizedValue);
}
return normalizeRepoPath(repoRelative);
};
const opts = parseArgs(process.argv.slice(2));
const reportPath =
@ -74,7 +85,7 @@ const report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
const files = Object.fromEntries(
(report.testResults ?? [])
.map((result) => {
const file = typeof result.name === "string" ? normalizeRepoPath(result.name) : "";
const file = typeof result.name === "string" ? normalizeTrackedRepoPath(result.name) : "";
const start = typeof result.startTime === "number" ? result.startTime : 0;
const end = typeof result.endTime === "number" ? result.endTime : 0;
const testCount = Array.isArray(result.assertionResults) ? result.assertionResults.length : 0;

View File

@ -130,6 +130,22 @@ describe("exec PATH login shell merge", () => {
expect(shellPathMock).not.toHaveBeenCalled();
});
it("fails closed when a blocked runtime override key is requested", async () => {
if (isWin) {
return;
}
const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
await expect(
tool.execute("call-blocked-runtime-env", {
command: "echo ok",
env: { CLASSPATH: "/tmp/evil-classpath" },
}),
).rejects.toThrow(
/Security Violation: Environment variable 'CLASSPATH' is forbidden during host execution\./,
);
});
it("does not apply login-shell PATH when probe rejects unregistered absolute SHELL", async () => {
if (isWin) {
return;

View File

@ -3,6 +3,7 @@ import path from "node:path";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { type ExecHost, loadExecApprovals, maxAsk, minSecurity } from "../infra/exec-approvals.js";
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
import { sanitizeHostExecEnvWithDiagnostics } from "../infra/host-env-security.js";
import {
getShellPathFromLoginShell,
resolveShellEnvFallbackTimeoutMs,
@ -25,9 +26,7 @@ import {
renderExecHostLabel,
resolveApprovalRunningNoticeMs,
runExecProcess,
sanitizeHostBaseEnv,
execSchema,
validateHostEnv,
} from "./bash-tools.exec-runtime.js";
import type {
ExecElevatedDefaults,
@ -362,24 +361,58 @@ export function createExecTool(
}
const inheritedBaseEnv = coerceEnv(process.env);
const baseEnv = host === "sandbox" ? inheritedBaseEnv : sanitizeHostBaseEnv(inheritedBaseEnv);
// Logic: Sandbox gets raw env. Host (gateway/node) must pass validation.
// We validate BEFORE merging to prevent any dangerous vars from entering the stream.
if (host !== "sandbox" && params.env) {
validateHostEnv(params.env);
const hostEnvResult =
host === "sandbox"
? null
: sanitizeHostExecEnvWithDiagnostics({
baseEnv: inheritedBaseEnv,
overrides: params.env,
blockPathOverrides: true,
});
if (
hostEnvResult &&
params.env &&
(hostEnvResult.rejectedOverrideBlockedKeys.length > 0 ||
hostEnvResult.rejectedOverrideInvalidKeys.length > 0)
) {
const blockedKeys = hostEnvResult.rejectedOverrideBlockedKeys;
const invalidKeys = hostEnvResult.rejectedOverrideInvalidKeys;
const pathBlocked = blockedKeys.includes("PATH");
if (pathBlocked && blockedKeys.length === 1 && invalidKeys.length === 0) {
throw new Error(
"Security Violation: Custom 'PATH' variable is forbidden during host execution.",
);
}
if (blockedKeys.length === 1 && invalidKeys.length === 0) {
throw new Error(
`Security Violation: Environment variable '${blockedKeys[0]}' is forbidden during host execution.`,
);
}
const details: string[] = [];
if (blockedKeys.length > 0) {
details.push(`blocked override keys: ${blockedKeys.join(", ")}`);
}
if (invalidKeys.length > 0) {
details.push(`invalid non-portable override keys: ${invalidKeys.join(", ")}`);
}
const suffix = details.join("; ");
if (pathBlocked) {
throw new Error(
`Security Violation: Custom 'PATH' variable is forbidden during host execution (${suffix}).`,
);
}
throw new Error(`Security Violation: ${suffix}.`);
}
const mergedEnv = params.env ? { ...baseEnv, ...params.env } : baseEnv;
const env = sandbox
? buildSandboxEnv({
defaultPath: DEFAULT_PATH,
paramsEnv: params.env,
sandboxEnv: sandbox.env,
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
})
: mergedEnv;
const env =
sandbox && host === "sandbox"
? buildSandboxEnv({
defaultPath: DEFAULT_PATH,
paramsEnv: params.env,
sandboxEnv: sandbox.env,
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
})
: (hostEnvResult?.env ?? inheritedBaseEnv);
if (!sandbox && host === "gateway" && !params.env?.PATH) {
const shellPath = getShellPathFromLoginShell({

View File

@ -16,6 +16,7 @@ import {
decodeHtmlEntitiesInObject,
wrapOllamaCompatNumCtx,
wrapStreamFnRepairMalformedToolCallArguments,
wrapStreamFnSanitizeMalformedToolCalls,
wrapStreamFnTrimToolCallNames,
} from "./attempt.js";
@ -779,6 +780,552 @@ describe("wrapStreamFnTrimToolCallNames", () => {
});
});
describe("wrapStreamFnSanitizeMalformedToolCalls", () => {
it("drops malformed assistant tool calls from outbound context before provider replay", async () => {
const messages = [
{
role: "assistant",
stopReason: "error",
content: [{ type: "toolCall", name: "read", arguments: {} }],
},
{
role: "user",
content: [{ type: "text", text: "retry" }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] };
expect(seenContext.messages).toEqual([
{
role: "user",
content: [{ type: "text", text: "retry" }],
},
]);
expect(seenContext.messages).not.toBe(messages);
});
it("preserves outbound context when all assistant tool calls are valid", async () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] };
expect(seenContext.messages).toBe(messages);
});
it("preserves sessions_spawn attachment payloads on replay", async () => {
const attachmentContent = "INLINE_ATTACHMENT_PAYLOAD";
const messages = [
{
role: "assistant",
content: [
{
type: "toolUse",
id: "call_1",
name: " SESSIONS_SPAWN ",
input: {
task: "inspect attachment",
attachments: [{ name: "snapshot.txt", content: attachmentContent }],
},
},
],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(
baseFn as never,
new Set(["sessions_spawn"]),
);
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as {
messages: Array<{ content?: Array<Record<string, unknown>> }>;
};
const toolCall = seenContext.messages[0]?.content?.[0] as {
name?: string;
input?: { attachments?: Array<{ content?: string }> };
};
expect(toolCall.name).toBe("sessions_spawn");
expect(toolCall.input?.attachments?.[0]?.content).toBe(attachmentContent);
});
it("preserves allowlisted tool names that contain punctuation", async () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolUse", id: "call_1", name: "admin.export", input: { scope: "all" } }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(
baseFn as never,
new Set(["admin.export"]),
);
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] };
expect(seenContext.messages).toBe(messages);
});
it("normalizes provider-prefixed replayed tool names before provider replay", async () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolUse", id: "call_1", name: "functions.read", input: { path: "." } }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as {
messages: Array<{ content?: Array<{ name?: string }> }>;
};
expect(seenContext.messages[0]?.content?.[0]?.name).toBe("read");
});
it("canonicalizes mixed-case allowlisted tool names on replay", async () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "readfile", arguments: {} }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["ReadFile"]));
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as {
messages: Array<{ content?: Array<{ name?: string }> }>;
};
expect(seenContext.messages[0]?.content?.[0]?.name).toBe("ReadFile");
});
it("recovers blank replayed tool names from their ids", async () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolCall", id: "functionswrite4", name: " ", arguments: {} }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["write"]));
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as {
messages: Array<{ content?: Array<{ name?: string }> }>;
};
expect(seenContext.messages[0]?.content?.[0]?.name).toBe("write");
});
it("recovers mangled replayed tool names before dropping the call", async () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "functionsread3", arguments: {} }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as {
messages: Array<{ content?: Array<{ name?: string }> }>;
};
expect(seenContext.messages[0]?.content?.[0]?.name).toBe("read");
});
it("drops orphaned tool results after replay sanitization removes a tool-call turn", async () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolCall", name: "read", arguments: {} }],
stopReason: "error",
},
{
role: "toolResult",
toolCallId: "call_missing",
toolName: "read",
content: [{ type: "text", text: "stale result" }],
isError: false,
},
{
role: "user",
content: [{ type: "text", text: "retry" }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as {
messages: Array<{ role?: string }>;
};
expect(seenContext.messages).toEqual([
{
role: "user",
content: [{ type: "text", text: "retry" }],
},
]);
});
it("drops replayed tool calls that are no longer allowlisted", async () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "write", arguments: {} }],
},
{
role: "toolResult",
toolCallId: "call_1",
toolName: "write",
content: [{ type: "text", text: "stale result" }],
isError: false,
},
{
role: "user",
content: [{ type: "text", text: "retry" }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as {
messages: Array<{ role?: string }>;
};
expect(seenContext.messages).toEqual([
{
role: "user",
content: [{ type: "text", text: "retry" }],
},
]);
});
it("drops replayed tool names that are no longer allowlisted", async () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolUse", id: "call_1", name: "unknown_tool", input: { path: "." } }],
},
{
role: "toolResult",
toolCallId: "call_1",
toolName: "unknown_tool",
content: [{ type: "text", text: "stale result" }],
isError: false,
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] };
expect(seenContext.messages).toEqual([]);
});
it("drops ambiguous mangled replay names instead of guessing a tool", async () => {
const messages = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "functions.exec2", arguments: {} }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(
baseFn as never,
new Set(["exec", "exec2"]),
);
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] };
expect(seenContext.messages).toEqual([]);
});
it("preserves matching tool results for retained errored assistant turns", async () => {
const messages = [
{
role: "assistant",
stopReason: "error",
content: [
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
{ type: "toolCall", name: "read", arguments: {} },
],
},
{
role: "toolResult",
toolCallId: "call_1",
toolName: "read",
content: [{ type: "text", text: "kept result" }],
isError: false,
},
{
role: "user",
content: [{ type: "text", text: "retry" }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] };
expect(seenContext.messages).toEqual([
{
role: "assistant",
stopReason: "error",
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
},
{
role: "toolResult",
toolCallId: "call_1",
toolName: "read",
content: [{ type: "text", text: "kept result" }],
isError: false,
},
{
role: "user",
content: [{ type: "text", text: "retry" }],
},
]);
});
it("revalidates turn ordering after dropping an assistant replay turn", async () => {
const messages = [
{
role: "user",
content: [{ type: "text", text: "first" }],
},
{
role: "assistant",
stopReason: "error",
content: [{ type: "toolCall", name: "read", arguments: {} }],
},
{
role: "user",
content: [{ type: "text", text: "second" }],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]), {
validateGeminiTurns: false,
validateAnthropicTurns: true,
});
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as {
messages: Array<{ role?: string; content?: unknown[] }>;
};
expect(seenContext.messages).toEqual([
{
role: "user",
content: [
{ type: "text", text: "first" },
{ type: "text", text: "second" },
],
},
]);
});
it("drops orphaned Anthropic user tool_result blocks after replay sanitization", async () => {
const messages = [
{
role: "assistant",
content: [
{ type: "text", text: "partial response" },
{ type: "toolUse", name: "read", input: { path: "." } },
],
},
{
role: "user",
content: [
{ type: "toolResult", toolUseId: "call_1", content: [{ type: "text", text: "stale" }] },
{ type: "text", text: "retry" },
],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]), {
validateGeminiTurns: false,
validateAnthropicTurns: true,
});
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as {
messages: Array<{ role?: string; content?: unknown[] }>;
};
expect(seenContext.messages).toEqual([
{
role: "assistant",
content: [{ type: "text", text: "partial response" }],
},
{
role: "user",
content: [{ type: "text", text: "retry" }],
},
]);
});
it("drops orphaned Anthropic user tool_result blocks after dropping an assistant replay turn", async () => {
const messages = [
{
role: "user",
content: [{ type: "text", text: "first" }],
},
{
role: "assistant",
stopReason: "error",
content: [{ type: "toolUse", name: "read", input: { path: "." } }],
},
{
role: "user",
content: [
{ type: "toolResult", toolUseId: "call_1", content: [{ type: "text", text: "stale" }] },
{ type: "text", text: "second" },
],
},
];
const baseFn = vi.fn((_model, _context) =>
createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
);
const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]), {
validateGeminiTurns: false,
validateAnthropicTurns: true,
});
const stream = wrapped({} as never, { messages } as never, {} as never) as
| FakeWrappedStream
| Promise<FakeWrappedStream>;
await Promise.resolve(stream);
expect(baseFn).toHaveBeenCalledTimes(1);
const seenContext = baseFn.mock.calls[0]?.[1] as {
messages: Array<{ role?: string; content?: unknown[] }>;
};
expect(seenContext.messages).toEqual([
{
role: "user",
content: [
{ type: "text", text: "first" },
{ type: "text", text: "second" },
],
},
]);
});
});
describe("wrapStreamFnRepairMalformedToolCallArguments", () => {
async function invokeWrappedStream(baseFn: (...args: never[]) => unknown) {
return await invokeWrappedTestStream(

View File

@ -97,6 +97,7 @@ import { buildSystemPromptReport } from "../../system-prompt-report.js";
import { sanitizeToolCallIdsForCloudCodeAssist } from "../../tool-call-id.js";
import { resolveEffectiveToolFsWorkspaceOnly } from "../../tool-fs-policy.js";
import { normalizeToolName } from "../../tool-policy.js";
import type { TranscriptPolicy } from "../../transcript-policy.js";
import { resolveTranscriptPolicy } from "../../transcript-policy.js";
import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js";
import { isRunnerAbortError } from "../abort.js";
@ -648,6 +649,200 @@ function isToolCallBlockType(type: unknown): boolean {
return type === "toolCall" || type === "toolUse" || type === "functionCall";
}
const REPLAY_TOOL_CALL_NAME_MAX_CHARS = 64;
type ReplayToolCallBlock = {
type?: unknown;
id?: unknown;
name?: unknown;
input?: unknown;
arguments?: unknown;
};
type ReplayToolCallSanitizeReport = {
messages: AgentMessage[];
droppedAssistantMessages: number;
};
type AnthropicToolResultContentBlock = {
type?: unknown;
toolUseId?: unknown;
};
function isReplayToolCallBlock(block: unknown): block is ReplayToolCallBlock {
if (!block || typeof block !== "object") {
return false;
}
return isToolCallBlockType((block as { type?: unknown }).type);
}
function replayToolCallHasInput(block: ReplayToolCallBlock): boolean {
const hasInput = "input" in block ? block.input !== undefined && block.input !== null : false;
const hasArguments =
"arguments" in block ? block.arguments !== undefined && block.arguments !== null : false;
return hasInput || hasArguments;
}
function replayToolCallNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function resolveReplayToolCallName(
rawName: string,
rawId: string,
allowedToolNames?: Set<string>,
): string | null {
if (rawName.length > REPLAY_TOOL_CALL_NAME_MAX_CHARS * 2) {
return null;
}
const normalized = normalizeToolCallNameForDispatch(rawName, allowedToolNames, rawId);
const trimmed = normalized.trim();
if (!trimmed || trimmed.length > REPLAY_TOOL_CALL_NAME_MAX_CHARS || /\s/.test(trimmed)) {
return null;
}
if (!allowedToolNames || allowedToolNames.size === 0) {
return trimmed;
}
return resolveExactAllowedToolName(trimmed, allowedToolNames);
}
function sanitizeReplayToolCallInputs(
messages: AgentMessage[],
allowedToolNames?: Set<string>,
): ReplayToolCallSanitizeReport {
let changed = false;
let droppedAssistantMessages = 0;
const out: AgentMessage[] = [];
for (const message of messages) {
if (!message || typeof message !== "object" || message.role !== "assistant") {
out.push(message);
continue;
}
if (!Array.isArray(message.content)) {
out.push(message);
continue;
}
const nextContent: typeof message.content = [];
let messageChanged = false;
for (const block of message.content) {
if (!isReplayToolCallBlock(block)) {
nextContent.push(block);
continue;
}
const replayBlock = block as ReplayToolCallBlock;
if (!replayToolCallHasInput(replayBlock) || !replayToolCallNonEmptyString(replayBlock.id)) {
changed = true;
messageChanged = true;
continue;
}
const rawName = typeof replayBlock.name === "string" ? replayBlock.name : "";
const resolvedName = resolveReplayToolCallName(rawName, replayBlock.id, allowedToolNames);
if (!resolvedName) {
changed = true;
messageChanged = true;
continue;
}
if (replayBlock.name !== resolvedName) {
nextContent.push({ ...(block as object), name: resolvedName } as typeof block);
changed = true;
messageChanged = true;
continue;
}
nextContent.push(block);
}
if (messageChanged) {
changed = true;
if (nextContent.length > 0) {
out.push({ ...message, content: nextContent });
} else {
droppedAssistantMessages += 1;
}
continue;
}
out.push(message);
}
return {
messages: changed ? out : messages,
droppedAssistantMessages,
};
}
function sanitizeAnthropicReplayToolResults(messages: AgentMessage[]): AgentMessage[] {
let changed = false;
const out: AgentMessage[] = [];
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index];
if (!message || typeof message !== "object" || message.role !== "user") {
out.push(message);
continue;
}
if (!Array.isArray(message.content)) {
out.push(message);
continue;
}
const previous = messages[index - 1];
const validToolUseIds = new Set<string>();
if (previous && typeof previous === "object" && previous.role === "assistant") {
const previousContent = (previous as { content?: unknown }).content;
if (Array.isArray(previousContent)) {
for (const block of previousContent) {
if (!block || typeof block !== "object") {
continue;
}
const typedBlock = block as { type?: unknown; id?: unknown };
if (typedBlock.type !== "toolUse" || typeof typedBlock.id !== "string") {
continue;
}
const trimmedId = typedBlock.id.trim();
if (trimmedId) {
validToolUseIds.add(trimmedId);
}
}
}
}
const nextContent = message.content.filter((block) => {
if (!block || typeof block !== "object") {
return true;
}
const typedBlock = block as AnthropicToolResultContentBlock;
if (typedBlock.type !== "toolResult" || typeof typedBlock.toolUseId !== "string") {
return true;
}
return validToolUseIds.size > 0 && validToolUseIds.has(typedBlock.toolUseId);
});
if (nextContent.length === message.content.length) {
out.push(message);
continue;
}
changed = true;
if (nextContent.length > 0) {
out.push({ ...message, content: nextContent });
continue;
}
out.push({
...message,
content: [{ type: "text", text: "[tool results omitted]" }],
} as AgentMessage);
}
return changed ? out : messages;
}
function normalizeToolCallIdsInMessage(message: unknown): void {
if (!message || typeof message !== "object") {
return;
@ -796,6 +991,43 @@ export function wrapStreamFnTrimToolCallNames(
};
}
export function wrapStreamFnSanitizeMalformedToolCalls(
baseFn: StreamFn,
allowedToolNames?: Set<string>,
transcriptPolicy?: Pick<TranscriptPolicy, "validateGeminiTurns" | "validateAnthropicTurns">,
): StreamFn {
return (model, context, options) => {
const ctx = context as unknown as { messages?: unknown };
const messages = ctx?.messages;
if (!Array.isArray(messages)) {
return baseFn(model, context, options);
}
const sanitized = sanitizeReplayToolCallInputs(messages as AgentMessage[], allowedToolNames);
if (sanitized.messages === messages) {
return baseFn(model, context, options);
}
let nextMessages = sanitizeToolUseResultPairing(sanitized.messages, {
preserveErroredAssistantResults: true,
});
if (transcriptPolicy?.validateAnthropicTurns) {
nextMessages = sanitizeAnthropicReplayToolResults(nextMessages);
}
if (sanitized.droppedAssistantMessages > 0 || transcriptPolicy?.validateAnthropicTurns) {
if (transcriptPolicy?.validateGeminiTurns) {
nextMessages = validateGeminiTurns(nextMessages);
}
if (transcriptPolicy?.validateAnthropicTurns) {
nextMessages = validateAnthropicTurns(nextMessages);
}
}
const nextContext = {
...(context as unknown as Record<string, unknown>),
messages: nextMessages,
} as unknown;
return baseFn(model, nextContext as typeof context, options);
};
}
function extractBalancedJsonPrefix(raw: string): string | null {
let start = 0;
while (start < raw.length && /\s/.test(raw[start] ?? "")) {
@ -2100,6 +2332,11 @@ export async function runEmbeddedAttempt(
// Some models emit tool names with surrounding whitespace (e.g. " read ").
// pi-agent-core dispatches tool calls with exact string matching, so normalize
// names on the live response stream before tool execution.
activeSession.agent.streamFn = wrapStreamFnSanitizeMalformedToolCalls(
activeSession.agent.streamFn,
allowedToolNames,
transcriptPolicy,
);
activeSession.agent.streamFn = wrapStreamFnTrimToolCallNames(
activeSession.agent.streamFn,
allowedToolNames,

View File

@ -195,6 +195,10 @@ export type ToolCallInputRepairOptions = {
allowedToolNames?: Iterable<string>;
};
export type ToolUseResultPairingOptions = {
preserveErroredAssistantResults?: boolean;
};
export function stripToolResultDetails(messages: AgentMessage[]): AgentMessage[] {
let touched = false;
const out: AgentMessage[] = [];
@ -327,8 +331,11 @@ export function sanitizeToolCallInputs(
return repairToolCallInputs(messages, options).messages;
}
export function sanitizeToolUseResultPairing(messages: AgentMessage[]): AgentMessage[] {
return repairToolUseResultPairing(messages).messages;
export function sanitizeToolUseResultPairing(
messages: AgentMessage[],
options?: ToolUseResultPairingOptions,
): AgentMessage[] {
return repairToolUseResultPairing(messages, options).messages;
}
export type ToolUseRepairReport = {
@ -339,7 +346,10 @@ export type ToolUseRepairReport = {
moved: boolean;
};
export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRepairReport {
export function repairToolUseResultPairing(
messages: AgentMessage[],
options?: ToolUseResultPairingOptions,
): ToolUseRepairReport {
// Anthropic (and Cloud Code Assist) reject transcripts where assistant tool calls are not
// immediately followed by matching tool results. Session files can end up with results
// displaced (e.g. after user turns) or duplicated. Repair by:
@ -390,18 +400,6 @@ export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRep
const assistant = msg as Extract<AgentMessage, { role: "assistant" }>;
// Skip tool call extraction for aborted or errored assistant messages.
// When stopReason is "error" or "aborted", the tool_use blocks may be incomplete
// (e.g., partialJson: true) and should not have synthetic tool_results created.
// Creating synthetic results for incomplete tool calls causes API 400 errors:
// "unexpected tool_use_id found in tool_result blocks"
// See: https://github.com/openclaw/openclaw/issues/4597
const stopReason = (assistant as { stopReason?: string }).stopReason;
if (stopReason === "error" || stopReason === "aborted") {
out.push(msg);
continue;
}
const toolCalls = extractToolCallsFromAssistant(assistant);
if (toolCalls.length === 0) {
out.push(msg);
@ -459,6 +457,28 @@ export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRep
}
}
// Aborted/errored assistant turns should never synthesize missing tool results, but
// the replay sanitizer can still legitimately retain real tool results for surviving
// tool calls in the same turn after malformed siblings are dropped.
const stopReason = (assistant as { stopReason?: string }).stopReason;
if (stopReason === "error" || stopReason === "aborted") {
out.push(msg);
if (options?.preserveErroredAssistantResults) {
for (const toolCall of toolCalls) {
const result = spanResultsById.get(toolCall.id);
if (!result) {
continue;
}
pushToolResult(result);
}
}
for (const rem of remainder) {
out.push(rem);
}
i = j - 1;
continue;
}
out.push(msg);
if (spanResultsById.size > 0 && remainder.length > 0) {

View File

@ -1,11 +1,11 @@
import bravePlugin from "../../extensions/brave/index.js";
import firecrawlPlugin from "../../extensions/firecrawl/index.js";
import googlePlugin from "../../extensions/google/index.js";
import moonshotPlugin from "../../extensions/moonshot/index.js";
import perplexityPlugin from "../../extensions/perplexity/index.js";
import tavilyPlugin from "../../extensions/tavily/index.js";
import xaiPlugin from "../../extensions/xai/index.js";
import type { OpenClawPluginApi } from "./types.js";
import bravePlugin from "../extensions/brave/index.js";
import firecrawlPlugin from "../extensions/firecrawl/index.js";
import googlePlugin from "../extensions/google/index.js";
import moonshotPlugin from "../extensions/moonshot/index.js";
import perplexityPlugin from "../extensions/perplexity/index.js";
import tavilyPlugin from "../extensions/tavily/index.js";
import xaiPlugin from "../extensions/xai/index.js";
import type { OpenClawPluginApi } from "./plugins/types.js";
type RegistrablePlugin = {
id: string;

View File

@ -56,7 +56,23 @@
"OPENSSL_ENGINES",
"PYTHONSTARTUP",
"WGETRC",
"CURL_HOME"
"CURL_HOME",
"CLASSPATH",
"CGO_CFLAGS",
"CGO_LDFLAGS",
"GOFLAGS",
"CORECLR_PROFILER_PATH",
"PHPRC",
"PHP_INI_SCAN_DIR",
"DENO_DIR",
"BUN_CONFIG_REGISTRY",
"LUA_PATH",
"LUA_CPATH",
"GEM_HOME",
"GEM_PATH",
"BUNDLE_GEMFILE",
"COMPOSER_HOME",
"XDG_CONFIG_HOME"
],
"blockedOverridePrefixes": ["GIT_CONFIG_", "NPM_CONFIG_"],
"blockedPrefixes": ["DYLD_", "LD_", "BASH_FUNC_"]

View File

@ -8,6 +8,7 @@ import {
isDangerousHostEnvVarName,
normalizeEnvVarKey,
sanitizeHostExecEnv,
sanitizeHostExecEnvWithDiagnostics,
sanitizeSystemRunEnvOverrides,
} from "./host-env-security.js";
import { OPENCLAW_CLI_ENV_VALUE } from "./openclaw-exec-env.js";
@ -114,6 +115,10 @@ describe("sanitizeHostExecEnv", () => {
GIT_CONFIG_GLOBAL: "/tmp/gitconfig",
SHELLOPTS: "xtrace",
PS4: "$(touch /tmp/pwned)",
CLASSPATH: "/tmp/evil-classpath",
GOFLAGS: "-mod=mod",
PHPRC: "/tmp/evil-php.ini",
XDG_CONFIG_HOME: "/tmp/evil-config",
SAFE: "ok",
},
});
@ -128,6 +133,10 @@ describe("sanitizeHostExecEnv", () => {
expect(env.GIT_CONFIG_GLOBAL).toBeUndefined();
expect(env.SHELLOPTS).toBeUndefined();
expect(env.PS4).toBeUndefined();
expect(env.CLASSPATH).toBeUndefined();
expect(env.GOFLAGS).toBeUndefined();
expect(env.PHPRC).toBeUndefined();
expect(env.XDG_CONFIG_HOME).toBeUndefined();
expect(env.SAFE).toBe("ok");
expect(env.HOME).toBe("/tmp/trusted-home");
expect(env.ZDOTDIR).toBe("/tmp/trusted-zdotdir");
@ -183,7 +192,7 @@ describe("sanitizeHostExecEnv", () => {
expect(env.OPENCLAW_CLI).toBe(OPENCLAW_CLI_ENV_VALUE);
});
it("drops non-string inherited values and non-portable inherited keys", () => {
it("drops non-string inherited values while preserving non-portable inherited keys", () => {
const env = sanitizeHostExecEnv({
baseEnv: {
PATH: "/usr/bin:/bin",
@ -191,6 +200,7 @@ describe("sanitizeHostExecEnv", () => {
// oxlint-disable-next-line typescript/no-explicit-any
BAD_NUMBER: 1 as any,
"NOT-PORTABLE": "x",
"ProgramFiles(x86)": "C:\\Program Files (x86)",
},
});
@ -198,6 +208,8 @@ describe("sanitizeHostExecEnv", () => {
OPENCLAW_CLI: OPENCLAW_CLI_ENV_VALUE,
PATH: "/usr/bin:/bin",
GOOD: "1",
"NOT-PORTABLE": "x",
"ProgramFiles(x86)": "C:\\Program Files (x86)",
});
});
});
@ -212,11 +224,58 @@ describe("isDangerousHostEnvOverrideVarName", () => {
expect(isDangerousHostEnvOverrideVarName("git_config_global")).toBe(true);
expect(isDangerousHostEnvOverrideVarName("GRADLE_USER_HOME")).toBe(true);
expect(isDangerousHostEnvOverrideVarName("gradle_user_home")).toBe(true);
expect(isDangerousHostEnvOverrideVarName("CLASSPATH")).toBe(true);
expect(isDangerousHostEnvOverrideVarName("classpath")).toBe(true);
expect(isDangerousHostEnvOverrideVarName("GOFLAGS")).toBe(true);
expect(isDangerousHostEnvOverrideVarName("goflags")).toBe(true);
expect(isDangerousHostEnvOverrideVarName("CORECLR_PROFILER_PATH")).toBe(true);
expect(isDangerousHostEnvOverrideVarName("coreclr_profiler_path")).toBe(true);
expect(isDangerousHostEnvOverrideVarName("XDG_CONFIG_HOME")).toBe(true);
expect(isDangerousHostEnvOverrideVarName("xdg_config_home")).toBe(true);
expect(isDangerousHostEnvOverrideVarName("BASH_ENV")).toBe(false);
expect(isDangerousHostEnvOverrideVarName("FOO")).toBe(false);
});
});
describe("sanitizeHostExecEnvWithDiagnostics", () => {
it("reports blocked and invalid requested overrides", () => {
const result = sanitizeHostExecEnvWithDiagnostics({
baseEnv: {
PATH: "/usr/bin:/bin",
},
overrides: {
PATH: "/tmp/evil",
CLASSPATH: "/tmp/evil-classpath",
SAFE_KEY: "ok",
"BAD-KEY": "bad",
},
});
expect(result.rejectedOverrideBlockedKeys).toEqual(["CLASSPATH", "PATH"]);
expect(result.rejectedOverrideInvalidKeys).toEqual(["BAD-KEY"]);
expect(result.env.SAFE_KEY).toBe("ok");
expect(result.env.PATH).toBe("/usr/bin:/bin");
expect(result.env.CLASSPATH).toBeUndefined();
});
it("allows Windows-style override names while still rejecting invalid keys", () => {
const result = sanitizeHostExecEnvWithDiagnostics({
baseEnv: {
PATH: "/usr/bin:/bin",
"ProgramFiles(x86)": "C:\\Program Files (x86)",
},
overrides: {
"ProgramFiles(x86)": "D:\\SDKs",
"BAD-KEY": "bad",
},
});
expect(result.rejectedOverrideBlockedKeys).toEqual([]);
expect(result.rejectedOverrideInvalidKeys).toEqual(["BAD-KEY"]);
expect(result.env["ProgramFiles(x86)"]).toBe("D:\\SDKs");
});
});
describe("normalizeEnvVarKey", () => {
it("normalizes and validates keys", () => {
expect(normalizeEnvVarKey(" OPENROUTER_API_KEY ")).toBe("OPENROUTER_API_KEY");

View File

@ -2,6 +2,7 @@ import HOST_ENV_SECURITY_POLICY_JSON from "./host-env-security-policy.json" with
import { markOpenClawExecEnv } from "./openclaw-exec-env.js";
const PORTABLE_ENV_VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
const WINDOWS_COMPAT_OVERRIDE_ENV_VAR_KEY = /^[A-Za-z_][A-Za-z0-9_()]*$/;
type HostEnvSecurityPolicy = {
blockedKeys: string[];
@ -42,6 +43,17 @@ export const HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEYS = new Set<string>(
HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEY_VALUES,
);
export type HostExecEnvSanitizationResult = {
env: Record<string, string>;
rejectedOverrideBlockedKeys: string[];
rejectedOverrideInvalidKeys: string[];
};
export type HostExecEnvOverrideDiagnostics = {
rejectedOverrideBlockedKeys: string[];
rejectedOverrideInvalidKeys: string[];
};
export function normalizeEnvVarKey(
rawKey: string,
options?: { portable?: boolean },
@ -56,6 +68,17 @@ export function normalizeEnvVarKey(
return key;
}
function normalizeHostOverrideEnvVarKey(rawKey: string): string | null {
const key = normalizeEnvVarKey(rawKey);
if (!key) {
return null;
}
if (PORTABLE_ENV_VAR_KEY.test(key) || WINDOWS_COMPAT_OVERRIDE_ENV_VAR_KEY.test(key)) {
return key;
}
return null;
}
export function isDangerousHostEnvVarName(rawKey: string): boolean {
const key = normalizeEnvVarKey(rawKey);
if (!key) {
@ -80,15 +103,16 @@ export function isDangerousHostEnvOverrideVarName(rawKey: string): boolean {
return HOST_DANGEROUS_OVERRIDE_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix));
}
function listNormalizedPortableEnvEntries(
function listNormalizedEnvEntries(
source: Record<string, string | undefined>,
options?: { portable?: boolean },
): Array<[string, string]> {
const entries: Array<[string, string]> = [];
for (const [rawKey, value] of Object.entries(source)) {
if (typeof value !== "string") {
continue;
}
const key = normalizeEnvVarKey(rawKey, { portable: true });
const key = normalizeEnvVarKey(rawKey, options);
if (!key) {
continue;
}
@ -97,41 +121,112 @@ function listNormalizedPortableEnvEntries(
return entries;
}
export function sanitizeHostExecEnv(params?: {
function sortUnique(values: Iterable<string>): string[] {
return Array.from(new Set(values)).toSorted((a, b) => a.localeCompare(b));
}
function sanitizeHostEnvOverridesWithDiagnostics(params?: {
overrides?: Record<string, string> | null;
blockPathOverrides?: boolean;
}): {
acceptedOverrides?: Record<string, string>;
rejectedOverrideBlockedKeys: string[];
rejectedOverrideInvalidKeys: string[];
} {
const overrides = params?.overrides ?? undefined;
if (!overrides) {
return {
acceptedOverrides: undefined,
rejectedOverrideBlockedKeys: [],
rejectedOverrideInvalidKeys: [],
};
}
const blockPathOverrides = params?.blockPathOverrides ?? true;
const acceptedOverrides: Record<string, string> = {};
const rejectedBlocked: string[] = [];
const rejectedInvalid: string[] = [];
for (const [rawKey, value] of Object.entries(overrides)) {
if (typeof value !== "string") {
continue;
}
const normalized = normalizeHostOverrideEnvVarKey(rawKey);
if (!normalized) {
const candidate = rawKey.trim();
rejectedInvalid.push(candidate || rawKey);
continue;
}
const upper = normalized.toUpperCase();
// PATH is part of the security boundary (command resolution + safe-bin checks). Never allow
// request-scoped PATH overrides from agents/gateways.
if (blockPathOverrides && upper === "PATH") {
rejectedBlocked.push(upper);
continue;
}
if (isDangerousHostEnvVarName(upper) || isDangerousHostEnvOverrideVarName(upper)) {
rejectedBlocked.push(upper);
continue;
}
acceptedOverrides[normalized] = value;
}
return {
acceptedOverrides,
rejectedOverrideBlockedKeys: sortUnique(rejectedBlocked),
rejectedOverrideInvalidKeys: sortUnique(rejectedInvalid),
};
}
export function sanitizeHostExecEnvWithDiagnostics(params?: {
baseEnv?: Record<string, string | undefined>;
overrides?: Record<string, string> | null;
blockPathOverrides?: boolean;
}): Record<string, string> {
}): HostExecEnvSanitizationResult {
const baseEnv = params?.baseEnv ?? process.env;
const overrides = params?.overrides ?? undefined;
const blockPathOverrides = params?.blockPathOverrides ?? true;
const merged: Record<string, string> = {};
for (const [key, value] of listNormalizedPortableEnvEntries(baseEnv)) {
for (const [key, value] of listNormalizedEnvEntries(baseEnv)) {
if (isDangerousHostEnvVarName(key)) {
continue;
}
merged[key] = value;
}
if (!overrides) {
return markOpenClawExecEnv(merged);
const overrideResult = sanitizeHostEnvOverridesWithDiagnostics({
overrides: params?.overrides ?? undefined,
blockPathOverrides: params?.blockPathOverrides ?? true,
});
if (overrideResult.acceptedOverrides) {
for (const [key, value] of Object.entries(overrideResult.acceptedOverrides)) {
merged[key] = value;
}
}
for (const [key, value] of listNormalizedPortableEnvEntries(overrides)) {
const upper = key.toUpperCase();
// PATH is part of the security boundary (command resolution + safe-bin checks). Never allow
// request-scoped PATH overrides from agents/gateways.
if (blockPathOverrides && upper === "PATH") {
continue;
}
if (isDangerousHostEnvVarName(upper) || isDangerousHostEnvOverrideVarName(upper)) {
continue;
}
merged[key] = value;
}
return {
env: markOpenClawExecEnv(merged),
rejectedOverrideBlockedKeys: overrideResult.rejectedOverrideBlockedKeys,
rejectedOverrideInvalidKeys: overrideResult.rejectedOverrideInvalidKeys,
};
}
return markOpenClawExecEnv(merged);
export function inspectHostExecEnvOverrides(params?: {
overrides?: Record<string, string> | null;
blockPathOverrides?: boolean;
}): HostExecEnvOverrideDiagnostics {
const result = sanitizeHostEnvOverridesWithDiagnostics(params);
return {
rejectedOverrideBlockedKeys: result.rejectedOverrideBlockedKeys,
rejectedOverrideInvalidKeys: result.rejectedOverrideInvalidKeys,
};
}
export function sanitizeHostExecEnv(params?: {
baseEnv?: Record<string, string | undefined>;
overrides?: Record<string, string> | null;
blockPathOverrides?: boolean;
}): Record<string, string> {
return sanitizeHostExecEnvWithDiagnostics(params).env;
}
export function sanitizeSystemRunEnvOverrides(params?: {
@ -146,7 +241,7 @@ export function sanitizeSystemRunEnvOverrides(params?: {
return overrides;
}
const filtered: Record<string, string> = {};
for (const [key, value] of listNormalizedPortableEnvEntries(overrides)) {
for (const [key, value] of listNormalizedEnvEntries(overrides, { portable: true })) {
if (!HOST_SHELL_WRAPPER_ALLOWED_OVERRIDE_ENV_KEYS.has(key.toUpperCase())) {
continue;
}

View File

@ -336,6 +336,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
preferMacAppExecHost: boolean;
runViaResponse?: ExecHostResponse | null;
command?: string[];
env?: Record<string, string>;
rawCommand?: string | null;
systemRunPlan?: SystemRunApprovalPlan | null;
cwd?: string;
@ -391,6 +392,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
client: {} as never,
params: {
command: params.command ?? ["echo", "ok"],
env: params.env,
rawCommand: params.rawCommand,
systemRunPlan: params.systemRunPlan,
cwd: params.cwd,
@ -1106,6 +1108,65 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
expectApprovalRequiredDenied({ sendNodeEvent, sendInvokeResult });
});
it("rejects blocked environment overrides before execution", async () => {
const { runCommand, sendInvokeResult } = await runSystemInvoke({
preferMacAppExecHost: false,
security: "full",
ask: "off",
env: { CLASSPATH: "/tmp/evil-classpath" },
});
expect(runCommand).not.toHaveBeenCalled();
expectInvokeErrorMessage(sendInvokeResult, {
message: "SYSTEM_RUN_DENIED: environment override rejected",
});
expectInvokeErrorMessage(sendInvokeResult, {
message: "CLASSPATH",
});
});
it("rejects blocked environment overrides for shell-wrapper commands", async () => {
const shellCommand =
process.platform === "win32"
? ["cmd.exe", "/d", "/s", "/c", "echo ok"]
: ["/bin/sh", "-lc", "echo ok"];
const { runCommand, sendInvokeResult } = await runSystemInvoke({
preferMacAppExecHost: false,
security: "full",
ask: "off",
command: shellCommand,
env: {
CLASSPATH: "/tmp/evil-classpath",
LANG: "C",
},
});
expect(runCommand).not.toHaveBeenCalled();
expectInvokeErrorMessage(sendInvokeResult, {
message: "SYSTEM_RUN_DENIED: environment override rejected",
});
expectInvokeErrorMessage(sendInvokeResult, {
message: "CLASSPATH",
});
});
it("rejects invalid non-portable environment override keys before execution", async () => {
const { runCommand, sendInvokeResult } = await runSystemInvoke({
preferMacAppExecHost: false,
security: "full",
ask: "off",
env: { "BAD-KEY": "x" },
});
expect(runCommand).not.toHaveBeenCalled();
expectInvokeErrorMessage(sendInvokeResult, {
message: "SYSTEM_RUN_DENIED: environment override rejected",
});
expectInvokeErrorMessage(sendInvokeResult, {
message: "BAD-KEY",
});
});
async function expectNestedEnvShellDenied(params: {
depth: number;
markerName: string;

View File

@ -14,7 +14,10 @@ import {
} from "../infra/exec-approvals.js";
import type { ExecHostRequest, ExecHostResponse, ExecHostRunResult } from "../infra/exec-host.js";
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
import { sanitizeSystemRunEnvOverrides } from "../infra/host-env-security.js";
import {
inspectHostExecEnvOverrides,
sanitizeSystemRunEnvOverrides,
} from "../infra/host-env-security.js";
import { normalizeSystemRunApprovalPlan } from "../infra/system-run-approval-binding.js";
import { resolveSystemRunCommandRequest } from "../infra/system-run-command.js";
import { logWarn } from "../logger.js";
@ -244,6 +247,34 @@ async function parseSystemRunPhase(
const sessionKey = opts.params.sessionKey?.trim() || "node";
const runId = opts.params.runId?.trim() || crypto.randomUUID();
const suppressNotifyOnExit = opts.params.suppressNotifyOnExit === true;
const envOverrideDiagnostics = inspectHostExecEnvOverrides({
overrides: opts.params.env ?? undefined,
blockPathOverrides: true,
});
if (
envOverrideDiagnostics.rejectedOverrideBlockedKeys.length > 0 ||
envOverrideDiagnostics.rejectedOverrideInvalidKeys.length > 0
) {
const details: string[] = [];
if (envOverrideDiagnostics.rejectedOverrideBlockedKeys.length > 0) {
details.push(
`blocked override keys: ${envOverrideDiagnostics.rejectedOverrideBlockedKeys.join(", ")}`,
);
}
if (envOverrideDiagnostics.rejectedOverrideInvalidKeys.length > 0) {
details.push(
`invalid non-portable override keys: ${envOverrideDiagnostics.rejectedOverrideInvalidKeys.join(", ")}`,
);
}
await opts.sendInvokeResult({
ok: false,
error: {
code: "INVALID_REQUEST",
message: `SYSTEM_RUN_DENIED: environment override rejected (${details.join("; ")})`,
},
});
return null;
}
const envOverrides = sanitizeSystemRunEnvOverrides({
overrides: opts.params.env ?? undefined,
shellWrapper: shellPayload !== null,

View File

@ -51,6 +51,13 @@ describe("node-host sanitizeEnv", () => {
expect(env.BASH_ENV).toBeUndefined();
});
});
it("preserves inherited non-portable Windows-style env keys", () => {
withEnv({ "ProgramFiles(x86)": "C:\\Program Files (x86)" }, () => {
const env = sanitizeEnv(undefined);
expect(env["ProgramFiles(x86)"]).toBe("C:\\Program Files (x86)");
});
});
});
describe("node-host output decoding", () => {

View File

@ -1,4 +1,4 @@
import { bundledWebSearchPluginRegistrations } from "./bundled-web-search-registry.js";
import { bundledWebSearchPluginRegistrations } from "../bundled-web-search-registry.js";
import { capturePluginRegistration } from "./captured-registration.js";
import type { PluginLoadOptions } from "./loader.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";

View File

@ -34,7 +34,7 @@ import volcenginePlugin from "../../../extensions/volcengine/index.js";
import xaiPlugin from "../../../extensions/xai/index.js";
import xiaomiPlugin from "../../../extensions/xiaomi/index.js";
import zaiPlugin from "../../../extensions/zai/index.js";
import { bundledWebSearchPluginRegistrations } from "../bundled-web-search-registry.js";
import { bundledWebSearchPluginRegistrations } from "../../bundled-web-search-registry.js";
import { createCapturedPluginRegistration } from "../captured-registration.js";
import { resolvePluginProviders } from "../providers.js";
import type {

View File

@ -1100,29 +1100,29 @@ description: test skill
},
] as const;
for (const testCase of cases) {
if (!testCase.supported) {
continue;
}
await Promise.all(
cases
.filter((testCase) => testCase.supported)
.map(async (testCase) => {
const fixture = await testCase.setup();
const configPath = path.join(fixture.stateDir, "openclaw.json");
await fs.writeFile(configPath, "{}\n", "utf-8");
if (!isWindows) {
await fs.chmod(configPath, 0o600);
}
const fixture = await testCase.setup();
const configPath = path.join(fixture.stateDir, "openclaw.json");
await fs.writeFile(configPath, "{}\n", "utf-8");
if (!isWindows) {
await fs.chmod(configPath, 0o600);
}
const res = await runSecurityAudit({
config: { agents: { defaults: { workspace: fixture.workspaceDir } } },
includeFilesystem: true,
includeChannelSecurity: false,
stateDir: fixture.stateDir,
configPath,
execDockerRawFn: execDockerRawUnavailable,
});
const res = await runSecurityAudit({
config: { agents: { defaults: { workspace: fixture.workspaceDir } } },
includeFilesystem: true,
includeChannelSecurity: false,
stateDir: fixture.stateDir,
configPath,
execDockerRawFn: execDockerRawUnavailable,
});
testCase.assert(res, fixture);
}
testCase.assert(res, fixture);
}),
);
});
it("scores small-model risk by tool/sandbox exposure", async () => {
@ -1554,20 +1554,24 @@ description: test skill
},
] as const;
for (const testCase of cases) {
const res = await audit(testCase.cfg);
if ("expectedFinding" in testCase) {
expect(res.findings, testCase.name).toEqual(
expect.arrayContaining([expect.objectContaining(testCase.expectedFinding)]),
await Promise.all(
cases.map(async (testCase) => {
const res = await audit(testCase.cfg);
if ("expectedFinding" in testCase) {
expect(res.findings, testCase.name).toEqual(
expect.arrayContaining([expect.objectContaining(testCase.expectedFinding)]),
);
}
const finding = res.findings.find(
(f) => f.checkId === "config.insecure_or_dangerous_flags",
);
}
const finding = res.findings.find((f) => f.checkId === "config.insecure_or_dangerous_flags");
expect(finding, testCase.name).toBeTruthy();
expect(finding?.severity, testCase.name).toBe("warn");
for (const detail of testCase.expectedDangerousDetails) {
expect(finding?.detail, `${testCase.name}:${detail}`).toContain(detail);
}
}
expect(finding, testCase.name).toBeTruthy();
expect(finding?.severity, testCase.name).toBe("warn");
for (const detail of testCase.expectedDangerousDetails) {
expect(finding?.detail, `${testCase.name}:${detail}`).toContain(detail);
}
}),
);
});
it.each([
@ -3116,17 +3120,19 @@ description: test skill
},
] as const;
for (const testCase of cases) {
const res = await testCase.run();
const expectedPresent = "expectedPresent" in testCase ? testCase.expectedPresent : [];
for (const checkId of expectedPresent) {
expect(hasFinding(res, checkId, "warn"), `${testCase.name}:${checkId}`).toBe(true);
}
const expectedAbsent = "expectedAbsent" in testCase ? testCase.expectedAbsent : [];
for (const checkId of expectedAbsent) {
expect(hasFinding(res, checkId), `${testCase.name}:${checkId}`).toBe(false);
}
}
await Promise.all(
cases.map(async (testCase) => {
const res = await testCase.run();
const expectedPresent = "expectedPresent" in testCase ? testCase.expectedPresent : [];
for (const checkId of expectedPresent) {
expect(hasFinding(res, checkId, "warn"), `${testCase.name}:${checkId}`).toBe(true);
}
const expectedAbsent = "expectedAbsent" in testCase ? testCase.expectedAbsent : [];
for (const checkId of expectedAbsent) {
expect(hasFinding(res, checkId), `${testCase.name}:${checkId}`).toBe(false);
}
}),
);
});
it("evaluates extension tool reachability findings", async () => {
@ -3339,9 +3345,17 @@ description: test skill
},
] as const;
for (const testCase of cases) {
const result = await testCase.run();
testCase.assert(result as never);
await Promise.all(
cases.slice(0, -1).map(async (testCase) => {
const result = await testCase.run();
testCase.assert(result as never);
}),
);
const scanFailureCase = cases.at(-1);
if (scanFailureCase) {
const result = await scanFailureCase.run();
scanFailureCase.assert(result as never);
}
});

File diff suppressed because it is too large Load Diff