Merge remote-tracking branch 'origin/main' into fix/codex-transport-path
This commit is contained in:
commit
a33b992a47
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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] = [
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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: [])
|
||||
|
||||
101
extensions/openrouter/index.test.ts
Normal file
101
extensions/openrouter/index.test.ts
Normal 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);
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)],
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
@ -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_"]
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
1160
test/fixtures/test-timings.unit.json
vendored
1160
test/fixtures/test-timings.unit.json
vendored
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user