mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 11:21:23 +00:00
chore: merge origin/main into main
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user