chore: merge origin/main into main

This commit is contained in:
Peter Steinberger
2026-02-22 13:42:52 +00:00
304 changed files with 17041 additions and 5502 deletions

View File

@@ -83,6 +83,32 @@ describe("markAuthProfileFailure", () => {
expectCooldownInRange(remainingMs, 0.8 * 60 * 60 * 1000, 1.2 * 60 * 60 * 1000);
});
});
it("keeps persisted cooldownUntil unchanged across mid-window retries", async () => {
await withAuthProfileStore(async ({ agentDir, store }) => {
await markAuthProfileFailure({
store,
profileId: "anthropic:default",
reason: "rate_limit",
agentDir,
});
const firstCooldownUntil = store.usageStats?.["anthropic:default"]?.cooldownUntil;
expect(typeof firstCooldownUntil).toBe("number");
await markAuthProfileFailure({
store,
profileId: "anthropic:default",
reason: "rate_limit",
agentDir,
});
const secondCooldownUntil = store.usageStats?.["anthropic:default"]?.cooldownUntil;
expect(secondCooldownUntil).toBe(firstCooldownUntil);
const reloaded = ensureAuthProfileStore(agentDir);
expect(reloaded.usageStats?.["anthropic:default"]?.cooldownUntil).toBe(firstCooldownUntil);
});
});
it("resets backoff counters outside the failure window", async () => {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-"));
try {

View File

@@ -1,9 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import type { AuthProfileStore } from "./types.js";
import type { AuthProfileStore, ProfileUsageStats } from "./types.js";
import {
clearAuthProfileCooldown,
clearExpiredCooldowns,
isProfileInCooldown,
markAuthProfileFailure,
resolveProfileUnusableUntil,
} from "./usage.js";
@@ -347,3 +348,116 @@ describe("clearAuthProfileCooldown", () => {
expect(store.usageStats).toBeUndefined();
});
});
describe("markAuthProfileFailure — active windows do not extend on retry", () => {
// Regression for https://github.com/openclaw/openclaw/issues/23516
// When all providers are at saturation backoff (60 min) and retries fire every 30 min,
// each retry was resetting cooldownUntil to now+60m, preventing recovery.
type WindowStats = ProfileUsageStats;
async function markFailureAt(params: {
store: ReturnType<typeof makeStore>;
now: number;
reason: "rate_limit" | "billing";
}): Promise<void> {
vi.useFakeTimers();
vi.setSystemTime(params.now);
try {
await markAuthProfileFailure({
store: params.store,
profileId: "anthropic:default",
reason: params.reason,
});
} finally {
vi.useRealTimers();
}
}
const activeWindowCases = [
{
label: "cooldownUntil",
reason: "rate_limit" as const,
buildUsageStats: (now: number): WindowStats => ({
cooldownUntil: now + 50 * 60 * 1000,
errorCount: 3,
lastFailureAt: now - 10 * 60 * 1000,
}),
readUntil: (stats: WindowStats | undefined) => stats?.cooldownUntil,
},
{
label: "disabledUntil",
reason: "billing" as const,
buildUsageStats: (now: number): WindowStats => ({
disabledUntil: now + 20 * 60 * 60 * 1000,
disabledReason: "billing",
errorCount: 5,
failureCounts: { billing: 5 },
lastFailureAt: now - 60_000,
}),
readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil,
},
];
for (const testCase of activeWindowCases) {
it(`keeps active ${testCase.label} unchanged on retry`, async () => {
const now = 1_000_000;
const existingStats = testCase.buildUsageStats(now);
const existingUntil = testCase.readUntil(existingStats);
const store = makeStore({ "anthropic:default": existingStats });
await markFailureAt({
store,
now,
reason: testCase.reason,
});
const stats = store.usageStats?.["anthropic:default"];
expect(testCase.readUntil(stats)).toBe(existingUntil);
});
}
const expiredWindowCases = [
{
label: "cooldownUntil",
reason: "rate_limit" as const,
buildUsageStats: (now: number): WindowStats => ({
cooldownUntil: now - 60_000,
errorCount: 3,
lastFailureAt: now - 60_000,
}),
expectedUntil: (now: number) => now + 60 * 60 * 1000,
readUntil: (stats: WindowStats | undefined) => stats?.cooldownUntil,
},
{
label: "disabledUntil",
reason: "billing" as const,
buildUsageStats: (now: number): WindowStats => ({
disabledUntil: now - 60_000,
disabledReason: "billing",
errorCount: 5,
failureCounts: { billing: 2 },
lastFailureAt: now - 60_000,
}),
expectedUntil: (now: number) => now + 20 * 60 * 60 * 1000,
readUntil: (stats: WindowStats | undefined) => stats?.disabledUntil,
},
];
for (const testCase of expiredWindowCases) {
it(`recomputes ${testCase.label} after the previous window expires`, async () => {
const now = 1_000_000;
const store = makeStore({
"anthropic:default": testCase.buildUsageStats(now),
});
await markFailureAt({
store,
now,
reason: testCase.reason,
});
const stats = store.usageStats?.["anthropic:default"];
expect(testCase.readUntil(stats)).toBe(testCase.expectedUntil(now));
});
}
});

View File

@@ -256,6 +256,17 @@ export function resolveProfileUnusableUntilForDisplay(
return resolveProfileUnusableUntil(stats);
}
function keepActiveWindowOrRecompute(params: {
existingUntil: number | undefined;
now: number;
recomputedUntil: number;
}): number {
const { existingUntil, now, recomputedUntil } = params;
const hasActiveWindow =
typeof existingUntil === "number" && Number.isFinite(existingUntil) && existingUntil > now;
return hasActiveWindow ? existingUntil : recomputedUntil;
}
function computeNextProfileUsageStats(params: {
existing: ProfileUsageStats;
now: number;
@@ -287,11 +298,23 @@ function computeNextProfileUsageStats(params: {
baseMs: params.cfgResolved.billingBackoffMs,
maxMs: params.cfgResolved.billingMaxMs,
});
updatedStats.disabledUntil = params.now + backoffMs;
// Keep active disable windows immutable so retries within the window cannot
// extend recovery time indefinitely.
updatedStats.disabledUntil = keepActiveWindowOrRecompute({
existingUntil: params.existing.disabledUntil,
now: params.now,
recomputedUntil: params.now + backoffMs,
});
updatedStats.disabledReason = "billing";
} else {
const backoffMs = calculateAuthProfileCooldownMs(nextErrorCount);
updatedStats.cooldownUntil = params.now + backoffMs;
// Keep active cooldown windows immutable so retries within the window
// cannot push recovery further out.
updatedStats.cooldownUntil = keepActiveWindowOrRecompute({
existingUntil: params.existing.cooldownUntil,
now: params.now,
recomputedUntil: params.now + backoffMs,
});
}
return updatedStats;

View File

@@ -14,6 +14,7 @@ import {
resolveAllowAlwaysPatterns,
resolveExecApprovals,
} from "../infra/exec-approvals.js";
import type { SafeBinProfile } from "../infra/exec-safe-bin-policy.js";
import { markBackgrounded, tail } from "./bash-process-registry.js";
import { requestExecApprovalDecision } from "./bash-tools.exec-approval-request.js";
import {
@@ -36,6 +37,7 @@ export type ProcessGatewayAllowlistParams = {
security: ExecSecurity;
ask: ExecAsk;
safeBins: Set<string>;
safeBinProfiles: Readonly<Record<string, SafeBinProfile>>;
agentId?: string;
sessionKey?: string;
scopeKey?: string;
@@ -69,6 +71,7 @@ export async function processGatewayAllowlist(
command: params.command,
allowlist: approvals.allowlist,
safeBins: params.safeBins,
safeBinProfiles: params.safeBinProfiles,
cwd: params.workdir,
env: params.env,
platform: process.platform,

View File

@@ -1,4 +1,5 @@
import type { ExecAsk, ExecHost, ExecSecurity } from "../infra/exec-approvals.js";
import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js";
import type { BashSandboxConfig } from "./bash-tools.shared.js";
export type ExecToolDefaults = {
@@ -8,6 +9,7 @@ export type ExecToolDefaults = {
node?: string;
pathPrepend?: string[];
safeBins?: string[];
safeBinProfiles?: Record<string, SafeBinProfileFixture>;
agentId?: string;
backgroundMs?: number;
timeoutSec?: number;

View File

@@ -1,8 +1,8 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { type ExecHost, maxAsk, minSecurity, resolveSafeBins } from "../infra/exec-approvals.js";
import { getTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js";
import { type ExecHost, maxAsk, minSecurity } from "../infra/exec-approvals.js";
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
import {
getShellPathFromLoginShell,
resolveShellEnvFallbackTimeoutMs,
@@ -163,8 +163,28 @@ export function createExecTool(
? defaults.timeoutSec
: 1800;
const defaultPathPrepend = normalizePathPrepend(defaults?.pathPrepend);
const safeBins = resolveSafeBins(defaults?.safeBins);
const trustedSafeBinDirs = getTrustedSafeBinDirs();
const {
safeBins,
safeBinProfiles,
trustedSafeBinDirs,
unprofiledSafeBins,
unprofiledInterpreterSafeBins,
} = resolveExecSafeBinRuntimePolicy({
local: {
safeBins: defaults?.safeBins,
safeBinProfiles: defaults?.safeBinProfiles,
},
});
if (unprofiledSafeBins.length > 0) {
logInfo(
`exec: ignoring unprofiled safeBins entries (${unprofiledSafeBins.toSorted().join(", ")}); use allowlist or define tools.exec.safeBinProfiles.<bin>`,
);
}
if (unprofiledInterpreterSafeBins.length > 0) {
logInfo(
`exec: interpreter/runtime binaries in safeBins (${unprofiledInterpreterSafeBins.join(", ")}) are unsafe without explicit hardened profiles; prefer allowlist entries`,
);
}
const notifyOnExit = defaults?.notifyOnExit !== false;
const notifyOnExitEmptySuccess = defaults?.notifyOnExitEmptySuccess === true;
const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
@@ -404,6 +424,7 @@ export function createExecTool(
security,
ask,
safeBins,
safeBinProfiles,
agentId,
sessionKey: defaults?.sessionKey,
scopeKey: defaults?.scopeKey,

View File

@@ -278,6 +278,18 @@ export function createProcessTool(
});
};
const runningSessionResult = (
session: ProcessSession,
text: string,
): AgentToolResult<unknown> => ({
content: [{ type: "text", text }],
details: {
status: "running",
sessionId: params.sessionId,
name: deriveSessionName(session.command),
},
});
switch (params.action) {
case "poll": {
if (!scopedSession) {
@@ -452,21 +464,12 @@ export function createProcessTool(
if (params.eof) {
resolved.stdin.end();
}
return {
content: [
{
type: "text",
text: `Wrote ${(params.data ?? "").length} bytes to session ${params.sessionId}${
params.eof ? " (stdin closed)" : ""
}.`,
},
],
details: {
status: "running",
sessionId: params.sessionId,
name: deriveSessionName(resolved.session.command),
},
};
return runningSessionResult(
resolved.session,
`Wrote ${(params.data ?? "").length} bytes to session ${params.sessionId}${
params.eof ? " (stdin closed)" : ""
}.`,
);
}
case "send-keys": {
@@ -491,21 +494,11 @@ export function createProcessTool(
};
}
await writeToStdin(resolved.stdin, data);
return {
content: [
{
type: "text",
text:
`Sent ${data.length} bytes to session ${params.sessionId}.` +
(warnings.length ? `\nWarnings:\n- ${warnings.join("\n- ")}` : ""),
},
],
details: {
status: "running",
sessionId: params.sessionId,
name: deriveSessionName(resolved.session.command),
},
};
return runningSessionResult(
resolved.session,
`Sent ${data.length} bytes to session ${params.sessionId}.` +
(warnings.length ? `\nWarnings:\n- ${warnings.join("\n- ")}` : ""),
);
}
case "submit": {
@@ -514,19 +507,10 @@ export function createProcessTool(
return resolved.result;
}
await writeToStdin(resolved.stdin, "\r");
return {
content: [
{
type: "text",
text: `Submitted session ${params.sessionId} (sent CR).`,
},
],
details: {
status: "running",
sessionId: params.sessionId,
name: deriveSessionName(resolved.session.command),
},
};
return runningSessionResult(
resolved.session,
`Submitted session ${params.sessionId} (sent CR).`,
);
}
case "paste": {
@@ -547,19 +531,10 @@ export function createProcessTool(
};
}
await writeToStdin(resolved.stdin, payload);
return {
content: [
{
type: "text",
text: `Pasted ${params.text?.length ?? 0} chars to session ${params.sessionId}.`,
},
],
details: {
status: "running",
sessionId: params.sessionId,
name: deriveSessionName(resolved.session.command),
},
};
return runningSessionResult(
resolved.session,
`Pasted ${params.text?.length ?? 0} chars to session ${params.sessionId}.`,
);
}
case "kill": {

View File

@@ -24,6 +24,33 @@ function registerExtraBootstrapFileHook() {
});
}
function registerMalformedBootstrapFileHook() {
registerInternalHook("agent:bootstrap", (event) => {
const context = event.context as AgentBootstrapHookContext;
context.bootstrapFiles = [
...context.bootstrapFiles,
{
name: "EXTRA.md",
filePath: path.join(context.workspaceDir, "BROKEN.md"),
content: "broken",
missing: false,
} as unknown as WorkspaceBootstrapFile,
{
name: "EXTRA.md",
path: 123,
content: "broken",
missing: false,
} as unknown as WorkspaceBootstrapFile,
{
name: "EXTRA.md",
path: " ",
content: "broken",
missing: false,
} as unknown as WorkspaceBootstrapFile,
];
});
}
describe("resolveBootstrapFilesForRun", () => {
beforeEach(() => clearInternalHooks());
afterEach(() => clearInternalHooks());
@@ -36,6 +63,23 @@ describe("resolveBootstrapFilesForRun", () => {
expect(files.some((file) => file.path === path.join(workspaceDir, "EXTRA.md"))).toBe(true);
});
it("drops malformed hook files with missing/invalid paths", async () => {
registerMalformedBootstrapFileHook();
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
const warnings: string[] = [];
const files = await resolveBootstrapFilesForRun({
workspaceDir,
warn: (message) => warnings.push(message),
});
expect(
files.every((file) => typeof file.path === "string" && file.path.trim().length > 0),
).toBe(true);
expect(warnings).toHaveLength(3);
expect(warnings[0]).toContain('missing or invalid "path" field');
});
});
describe("resolveBootstrapContextForRun", () => {

View File

@@ -22,12 +22,31 @@ export function makeBootstrapWarn(params: {
return (message: string) => params.warn?.(`${message} (sessionKey=${params.sessionLabel})`);
}
function sanitizeBootstrapFiles(
files: WorkspaceBootstrapFile[],
warn?: (message: string) => void,
): WorkspaceBootstrapFile[] {
const sanitized: WorkspaceBootstrapFile[] = [];
for (const file of files) {
const pathValue = typeof file.path === "string" ? file.path.trim() : "";
if (!pathValue) {
warn?.(
`skipping bootstrap file "${file.name}" — missing or invalid "path" field (hook may have used "filePath" instead)`,
);
continue;
}
sanitized.push({ ...file, path: pathValue });
}
return sanitized;
}
export async function resolveBootstrapFilesForRun(params: {
workspaceDir: string;
config?: OpenClawConfig;
sessionKey?: string;
sessionId?: string;
agentId?: string;
warn?: (message: string) => void;
}): Promise<WorkspaceBootstrapFile[]> {
const sessionKey = params.sessionKey ?? params.sessionId;
const bootstrapFiles = filterBootstrapFilesForSession(
@@ -35,7 +54,7 @@ export async function resolveBootstrapFilesForRun(params: {
sessionKey,
);
return applyBootstrapHookOverrides({
const updated = await applyBootstrapHookOverrides({
files: bootstrapFiles,
workspaceDir: params.workspaceDir,
config: params.config,
@@ -43,6 +62,7 @@ export async function resolveBootstrapFilesForRun(params: {
sessionId: params.sessionId,
agentId: params.agentId,
});
return sanitizeBootstrapFiles(updated, params.warn);
}
export async function resolveBootstrapContextForRun(params: {

View File

@@ -3,7 +3,9 @@ import { emitAgentEvent } from "../infra/agent-events.js";
import "./test-helpers/fast-core-tools.js";
import {
getCallGatewayMock,
getSessionsSpawnTool,
resetSessionsSpawnConfigOverride,
setupSessionsSpawnGatewayMock,
setSessionsSpawnConfigOverride,
} from "./openclaw-tools.subagents.sessions-spawn.test-harness.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -18,22 +20,6 @@ vi.mock("./pi-embedded.js", () => ({
const callGatewayMock = getCallGatewayMock();
const RUN_TIMEOUT_SECONDS = 1;
type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"];
type CreateOpenClawToolsOpts = Parameters<CreateOpenClawTools>[0];
async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) {
// Dynamic import: ensure harness mocks are installed before tool modules load.
const { createOpenClawTools } = await import("./openclaw-tools.js");
const tool = createOpenClawTools(opts).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) {
throw new Error("missing sessions_spawn tool");
}
return tool;
}
type GatewayRequest = { method?: string; params?: unknown };
type AgentWaitCall = { runId?: string; timeoutMs?: number };
function buildDiscordCleanupHooks(onDelete: (key: string | undefined) => void) {
return {
onAgentSubagentSpawn: (params: unknown) => {
@@ -48,98 +34,6 @@ function buildDiscordCleanupHooks(onDelete: (key: string | undefined) => void) {
};
}
function setupSessionsSpawnGatewayMock(opts: {
includeSessionsList?: boolean;
includeChatHistory?: boolean;
onAgentSubagentSpawn?: (params: unknown) => void;
onSessionsPatch?: (params: unknown) => void;
onSessionsDelete?: (params: unknown) => void;
agentWaitResult?: { status: "ok" | "timeout"; startedAt: number; endedAt: number };
}): {
calls: Array<GatewayRequest>;
waitCalls: Array<AgentWaitCall>;
getChild: () => { runId?: string; sessionKey?: string };
} {
const calls: Array<GatewayRequest> = [];
const waitCalls: Array<AgentWaitCall> = [];
let agentCallCount = 0;
let childRunId: string | undefined;
let childSessionKey: string | undefined;
callGatewayMock.mockImplementation(async (optsUnknown: unknown) => {
const request = optsUnknown as GatewayRequest;
calls.push(request);
if (request.method === "sessions.list" && opts.includeSessionsList) {
return {
sessions: [
{
key: "main",
lastChannel: "whatsapp",
lastTo: "+123",
},
],
};
}
if (request.method === "agent") {
agentCallCount += 1;
const runId = `run-${agentCallCount}`;
const params = request.params as { lane?: string; sessionKey?: string } | undefined;
// Only capture the first agent call (subagent spawn, not main agent trigger)
if (params?.lane === "subagent") {
childRunId = runId;
childSessionKey = params?.sessionKey ?? "";
opts.onAgentSubagentSpawn?.(params);
}
return {
runId,
status: "accepted",
acceptedAt: 1000 + agentCallCount,
};
}
if (request.method === "agent.wait") {
const params = request.params as AgentWaitCall | undefined;
waitCalls.push(params ?? {});
const res = opts.agentWaitResult ?? { status: "ok", startedAt: 1000, endedAt: 2000 };
return {
runId: params?.runId ?? "run-1",
...res,
};
}
if (request.method === "sessions.patch") {
opts.onSessionsPatch?.(request.params);
return { ok: true };
}
if (request.method === "sessions.delete") {
opts.onSessionsDelete?.(request.params);
return { ok: true };
}
if (request.method === "chat.history" && opts.includeChatHistory) {
return {
messages: [
{
role: "assistant",
content: [{ type: "text", text: "done" }],
},
],
};
}
return {};
});
return {
calls,
waitCalls,
getChild: () => ({ runId: childRunId, sessionKey: childSessionKey }),
};
}
const waitFor = async (predicate: () => boolean, timeoutMs = 1_500) => {
await vi.waitFor(
() => {
@@ -395,40 +289,10 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
});
it("sessions_spawn reports timed out when agent.wait returns timeout", async () => {
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
agentCallCount += 1;
return {
runId: `run-${agentCallCount}`,
status: "accepted",
acceptedAt: 5000 + agentCallCount,
};
}
if (request.method === "agent.wait") {
const params = request.params as { runId?: string } | undefined;
return {
runId: params?.runId ?? "run-1",
status: "timeout",
startedAt: 6000,
endedAt: 7000,
};
}
if (request.method === "chat.history") {
return {
messages: [
{
role: "assistant",
content: [{ type: "text", text: "still working" }],
},
],
};
}
return {};
const ctx = setupSessionsSpawnGatewayMock({
includeChatHistory: true,
chatHistoryText: "still working",
agentWaitResult: { status: "timeout", startedAt: 6000, endedAt: 7000 },
});
const tool = await getSessionsSpawnTool({
@@ -446,9 +310,9 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
runId: "run-1",
});
await waitFor(() => calls.filter((call) => call.method === "agent").length >= 2);
await waitFor(() => ctx.calls.filter((call) => call.method === "agent").length >= 2);
const mainAgentCall = calls
const mainAgentCall = ctx.calls
.filter((call) => call.method === "agent")
.find((call) => {
const params = call.params as { lane?: string } | undefined;
@@ -461,40 +325,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
});
it("sessions_spawn announces with requester accountId", async () => {
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
let childRunId: string | undefined;
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
agentCallCount += 1;
const runId = `run-${agentCallCount}`;
const params = request.params as { lane?: string; sessionKey?: string } | undefined;
if (params?.lane === "subagent") {
childRunId = runId;
}
return {
runId,
status: "accepted",
acceptedAt: 4000 + agentCallCount,
};
}
if (request.method === "agent.wait") {
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
return {
runId: params?.runId ?? "run-1",
status: "ok",
startedAt: 1000,
endedAt: 2000,
};
}
if (request.method === "sessions.delete" || request.method === "sessions.patch") {
return { ok: true };
}
return {};
});
const ctx = setupSessionsSpawnGatewayMock({});
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",
@@ -512,13 +343,14 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
runId: "run-1",
});
if (!childRunId) {
const child = ctx.getChild();
if (!child.runId) {
throw new Error("missing child runId");
}
vi.useFakeTimers();
try {
emitAgentEvent({
runId: childRunId,
runId: child.runId,
stream: "lifecycle",
data: {
phase: "end",
@@ -532,7 +364,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
vi.useRealTimers();
}
const agentCalls = calls.filter((call) => call.method === "agent");
const agentCalls = ctx.calls.filter((call) => call.method === "agent");
expect(agentCalls).toHaveLength(2);
const announceParams = agentCalls[1]?.params as
| { accountId?: string; channel?: string; deliver?: boolean }

View File

@@ -3,6 +3,17 @@ import { vi } from "vitest";
type SessionsSpawnTestConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>;
type CreateOpenClawTools = (typeof import("./openclaw-tools.js"))["createOpenClawTools"];
export type CreateOpenClawToolsOpts = Parameters<CreateOpenClawTools>[0];
export type GatewayRequest = { method?: string; params?: unknown };
export type AgentWaitCall = { runId?: string; timeoutMs?: number };
type SessionsSpawnGatewayMockOptions = {
includeSessionsList?: boolean;
includeChatHistory?: boolean;
chatHistoryText?: string;
onAgentSubagentSpawn?: (params: unknown) => void;
onSessionsPatch?: (params: unknown) => void;
onSessionsDelete?: (params: unknown) => void;
agentWaitResult?: { status: "ok" | "timeout"; startedAt: number; endedAt: number };
};
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
// oxlint-disable-next-line typescript/no-explicit-any
@@ -24,6 +35,18 @@ export function getCallGatewayMock(): AnyMock {
return hoisted.callGatewayMock;
}
export function getGatewayRequests(): Array<GatewayRequest> {
return getCallGatewayMock().mock.calls.map((call: [unknown]) => call[0] as GatewayRequest);
}
export function getGatewayMethods(): Array<string | undefined> {
return getGatewayRequests().map((request) => request.method);
}
export function findGatewayRequest(method: string): GatewayRequest | undefined {
return getGatewayRequests().find((request) => request.method === method);
}
export function resetSessionsSpawnConfigOverride(): void {
hoisted.state.configOverride = hoisted.defaultConfigOverride;
}
@@ -42,6 +65,95 @@ export async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) {
return tool;
}
export function setupSessionsSpawnGatewayMock(setupOpts: SessionsSpawnGatewayMockOptions): {
calls: Array<GatewayRequest>;
waitCalls: Array<AgentWaitCall>;
getChild: () => { runId?: string; sessionKey?: string };
} {
const calls: Array<GatewayRequest> = [];
const waitCalls: Array<AgentWaitCall> = [];
let agentCallCount = 0;
let childRunId: string | undefined;
let childSessionKey: string | undefined;
getCallGatewayMock().mockImplementation(async (optsUnknown: unknown) => {
const request = optsUnknown as GatewayRequest;
calls.push(request);
if (request.method === "sessions.list" && setupOpts.includeSessionsList) {
return {
sessions: [
{
key: "main",
lastChannel: "whatsapp",
lastTo: "+123",
},
],
};
}
if (request.method === "agent") {
agentCallCount += 1;
const runId = `run-${agentCallCount}`;
const params = request.params as { lane?: string; sessionKey?: string } | undefined;
// Capture only the subagent run metadata.
if (params?.lane === "subagent") {
childRunId = runId;
childSessionKey = params.sessionKey ?? "";
setupOpts.onAgentSubagentSpawn?.(params);
}
return {
runId,
status: "accepted",
acceptedAt: 1000 + agentCallCount,
};
}
if (request.method === "agent.wait") {
const params = request.params as AgentWaitCall | undefined;
waitCalls.push(params ?? {});
const waitResult = setupOpts.agentWaitResult ?? {
status: "ok",
startedAt: 1000,
endedAt: 2000,
};
return {
runId: params?.runId ?? "run-1",
...waitResult,
};
}
if (request.method === "sessions.patch") {
setupOpts.onSessionsPatch?.(request.params);
return { ok: true };
}
if (request.method === "sessions.delete") {
setupOpts.onSessionsDelete?.(request.params);
return { ok: true };
}
if (request.method === "chat.history" && setupOpts.includeChatHistory) {
return {
messages: [
{
role: "assistant",
content: [{ type: "text", text: setupOpts.chatHistoryText ?? "done" }],
},
],
};
}
return {};
});
return {
calls,
waitCalls,
getChild: () => ({ runId: childRunId, sessionKey: childSessionKey }),
};
}
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => hoisted.callGatewayMock(opts),
}));

View File

@@ -116,6 +116,40 @@ describe("buildBootstrapContextFiles", () => {
expect(result[0]?.content.length).toBeLessThanOrEqual(20);
expect(result[0]?.content.startsWith("[MISSING]")).toBe(true);
});
it("skips files with missing or invalid paths and emits warnings", () => {
const malformedMissingPath = {
name: "SKILL-SECURITY.md",
missing: false,
content: "secret",
} as unknown as WorkspaceBootstrapFile;
const malformedNonStringPath = {
name: "SKILL-SECURITY.md",
path: 123,
missing: false,
content: "secret",
} as unknown as WorkspaceBootstrapFile;
const malformedWhitespacePath = {
name: "SKILL-SECURITY.md",
path: " ",
missing: false,
content: "secret",
} as unknown as WorkspaceBootstrapFile;
const good = makeFile({ content: "hello" });
const warnings: string[] = [];
const result = buildBootstrapContextFiles(
[malformedMissingPath, malformedNonStringPath, malformedWhitespacePath, good],
{
warn: (msg) => warnings.push(msg),
},
);
expect(result).toHaveLength(1);
expect(result[0]?.path).toBe("/tmp/AGENTS.md");
expect(warnings).toHaveLength(3);
expect(warnings.every((warning) => warning.includes('missing or invalid "path" field'))).toBe(
true,
);
});
});
type BootstrapLimitResolverCase = {

View File

@@ -199,15 +199,22 @@ export function buildBootstrapContextFiles(
if (remainingTotalChars <= 0) {
break;
}
const pathValue = typeof file.path === "string" ? file.path.trim() : "";
if (!pathValue) {
opts?.warn?.(
`skipping bootstrap file "${file.name}" — missing or invalid "path" field (hook may have used "filePath" instead)`,
);
continue;
}
if (file.missing) {
const missingText = `[MISSING] Expected at: ${file.path}`;
const missingText = `[MISSING] Expected at: ${pathValue}`;
const cappedMissingText = clampToBudget(missingText, remainingTotalChars);
if (!cappedMissingText) {
break;
}
remainingTotalChars = Math.max(0, remainingTotalChars - cappedMissingText.length);
result.push({
path: file.path,
path: pathValue,
content: cappedMissingText,
});
continue;
@@ -231,7 +238,7 @@ export function buildBootstrapContextFiles(
}
remainingTotalChars = Math.max(0, remainingTotalChars - contentWithinBudget.length);
result.push({
path: file.path,
path: pathValue,
content: contentWithinBudget,
});
}

View File

@@ -231,6 +231,72 @@ describe("sanitizeSessionHistory (google thinking)", () => {
]);
});
it("strips non-base64 thought signatures for native Google Gemini", async () => {
const sessionManager = SessionManager.inMemory();
const input = [
{
role: "user",
content: "hi",
},
{
role: "assistant",
content: [
{ type: "text", text: "hello", thought_signature: "msg_abc123" },
{ type: "thinking", thinking: "ok", thought_signature: "c2ln" },
{
type: "toolCall",
id: "call_1",
name: "read",
arguments: { path: "/tmp/foo" },
thoughtSignature: '{"id":1}',
},
{
type: "toolCall",
id: "call_2",
name: "read",
arguments: { path: "/tmp/bar" },
thoughtSignature: "c2ln",
},
],
},
] as unknown as AgentMessage[];
const out = await sanitizeSessionHistory({
messages: input,
modelApi: "google-generative-ai",
provider: "google",
modelId: "gemini-2.0-flash",
sessionManager,
sessionId: "session:google-gemini",
});
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
content?: Array<{
type?: string;
thought_signature?: string;
thoughtSignature?: string;
thinking?: string;
}>;
};
expect(assistant.content).toEqual([
{ type: "text", text: "hello" },
{ type: "thinking", thinking: "ok", thought_signature: "c2ln" },
{
type: "toolCall",
id: "call1",
name: "read",
arguments: { path: "/tmp/foo" },
},
{
type: "toolCall",
id: "call2",
name: "read",
arguments: { path: "/tmp/bar" },
thoughtSignature: "c2ln",
},
]);
});
it("keeps mixed signed/unsigned thinking blocks for Google models", async () => {
const sessionManager = SessionManager.inMemory();
const input = [

View File

@@ -130,7 +130,7 @@ beforeAll(async () => {
workspaceDir = path.join(tempRoot, "workspace");
await fs.mkdir(agentDir, { recursive: true });
await fs.mkdir(workspaceDir, { recursive: true });
}, 60_000);
}, 180_000);
afterAll(async () => {
if (!tempRoot) {

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { ExecApprovalsResolved } from "../infra/exec-approvals.js";
import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js";
import { captureEnv } from "../test-utils/env.js";
const bundledPluginsDirSnapshot = captureEnv(["OPENCLAW_BUNDLED_PLUGINS_DIR"]);
@@ -86,6 +87,7 @@ type ExecTool = {
async function createSafeBinsExecTool(params: {
tmpPrefix: string;
safeBins: string[];
safeBinProfiles?: Record<string, SafeBinProfileFixture>;
files?: Array<{ name: string; contents: string }>;
}): Promise<{ tmpDir: string; execTool: ExecTool }> {
const { createOpenClawCodingTools } = await import("./pi-tools.js");
@@ -101,6 +103,7 @@ async function createSafeBinsExecTool(params: {
security: "allowlist",
ask: "off",
safeBins: params.safeBins,
safeBinProfiles: params.safeBinProfiles,
},
},
};
@@ -139,6 +142,9 @@ describe("createOpenClawCodingTools safeBins", () => {
{
tmpPrefix: "openclaw-safe-bins-",
safeBins: ["echo"],
safeBinProfiles: {
echo: { maxPositional: 1 },
},
},
async ({ tmpDir, execTool }) => {
const marker = `safe-bins-${Date.now()}`;
@@ -155,6 +161,23 @@ describe("createOpenClawCodingTools safeBins", () => {
);
});
it("rejects unprofiled custom safe-bin entries", async () => {
await withSafeBinsExecTool(
{
tmpPrefix: "openclaw-safe-bins-unprofiled-",
safeBins: ["echo"],
},
async ({ tmpDir, execTool }) => {
await expect(
execTool.execute("call1", {
command: "echo hello",
workdir: tmpDir,
}),
).rejects.toThrow("exec denied: allowlist miss");
},
);
});
it("does not allow env var expansion to smuggle file args via safeBins", async () => {
await withSafeBinsExecTool(
{

View File

@@ -7,6 +7,7 @@ import {
} from "@mariozechner/pi-coding-agent";
import type { OpenClawConfig } from "../config/config.js";
import type { ToolLoopDetectionConfig } from "../config/types.tools.js";
import { resolveMergedSafeBinProfileFixtures } from "../infra/exec-safe-bin-runtime-policy.js";
import { logWarn } from "../logger.js";
import { getPluginToolMeta } from "../plugins/tools.js";
import { isSubagentSessionKey } from "../routing/session-key.js";
@@ -104,6 +105,10 @@ function resolveExecConfig(params: { cfg?: OpenClawConfig; agentId?: string }) {
node: agentExec?.node ?? globalExec?.node,
pathPrepend: agentExec?.pathPrepend ?? globalExec?.pathPrepend,
safeBins: agentExec?.safeBins ?? globalExec?.safeBins,
safeBinProfiles: resolveMergedSafeBinProfileFixtures({
global: globalExec,
local: agentExec,
}),
backgroundMs: agentExec?.backgroundMs ?? globalExec?.backgroundMs,
timeoutSec: agentExec?.timeoutSec ?? globalExec?.timeoutSec,
approvalRunningNoticeMs:
@@ -361,6 +366,7 @@ export function createOpenClawCodingTools(options?: {
node: options?.exec?.node ?? execConfig.node,
pathPrepend: options?.exec?.pathPrepend ?? execConfig.pathPrepend,
safeBins: options?.exec?.safeBins ?? execConfig.safeBins,
safeBinProfiles: options?.exec?.safeBinProfiles ?? execConfig.safeBinProfiles,
agentId,
cwd: workspaceRoot,
allowBackground,

View File

@@ -6,6 +6,19 @@ import {
repairToolUseResultPairing,
} from "./session-transcript-repair.js";
const TOOL_CALL_BLOCK_TYPES = new Set(["toolCall", "toolUse", "functionCall"]);
function getAssistantToolCallBlocks(messages: AgentMessage[]) {
const assistant = messages[0] as Extract<AgentMessage, { role: "assistant" }> | undefined;
if (!assistant || !Array.isArray(assistant.content)) {
return [] as Array<{ type?: unknown; id?: unknown; name?: unknown }>;
}
return assistant.content.filter((block) => {
const type = (block as { type?: unknown }).type;
return typeof type === "string" && TOOL_CALL_BLOCK_TYPES.has(type);
}) as Array<{ type?: unknown; id?: unknown; name?: unknown }>;
}
describe("sanitizeToolUseResultPairing", () => {
const buildDuplicateToolResultInput = (opts?: {
middleMessage?: unknown;
@@ -229,13 +242,7 @@ describe("sanitizeToolCallInputs", () => {
] as unknown as AgentMessage[];
const out = sanitizeToolCallInputs(input);
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
const toolCalls = Array.isArray(assistant.content)
? assistant.content.filter((block) => {
const type = (block as { type?: unknown }).type;
return typeof type === "string" && ["toolCall", "toolUse", "functionCall"].includes(type);
})
: [];
const toolCalls = getAssistantToolCallBlocks(out);
expect(toolCalls).toHaveLength(1);
expect((toolCalls[0] as { id?: unknown }).id).toBe("call_ok");
@@ -264,13 +271,7 @@ describe("sanitizeToolCallInputs", () => {
] as unknown as AgentMessage[];
const out = sanitizeToolCallInputs(input);
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
const toolCalls = Array.isArray(assistant.content)
? assistant.content.filter((block) => {
const type = (block as { type?: unknown }).type;
return typeof type === "string" && ["toolCall", "toolUse", "functionCall"].includes(type);
})
: [];
const toolCalls = getAssistantToolCallBlocks(out);
expect(toolCalls).toHaveLength(1);
expect((toolCalls[0] as { name?: unknown }).name).toBe("read");
@@ -288,13 +289,7 @@ describe("sanitizeToolCallInputs", () => {
] as unknown as AgentMessage[];
const out = sanitizeToolCallInputs(input, { allowedToolNames: ["read"] });
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
const toolCalls = Array.isArray(assistant.content)
? assistant.content.filter((block) => {
const type = (block as { type?: unknown }).type;
return typeof type === "string" && ["toolCall", "toolUse", "functionCall"].includes(type);
})
: [];
const toolCalls = getAssistantToolCallBlocks(out);
expect(toolCalls).toHaveLength(1);
expect((toolCalls[0] as { name?: unknown }).name).toBe("read");

View File

@@ -1,10 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "./test-helpers/fast-core-tools.js";
import {
findGatewayRequest,
getCallGatewayMock,
getGatewayMethods,
getSessionsSpawnTool,
setSessionsSpawnConfigOverride,
} from "./openclaw-tools.subagents.sessions-spawn.test-harness.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
const hookRunnerMocks = vi.hoisted(() => ({
hasSubagentEndedHook: true,
@@ -45,21 +48,6 @@ vi.mock("../plugins/hook-runner-global.js", () => ({
})),
}));
type GatewayRequest = { method?: string; params?: Record<string, unknown> };
function getGatewayRequests(): GatewayRequest[] {
const callGatewayMock = getCallGatewayMock();
return callGatewayMock.mock.calls.map((call: [unknown]) => call[0] as GatewayRequest);
}
function getGatewayMethods(): Array<string | undefined> {
return getGatewayRequests().map((request) => request.method);
}
function findGatewayRequest(method: string): GatewayRequest | undefined {
return getGatewayRequests().find((request) => request.method === method);
}
function expectSessionsDeleteWithoutAgentStart() {
const methods = getGatewayMethods();
expect(methods).toContain("sessions.delete");
@@ -79,6 +67,7 @@ function mockAgentStartFailure() {
describe("sessions_spawn subagent lifecycle hooks", () => {
beforeEach(() => {
resetSubagentRegistryForTests();
hookRunnerMocks.hasSubagentEndedHook = true;
hookRunnerMocks.runSubagentSpawning.mockClear();
hookRunnerMocks.runSubagentSpawned.mockClear();
@@ -103,6 +92,10 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
});
});
afterEach(() => {
resetSubagentRegistryForTests();
});
it("runs subagent_spawning and emits subagent_spawned with requester metadata", async () => {
const tool = await getSessionsSpawnTool({
agentSessionKey: "main",

View File

@@ -380,24 +380,26 @@ describe("applySkillEnvOverrides", () => {
metadata: '{"openclaw":{"requires":{"env":["OPENAI_API_KEY"]}}}',
});
const config = {
skills: {
entries: {
"snapshot-env-skill": {
env: {
OPENAI_API_KEY: "snap-secret",
},
},
},
},
};
const snapshot = buildWorkspaceSkillSnapshot(workspaceDir, {
managedSkillsDir: path.join(workspaceDir, ".managed"),
config,
});
withClearedEnv(["OPENAI_API_KEY"], () => {
const restore = applySkillEnvOverridesFromSnapshot({
snapshot,
config: {
skills: {
entries: {
"snapshot-env-skill": {
env: {
OPENAI_API_KEY: "snap-secret",
},
},
},
},
},
config,
});
try {

View File

@@ -1430,6 +1430,7 @@ describe("subagent announce formatting", () => {
requesterSessionKey: "agent:main:subagent:orchestrator",
requesterDisplayKey: "agent:main:subagent:orchestrator",
...defaultOutcomeAnnounce,
timeoutMs: 100,
});
expect(didAnnounce).toBe(true);

View File

@@ -502,7 +502,7 @@ function resolveRequesterStoreKey(
cfg: ReturnType<typeof loadConfig>,
requesterSessionKey: string,
): string {
const raw = requesterSessionKey.trim();
const raw = (requesterSessionKey ?? "").trim();
if (!raw) {
return raw;
}

View File

@@ -93,4 +93,23 @@ describe("buildSystemPromptReport", () => {
expect(report.injectedWorkspaceFiles[0]?.injectedChars).toBe(0);
expect(report.injectedWorkspaceFiles[0]?.truncated).toBe(true);
});
it("ignores malformed injected file paths and still matches valid entries", () => {
const file = makeBootstrapFile({ path: "/tmp/workspace/policies/AGENTS.md" });
const report = buildSystemPromptReport({
source: "run",
generatedAt: 0,
bootstrapMaxChars: 20_000,
systemPrompt: "system",
bootstrapFiles: [file],
injectedFiles: [
{ path: 123 as unknown as string, content: "bad" },
{ path: "/tmp/workspace/policies/AGENTS.md", content: "trimmed" },
],
skillsPrompt: "",
tools: [],
});
expect(report.injectedWorkspaceFiles[0]?.injectedChars).toBe("trimmed".length);
});
});

View File

@@ -40,26 +40,34 @@ function buildInjectedWorkspaceFiles(params: {
bootstrapFiles: WorkspaceBootstrapFile[];
injectedFiles: EmbeddedContextFile[];
}): SessionSystemPromptReport["injectedWorkspaceFiles"] {
const injectedByPath = new Map(params.injectedFiles.map((f) => [f.path, f.content]));
const injectedByPath = new Map<string, string>();
const injectedByBaseName = new Map<string, string>();
for (const file of params.injectedFiles) {
const normalizedPath = file.path.replace(/\\/g, "/");
const pathValue = typeof file.path === "string" ? file.path.trim() : "";
if (!pathValue) {
continue;
}
if (!injectedByPath.has(pathValue)) {
injectedByPath.set(pathValue, file.content);
}
const normalizedPath = pathValue.replace(/\\/g, "/");
const baseName = path.posix.basename(normalizedPath);
if (!injectedByBaseName.has(baseName)) {
injectedByBaseName.set(baseName, file.content);
}
}
return params.bootstrapFiles.map((file) => {
const pathValue = typeof file.path === "string" ? file.path.trim() : "";
const rawChars = file.missing ? 0 : (file.content ?? "").trimEnd().length;
const injected =
injectedByPath.get(file.path) ??
(pathValue ? injectedByPath.get(pathValue) : undefined) ??
injectedByPath.get(file.name) ??
injectedByBaseName.get(file.name);
const injectedChars = injected ? injected.length : 0;
const truncated = !file.missing && injectedChars < rawChars;
return {
name: file.name,
path: file.path,
path: pathValue || file.name,
missing: file.missing,
rawChars,
injectedChars,

View File

@@ -19,6 +19,10 @@ describe("resolveTranscriptPolicy", () => {
modelApi: "google-generative-ai",
});
expect(policy.sanitizeToolCallIds).toBe(true);
expect(policy.sanitizeThoughtSignatures).toEqual({
allowBase64Only: true,
includeCamelCase: true,
});
});
it("enables sanitizeToolCallIds for Mistral provider", () => {

View File

@@ -110,9 +110,8 @@ export function resolveTranscriptPolicy(params: {
? "strict"
: undefined;
const repairToolUseResultPairing = isGoogle || isAnthropic;
const sanitizeThoughtSignatures = isOpenRouterGemini
? { allowBase64Only: true, includeCamelCase: true }
: undefined;
const sanitizeThoughtSignatures =
isOpenRouterGemini || isGoogle ? { allowBase64Only: true, includeCamelCase: true } : undefined;
const sanitizeThinkingSignatures = isAntigravityClaudeModel;
return {