2026-02-15 21:32:12 +00:00
|
|
|
import fs from "node:fs";
|
|
|
|
|
import fsPromises from "node:fs/promises";
|
|
|
|
|
import os from "node:os";
|
|
|
|
|
import path from "node:path";
|
|
|
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
|
import { emitAgentEvent } from "../../infra/agent-events.js";
|
|
|
|
|
import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js";
|
|
|
|
|
import { resetLogger, setLoggerOverride } from "../../logging.js";
|
|
|
|
|
import { ExecApprovalManager } from "../exec-approval-manager.js";
|
|
|
|
|
import { validateExecApprovalRequestParams } from "../protocol/index.js";
|
|
|
|
|
import { waitForAgentJob } from "./agent-job.js";
|
|
|
|
|
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
|
|
|
|
|
import { normalizeRpcAttachmentsToChatAttachments } from "./attachment-normalize.js";
|
|
|
|
|
import { sanitizeChatSendMessageInput } from "./chat.js";
|
|
|
|
|
import { createExecApprovalHandlers } from "./exec-approval.js";
|
|
|
|
|
import { logsHandlers } from "./logs.js";
|
|
|
|
|
|
2026-02-16 01:03:10 +00:00
|
|
|
vi.mock("../../commands/status.js", () => ({
|
|
|
|
|
getStatusSummary: vi.fn().mockResolvedValue({ ok: true }),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
type HealthStatusHandlerParams = Parameters<
|
|
|
|
|
(typeof import("./health.js"))["healthHandlers"]["status"]
|
|
|
|
|
>[0];
|
|
|
|
|
|
2026-02-15 21:32:12 +00:00
|
|
|
describe("waitForAgentJob", () => {
|
fix(subagent): harden read-tool overflow guards and sticky reply threading (#19508)
* fix(gateway): avoid premature agent.wait completion on transient errors
* fix(agent): preemptively guard tool results against context overflow
* fix: harden tool-result context guard and add message_id metadata
* fix: use importOriginal in session-key mock to include DEFAULT_ACCOUNT_ID
The run.skill-filter test was mocking ../../routing/session-key.js with only
buildAgentMainSessionKey and normalizeAgentId, but the module also exports
DEFAULT_ACCOUNT_ID which is required transitively by src/web/auth-store.ts.
Switch to importOriginal pattern so all real exports are preserved alongside
the mocked functions.
* pi-runner: guard accumulated tool-result overflow in transformContext
* PI runner: compact overflowing tool-result context
* Subagent: harden tool-result context recovery
* Enhance tool-result context handling by adding support for legacy tool outputs and improving character estimation for message truncation. This includes a new function to create legacy tool results and updates to existing functions to better manage context overflow scenarios.
* Enhance iMessage handling by adding reply tag support in send functions and tests. This includes modifications to prepend or rewrite reply tags based on provided replyToId, ensuring proper message formatting for replies.
* Enhance message delivery across multiple channels by implementing sticky reply context for chunked messages. This includes preserving reply references in Discord, Telegram, and iMessage, ensuring that follow-up messages maintain their intended reply targets. Additionally, improve handling of reply tags in system prompts and tests to support consistent reply behavior.
* Enhance read tool functionality by implementing auto-paging across chunks when no explicit limit is provided, scaling output budget based on model context window. Additionally, add tests for adaptive reading behavior and capped continuation guidance for large outputs. Update related functions to support these features.
* Refine tool-result context management by stripping oversized read-tool details payloads during compaction, ensuring repeated read calls do not bypass context limits. Introduce new utility functions for handling truncation content and enhance character estimation for tool results. Add tests to validate the removal of excessive details in context overflow scenarios.
* Refine message delivery logic in Matrix and Telegram by introducing a flag to track if a text chunk was sent. This ensures that replies are only marked as delivered when a text chunk has been successfully sent, improving the accuracy of reply handling in both channels.
* fix: tighten reply threading coverage and prep fixes (#19508) (thanks @tyler6204)
2026-02-17 15:32:52 -08:00
|
|
|
const AGENT_RUN_ERROR_RETRY_GRACE_MS = 15_000;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.useFakeTimers();
|
|
|
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
vi.useRealTimers();
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-15 21:32:12 +00:00
|
|
|
it("maps lifecycle end events with aborted=true to timeout", async () => {
|
|
|
|
|
const runId = `run-timeout-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
|
|
|
const waitPromise = waitForAgentJob({ runId, timeoutMs: 1_000 });
|
|
|
|
|
|
|
|
|
|
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "start", startedAt: 100 } });
|
|
|
|
|
emitAgentEvent({
|
|
|
|
|
runId,
|
|
|
|
|
stream: "lifecycle",
|
|
|
|
|
data: { phase: "end", endedAt: 200, aborted: true },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const snapshot = await waitPromise;
|
|
|
|
|
expect(snapshot).not.toBeNull();
|
|
|
|
|
expect(snapshot?.status).toBe("timeout");
|
|
|
|
|
expect(snapshot?.startedAt).toBe(100);
|
|
|
|
|
expect(snapshot?.endedAt).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("keeps non-aborted lifecycle end events as ok", async () => {
|
|
|
|
|
const runId = `run-ok-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
|
|
|
const waitPromise = waitForAgentJob({ runId, timeoutMs: 1_000 });
|
|
|
|
|
|
|
|
|
|
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "start", startedAt: 300 } });
|
|
|
|
|
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end", endedAt: 400 } });
|
|
|
|
|
|
|
|
|
|
const snapshot = await waitPromise;
|
|
|
|
|
expect(snapshot).not.toBeNull();
|
|
|
|
|
expect(snapshot?.status).toBe("ok");
|
|
|
|
|
expect(snapshot?.startedAt).toBe(300);
|
|
|
|
|
expect(snapshot?.endedAt).toBe(400);
|
|
|
|
|
});
|
fix(subagent): harden read-tool overflow guards and sticky reply threading (#19508)
* fix(gateway): avoid premature agent.wait completion on transient errors
* fix(agent): preemptively guard tool results against context overflow
* fix: harden tool-result context guard and add message_id metadata
* fix: use importOriginal in session-key mock to include DEFAULT_ACCOUNT_ID
The run.skill-filter test was mocking ../../routing/session-key.js with only
buildAgentMainSessionKey and normalizeAgentId, but the module also exports
DEFAULT_ACCOUNT_ID which is required transitively by src/web/auth-store.ts.
Switch to importOriginal pattern so all real exports are preserved alongside
the mocked functions.
* pi-runner: guard accumulated tool-result overflow in transformContext
* PI runner: compact overflowing tool-result context
* Subagent: harden tool-result context recovery
* Enhance tool-result context handling by adding support for legacy tool outputs and improving character estimation for message truncation. This includes a new function to create legacy tool results and updates to existing functions to better manage context overflow scenarios.
* Enhance iMessage handling by adding reply tag support in send functions and tests. This includes modifications to prepend or rewrite reply tags based on provided replyToId, ensuring proper message formatting for replies.
* Enhance message delivery across multiple channels by implementing sticky reply context for chunked messages. This includes preserving reply references in Discord, Telegram, and iMessage, ensuring that follow-up messages maintain their intended reply targets. Additionally, improve handling of reply tags in system prompts and tests to support consistent reply behavior.
* Enhance read tool functionality by implementing auto-paging across chunks when no explicit limit is provided, scaling output budget based on model context window. Additionally, add tests for adaptive reading behavior and capped continuation guidance for large outputs. Update related functions to support these features.
* Refine tool-result context management by stripping oversized read-tool details payloads during compaction, ensuring repeated read calls do not bypass context limits. Introduce new utility functions for handling truncation content and enhance character estimation for tool results. Add tests to validate the removal of excessive details in context overflow scenarios.
* Refine message delivery logic in Matrix and Telegram by introducing a flag to track if a text chunk was sent. This ensures that replies are only marked as delivered when a text chunk has been successfully sent, improving the accuracy of reply handling in both channels.
* fix: tighten reply threading coverage and prep fixes (#19508) (thanks @tyler6204)
2026-02-17 15:32:52 -08:00
|
|
|
|
|
|
|
|
it("treats transient error->start->end as recovered when restart lands inside grace", async () => {
|
|
|
|
|
const runId = `run-recover-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
|
|
|
const waitPromise = waitForAgentJob({ runId, timeoutMs: 60_000 });
|
|
|
|
|
|
|
|
|
|
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "start", startedAt: 100 } });
|
|
|
|
|
emitAgentEvent({
|
|
|
|
|
runId,
|
|
|
|
|
stream: "lifecycle",
|
|
|
|
|
data: { phase: "error", endedAt: 110, error: "transient" },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
|
|
|
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "start", startedAt: 200 } });
|
|
|
|
|
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end", endedAt: 260 } });
|
|
|
|
|
|
|
|
|
|
const snapshot = await waitPromise;
|
|
|
|
|
expect(snapshot).not.toBeNull();
|
|
|
|
|
expect(snapshot?.status).toBe("ok");
|
|
|
|
|
expect(snapshot?.startedAt).toBe(200);
|
|
|
|
|
expect(snapshot?.endedAt).toBe(260);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("resolves error only after grace expires when no recovery start arrives", async () => {
|
|
|
|
|
const runId = `run-error-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
|
|
|
const waitPromise = waitForAgentJob({ runId, timeoutMs: 60_000 });
|
|
|
|
|
|
|
|
|
|
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "start", startedAt: 10 } });
|
|
|
|
|
emitAgentEvent({
|
|
|
|
|
runId,
|
|
|
|
|
stream: "lifecycle",
|
|
|
|
|
data: { phase: "error", endedAt: 20, error: "fatal" },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let settled = false;
|
|
|
|
|
void waitPromise.finally(() => {
|
|
|
|
|
settled = true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await vi.advanceTimersByTimeAsync(AGENT_RUN_ERROR_RETRY_GRACE_MS - 1);
|
|
|
|
|
expect(settled).toBe(false);
|
|
|
|
|
|
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
|
|
|
const snapshot = await waitPromise;
|
|
|
|
|
expect(snapshot).not.toBeNull();
|
|
|
|
|
expect(snapshot?.status).toBe("error");
|
|
|
|
|
expect(snapshot?.error).toBe("fatal");
|
|
|
|
|
expect(snapshot?.startedAt).toBe(10);
|
|
|
|
|
expect(snapshot?.endedAt).toBe(20);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("honors pending error grace when waiter attaches after the error event", async () => {
|
|
|
|
|
const runId = `run-late-wait-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
|
|
|
|
|
|
|
|
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "start", startedAt: 900 } });
|
|
|
|
|
emitAgentEvent({
|
|
|
|
|
runId,
|
|
|
|
|
stream: "lifecycle",
|
|
|
|
|
data: { phase: "error", endedAt: 999, error: "late-listener" },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
|
|
|
|
|
|
|
|
const waitPromise = waitForAgentJob({ runId, timeoutMs: 60_000 });
|
|
|
|
|
let settled = false;
|
|
|
|
|
void waitPromise.finally(() => {
|
|
|
|
|
settled = true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await vi.advanceTimersByTimeAsync(AGENT_RUN_ERROR_RETRY_GRACE_MS - 5_001);
|
|
|
|
|
expect(settled).toBe(false);
|
|
|
|
|
|
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
|
|
|
const snapshot = await waitPromise;
|
|
|
|
|
expect(snapshot).not.toBeNull();
|
|
|
|
|
expect(snapshot?.status).toBe("error");
|
|
|
|
|
expect(snapshot?.error).toBe("late-listener");
|
|
|
|
|
expect(snapshot?.startedAt).toBe(900);
|
|
|
|
|
expect(snapshot?.endedAt).toBe(999);
|
|
|
|
|
});
|
2026-02-15 21:32:12 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("injectTimestamp", () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.useFakeTimers();
|
|
|
|
|
vi.setSystemTime(new Date("2026-01-29T01:30:00.000Z"));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
vi.useRealTimers();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("prepends a compact timestamp matching formatZonedTimestamp", () => {
|
|
|
|
|
const result = injectTimestamp("Is it the weekend?", {
|
|
|
|
|
timezone: "America/New_York",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result).toMatch(/^\[Wed 2026-01-28 20:30 EST\] Is it the weekend\?$/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("uses channel envelope format with DOW prefix", () => {
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const expected = formatZonedTimestamp(now, { timeZone: "America/New_York" });
|
|
|
|
|
|
|
|
|
|
const result = injectTimestamp("hello", { timezone: "America/New_York" });
|
|
|
|
|
|
|
|
|
|
expect(result).toBe(`[Wed ${expected}] hello`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("always uses 24-hour format", () => {
|
|
|
|
|
const result = injectTimestamp("hello", { timezone: "America/New_York" });
|
|
|
|
|
|
|
|
|
|
expect(result).toContain("20:30");
|
|
|
|
|
expect(result).not.toContain("PM");
|
|
|
|
|
expect(result).not.toContain("AM");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("uses the configured timezone", () => {
|
|
|
|
|
const result = injectTimestamp("hello", { timezone: "America/Chicago" });
|
|
|
|
|
|
|
|
|
|
expect(result).toMatch(/^\[Wed 2026-01-28 19:30 CST\]/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("defaults to UTC when no timezone specified", () => {
|
|
|
|
|
const result = injectTimestamp("hello", {});
|
|
|
|
|
|
|
|
|
|
expect(result).toMatch(/^\[Thu 2026-01-29 01:30/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns empty/whitespace messages unchanged", () => {
|
|
|
|
|
expect(injectTimestamp("", { timezone: "UTC" })).toBe("");
|
|
|
|
|
expect(injectTimestamp(" ", { timezone: "UTC" })).toBe(" ");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does NOT double-stamp messages with channel envelope timestamps", () => {
|
|
|
|
|
const enveloped = "[Discord user1 2026-01-28 20:30 EST] hello there";
|
|
|
|
|
const result = injectTimestamp(enveloped, { timezone: "America/New_York" });
|
|
|
|
|
|
|
|
|
|
expect(result).toBe(enveloped);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does NOT double-stamp messages already injected by us", () => {
|
|
|
|
|
const alreadyStamped = "[Wed 2026-01-28 20:30 EST] hello there";
|
|
|
|
|
const result = injectTimestamp(alreadyStamped, { timezone: "America/New_York" });
|
|
|
|
|
|
|
|
|
|
expect(result).toBe(alreadyStamped);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does NOT double-stamp messages with cron-injected timestamps", () => {
|
|
|
|
|
const cronMessage =
|
|
|
|
|
"[cron:abc123 my-job] do the thing\nCurrent time: Wednesday, January 28th, 2026 — 8:30 PM (America/New_York)";
|
|
|
|
|
const result = injectTimestamp(cronMessage, { timezone: "America/New_York" });
|
|
|
|
|
|
|
|
|
|
expect(result).toBe(cronMessage);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles midnight correctly", () => {
|
|
|
|
|
vi.setSystemTime(new Date("2026-02-01T05:00:00.000Z"));
|
|
|
|
|
|
|
|
|
|
const result = injectTimestamp("hello", { timezone: "America/New_York" });
|
|
|
|
|
|
|
|
|
|
expect(result).toMatch(/^\[Sun 2026-02-01 00:00 EST\]/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles date boundaries (just before midnight)", () => {
|
|
|
|
|
vi.setSystemTime(new Date("2026-02-01T04:59:00.000Z"));
|
|
|
|
|
|
|
|
|
|
const result = injectTimestamp("hello", { timezone: "America/New_York" });
|
|
|
|
|
|
|
|
|
|
expect(result).toMatch(/^\[Sat 2026-01-31 23:59 EST\]/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles DST correctly (same UTC hour, different local time)", () => {
|
|
|
|
|
vi.setSystemTime(new Date("2026-01-15T05:00:00.000Z"));
|
|
|
|
|
const winter = injectTimestamp("winter", { timezone: "America/New_York" });
|
|
|
|
|
expect(winter).toMatch(/^\[Thu 2026-01-15 00:00 EST\]/);
|
|
|
|
|
|
|
|
|
|
vi.setSystemTime(new Date("2026-07-15T04:00:00.000Z"));
|
|
|
|
|
const summer = injectTimestamp("summer", { timezone: "America/New_York" });
|
|
|
|
|
expect(summer).toMatch(/^\[Wed 2026-07-15 00:00 EDT\]/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("accepts a custom now date", () => {
|
|
|
|
|
const customDate = new Date("2025-07-04T16:00:00.000Z");
|
|
|
|
|
|
|
|
|
|
const result = injectTimestamp("fireworks?", {
|
|
|
|
|
timezone: "America/New_York",
|
|
|
|
|
now: customDate,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result).toMatch(/^\[Fri 2025-07-04 12:00 EDT\]/);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("timestampOptsFromConfig", () => {
|
|
|
|
|
it("extracts timezone from config", () => {
|
|
|
|
|
const opts = timestampOptsFromConfig({
|
|
|
|
|
agents: {
|
|
|
|
|
defaults: {
|
|
|
|
|
userTimezone: "America/Chicago",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
|
|
|
} as any);
|
|
|
|
|
|
|
|
|
|
expect(opts.timezone).toBe("America/Chicago");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("falls back gracefully with empty config", () => {
|
|
|
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
|
|
|
const opts = timestampOptsFromConfig({} as any);
|
|
|
|
|
|
|
|
|
|
expect(opts.timezone).toBeDefined();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("normalizeRpcAttachmentsToChatAttachments", () => {
|
|
|
|
|
it("passes through string content", () => {
|
|
|
|
|
const res = normalizeRpcAttachmentsToChatAttachments([
|
|
|
|
|
{ type: "file", mimeType: "image/png", fileName: "a.png", content: "Zm9v" },
|
|
|
|
|
]);
|
|
|
|
|
expect(res).toEqual([
|
|
|
|
|
{ type: "file", mimeType: "image/png", fileName: "a.png", content: "Zm9v" },
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("converts Uint8Array content to base64", () => {
|
|
|
|
|
const bytes = new TextEncoder().encode("foo");
|
|
|
|
|
const res = normalizeRpcAttachmentsToChatAttachments([{ content: bytes }]);
|
|
|
|
|
expect(res[0]?.content).toBe("Zm9v");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("sanitizeChatSendMessageInput", () => {
|
|
|
|
|
it("rejects null bytes", () => {
|
|
|
|
|
expect(sanitizeChatSendMessageInput("before\u0000after")).toEqual({
|
|
|
|
|
ok: false,
|
|
|
|
|
error: "message must not contain null bytes",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("strips unsafe control characters while preserving tab/newline/carriage return", () => {
|
|
|
|
|
const result = sanitizeChatSendMessageInput("a\u0001b\tc\nd\re\u0007f\u007f");
|
|
|
|
|
expect(result).toEqual({ ok: true, message: "ab\tc\nd\ref" });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("normalizes unicode to NFC", () => {
|
|
|
|
|
expect(sanitizeChatSendMessageInput("Cafe\u0301")).toEqual({ ok: true, message: "Café" });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("gateway chat transcript writes (guardrail)", () => {
|
|
|
|
|
it("does not append transcript messages via raw fs.appendFileSync(transcriptPath, ...)", () => {
|
|
|
|
|
const chatTs = fileURLToPath(new URL("./chat.ts", import.meta.url));
|
|
|
|
|
const src = fs.readFileSync(chatTs, "utf-8");
|
|
|
|
|
|
|
|
|
|
expect(src.includes("fs.appendFileSync(transcriptPath")).toBe(false);
|
|
|
|
|
|
|
|
|
|
expect(src).toContain("SessionManager.open(transcriptPath)");
|
|
|
|
|
expect(src).toContain("appendMessage(");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("exec approval handlers", () => {
|
2026-02-17 12:22:34 +09:00
|
|
|
const execApprovalNoop = () => false;
|
2026-02-16 14:52:15 +00:00
|
|
|
type ExecApprovalHandlers = ReturnType<typeof createExecApprovalHandlers>;
|
|
|
|
|
type ExecApprovalRequestArgs = Parameters<ExecApprovalHandlers["exec.approval.request"]>[0];
|
|
|
|
|
type ExecApprovalResolveArgs = Parameters<ExecApprovalHandlers["exec.approval.resolve"]>[0];
|
2026-02-17 12:22:34 +09:00
|
|
|
type ExecApprovalRequestRespond = ExecApprovalRequestArgs["respond"];
|
|
|
|
|
type ExecApprovalResolveRespond = ExecApprovalResolveArgs["respond"];
|
2026-02-16 14:52:15 +00:00
|
|
|
|
|
|
|
|
const defaultExecApprovalRequestParams = {
|
|
|
|
|
command: "echo ok",
|
|
|
|
|
cwd: "/tmp",
|
|
|
|
|
host: "node",
|
|
|
|
|
timeoutMs: 2000,
|
|
|
|
|
} as const;
|
|
|
|
|
|
|
|
|
|
function toExecApprovalRequestContext(context: {
|
|
|
|
|
broadcast: (event: string, payload: unknown) => void;
|
|
|
|
|
}): ExecApprovalRequestArgs["context"] {
|
|
|
|
|
return context as unknown as ExecApprovalRequestArgs["context"];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toExecApprovalResolveContext(context: {
|
|
|
|
|
broadcast: (event: string, payload: unknown) => void;
|
|
|
|
|
}): ExecApprovalResolveArgs["context"] {
|
|
|
|
|
return context as unknown as ExecApprovalResolveArgs["context"];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function requestExecApproval(params: {
|
|
|
|
|
handlers: ExecApprovalHandlers;
|
2026-02-17 12:22:34 +09:00
|
|
|
respond: ExecApprovalRequestRespond;
|
2026-02-16 14:52:15 +00:00
|
|
|
context: { broadcast: (event: string, payload: unknown) => void };
|
|
|
|
|
params?: Record<string, unknown>;
|
|
|
|
|
}) {
|
|
|
|
|
const requestParams = {
|
|
|
|
|
...defaultExecApprovalRequestParams,
|
|
|
|
|
...params.params,
|
|
|
|
|
} as unknown as ExecApprovalRequestArgs["params"];
|
|
|
|
|
return params.handlers["exec.approval.request"]({
|
|
|
|
|
params: requestParams,
|
|
|
|
|
respond: params.respond,
|
|
|
|
|
context: toExecApprovalRequestContext(params.context),
|
|
|
|
|
client: null,
|
|
|
|
|
req: { id: "req-1", type: "req", method: "exec.approval.request" },
|
|
|
|
|
isWebchatConnect: execApprovalNoop,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function resolveExecApproval(params: {
|
|
|
|
|
handlers: ExecApprovalHandlers;
|
|
|
|
|
id: string;
|
2026-02-17 12:22:34 +09:00
|
|
|
respond: ExecApprovalResolveRespond;
|
2026-02-16 14:52:15 +00:00
|
|
|
context: { broadcast: (event: string, payload: unknown) => void };
|
|
|
|
|
}) {
|
|
|
|
|
return params.handlers["exec.approval.resolve"]({
|
|
|
|
|
params: { id: params.id, decision: "allow-once" } as ExecApprovalResolveArgs["params"],
|
|
|
|
|
respond: params.respond,
|
|
|
|
|
context: toExecApprovalResolveContext(params.context),
|
2026-02-17 12:22:34 +09:00
|
|
|
client: {
|
|
|
|
|
connect: {
|
|
|
|
|
client: {
|
|
|
|
|
id: "cli",
|
|
|
|
|
displayName: "CLI",
|
|
|
|
|
version: "1.0.0",
|
|
|
|
|
platform: "test",
|
|
|
|
|
mode: "cli",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
} as unknown as ExecApprovalResolveArgs["client"],
|
2026-02-16 14:52:15 +00:00
|
|
|
req: { id: "req-2", type: "req", method: "exec.approval.resolve" },
|
|
|
|
|
isWebchatConnect: execApprovalNoop,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createExecApprovalFixture() {
|
|
|
|
|
const manager = new ExecApprovalManager();
|
|
|
|
|
const handlers = createExecApprovalHandlers(manager);
|
|
|
|
|
const broadcasts: Array<{ event: string; payload: unknown }> = [];
|
2026-02-17 12:22:34 +09:00
|
|
|
const respond = vi.fn() as unknown as ExecApprovalRequestRespond;
|
2026-02-16 14:52:15 +00:00
|
|
|
const context = {
|
|
|
|
|
broadcast: (event: string, payload: unknown) => {
|
|
|
|
|
broadcasts.push({ event, payload });
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
return { handlers, broadcasts, respond, context };
|
|
|
|
|
}
|
2026-02-15 21:32:12 +00:00
|
|
|
|
|
|
|
|
describe("ExecApprovalRequestParams validation", () => {
|
|
|
|
|
it("accepts request with resolvedPath omitted", () => {
|
|
|
|
|
const params = {
|
|
|
|
|
command: "echo hi",
|
|
|
|
|
cwd: "/tmp",
|
|
|
|
|
host: "node",
|
|
|
|
|
};
|
|
|
|
|
expect(validateExecApprovalRequestParams(params)).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("accepts request with resolvedPath as string", () => {
|
|
|
|
|
const params = {
|
|
|
|
|
command: "echo hi",
|
|
|
|
|
cwd: "/tmp",
|
|
|
|
|
host: "node",
|
|
|
|
|
resolvedPath: "/usr/bin/echo",
|
|
|
|
|
};
|
|
|
|
|
expect(validateExecApprovalRequestParams(params)).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("accepts request with resolvedPath as undefined", () => {
|
|
|
|
|
const params = {
|
|
|
|
|
command: "echo hi",
|
|
|
|
|
cwd: "/tmp",
|
|
|
|
|
host: "node",
|
|
|
|
|
resolvedPath: undefined,
|
|
|
|
|
};
|
|
|
|
|
expect(validateExecApprovalRequestParams(params)).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("accepts request with resolvedPath as null", () => {
|
|
|
|
|
const params = {
|
|
|
|
|
command: "echo hi",
|
|
|
|
|
cwd: "/tmp",
|
|
|
|
|
host: "node",
|
|
|
|
|
resolvedPath: null,
|
|
|
|
|
};
|
|
|
|
|
expect(validateExecApprovalRequestParams(params)).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("broadcasts request + resolve", async () => {
|
2026-02-16 14:52:15 +00:00
|
|
|
const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
|
2026-02-15 21:32:12 +00:00
|
|
|
|
2026-02-16 14:52:15 +00:00
|
|
|
const requestPromise = requestExecApproval({
|
|
|
|
|
handlers,
|
2026-02-15 21:32:12 +00:00
|
|
|
respond,
|
2026-02-16 14:52:15 +00:00
|
|
|
context,
|
|
|
|
|
params: { twoPhase: true },
|
2026-02-15 21:32:12 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested");
|
|
|
|
|
expect(requested).toBeTruthy();
|
|
|
|
|
const id = (requested?.payload as { id?: string })?.id ?? "";
|
|
|
|
|
expect(id).not.toBe("");
|
|
|
|
|
|
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
|
|
|
true,
|
|
|
|
|
expect.objectContaining({ status: "accepted", id }),
|
|
|
|
|
undefined,
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-17 12:22:34 +09:00
|
|
|
const resolveRespond = vi.fn() as unknown as ExecApprovalResolveRespond;
|
2026-02-16 14:52:15 +00:00
|
|
|
await resolveExecApproval({
|
|
|
|
|
handlers,
|
|
|
|
|
id,
|
2026-02-15 21:32:12 +00:00
|
|
|
respond: resolveRespond,
|
2026-02-16 14:52:15 +00:00
|
|
|
context,
|
2026-02-15 21:32:12 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await requestPromise;
|
|
|
|
|
|
|
|
|
|
expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
|
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
|
|
|
true,
|
|
|
|
|
expect.objectContaining({ id, decision: "allow-once" }),
|
|
|
|
|
undefined,
|
|
|
|
|
);
|
|
|
|
|
expect(broadcasts.some((entry) => entry.event === "exec.approval.resolved")).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("accepts resolve during broadcast", async () => {
|
|
|
|
|
const manager = new ExecApprovalManager();
|
|
|
|
|
const handlers = createExecApprovalHandlers(manager);
|
|
|
|
|
const respond = vi.fn();
|
2026-02-17 12:22:34 +09:00
|
|
|
const resolveRespond = vi.fn() as unknown as ExecApprovalResolveRespond;
|
2026-02-15 21:32:12 +00:00
|
|
|
|
|
|
|
|
const resolveContext = {
|
|
|
|
|
broadcast: () => {},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const context = {
|
|
|
|
|
broadcast: (event: string, payload: unknown) => {
|
|
|
|
|
if (event !== "exec.approval.requested") {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const id = (payload as { id?: string })?.id ?? "";
|
2026-02-16 14:52:15 +00:00
|
|
|
void resolveExecApproval({
|
|
|
|
|
handlers,
|
|
|
|
|
id,
|
2026-02-15 21:32:12 +00:00
|
|
|
respond: resolveRespond,
|
2026-02-16 14:52:15 +00:00
|
|
|
context: resolveContext,
|
2026-02-15 21:32:12 +00:00
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-16 14:52:15 +00:00
|
|
|
await requestExecApproval({
|
|
|
|
|
handlers,
|
2026-02-15 21:32:12 +00:00
|
|
|
respond,
|
2026-02-16 14:52:15 +00:00
|
|
|
context,
|
2026-02-15 21:32:12 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
|
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
|
|
|
true,
|
|
|
|
|
expect.objectContaining({ decision: "allow-once" }),
|
|
|
|
|
undefined,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("accepts explicit approval ids", async () => {
|
2026-02-16 14:52:15 +00:00
|
|
|
const { handlers, broadcasts, respond, context } = createExecApprovalFixture();
|
2026-02-15 21:32:12 +00:00
|
|
|
|
2026-02-16 14:52:15 +00:00
|
|
|
const requestPromise = requestExecApproval({
|
|
|
|
|
handlers,
|
2026-02-15 21:32:12 +00:00
|
|
|
respond,
|
2026-02-16 14:52:15 +00:00
|
|
|
context,
|
|
|
|
|
params: { id: "approval-123", host: "gateway" },
|
2026-02-15 21:32:12 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested");
|
|
|
|
|
const id = (requested?.payload as { id?: string })?.id ?? "";
|
|
|
|
|
expect(id).toBe("approval-123");
|
|
|
|
|
|
|
|
|
|
const resolveRespond = vi.fn();
|
2026-02-16 14:52:15 +00:00
|
|
|
await resolveExecApproval({
|
|
|
|
|
handlers,
|
|
|
|
|
id,
|
2026-02-15 21:32:12 +00:00
|
|
|
respond: resolveRespond,
|
2026-02-16 14:52:15 +00:00
|
|
|
context,
|
2026-02-15 21:32:12 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await requestPromise;
|
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
|
|
|
true,
|
|
|
|
|
expect.objectContaining({ id: "approval-123", decision: "allow-once" }),
|
|
|
|
|
undefined,
|
|
|
|
|
);
|
|
|
|
|
expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-16 01:03:10 +00:00
|
|
|
describe("gateway healthHandlers.status scope handling", () => {
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
const status = await import("../../commands/status.js");
|
|
|
|
|
vi.mocked(status.getStatusSummary).mockClear();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("requests redacted status for non-admin clients", async () => {
|
|
|
|
|
const respond = vi.fn();
|
|
|
|
|
const status = await import("../../commands/status.js");
|
|
|
|
|
const { healthHandlers } = await import("./health.js");
|
|
|
|
|
|
|
|
|
|
await healthHandlers.status({
|
|
|
|
|
respond,
|
|
|
|
|
client: { connect: { role: "operator", scopes: ["operator.read"] } },
|
2026-02-17 12:22:34 +09:00
|
|
|
} as unknown as HealthStatusHandlerParams);
|
2026-02-16 01:03:10 +00:00
|
|
|
|
|
|
|
|
expect(vi.mocked(status.getStatusSummary)).toHaveBeenCalledWith({ includeSensitive: false });
|
|
|
|
|
expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("requests full status for admin clients", async () => {
|
|
|
|
|
const respond = vi.fn();
|
|
|
|
|
const status = await import("../../commands/status.js");
|
|
|
|
|
const { healthHandlers } = await import("./health.js");
|
|
|
|
|
|
|
|
|
|
await healthHandlers.status({
|
|
|
|
|
respond,
|
|
|
|
|
client: { connect: { role: "operator", scopes: ["operator.admin"] } },
|
2026-02-17 12:22:34 +09:00
|
|
|
} as unknown as HealthStatusHandlerParams);
|
2026-02-16 01:03:10 +00:00
|
|
|
|
|
|
|
|
expect(vi.mocked(status.getStatusSummary)).toHaveBeenCalledWith({ includeSensitive: true });
|
|
|
|
|
expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-16 20:49:44 +05:00
|
|
|
describe("gateway mesh.plan.auto scope handling", () => {
|
|
|
|
|
it("rejects operator.read clients for mesh.plan.auto", async () => {
|
|
|
|
|
const { handleGatewayRequest } = await import("../server-methods.js");
|
|
|
|
|
const respond = vi.fn();
|
|
|
|
|
const handler = vi.fn();
|
|
|
|
|
|
|
|
|
|
await handleGatewayRequest({
|
|
|
|
|
req: { id: "req-mesh-read", type: "req", method: "mesh.plan.auto", params: {} },
|
|
|
|
|
respond,
|
|
|
|
|
context: {} as Parameters<typeof handleGatewayRequest>[0]["context"],
|
2026-02-17 12:22:34 +09:00
|
|
|
client: { connect: { role: "operator", scopes: ["operator.read"] } } as unknown as Parameters<
|
|
|
|
|
typeof handleGatewayRequest
|
|
|
|
|
>[0]["client"],
|
2026-02-16 20:49:44 +05:00
|
|
|
isWebchatConnect: () => false,
|
|
|
|
|
extraHandlers: { "mesh.plan.auto": handler },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(handler).not.toHaveBeenCalled();
|
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
|
|
|
false,
|
|
|
|
|
undefined,
|
|
|
|
|
expect.objectContaining({ message: "missing scope: operator.write" }),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("allows operator.write clients for mesh.plan.auto", async () => {
|
|
|
|
|
const { handleGatewayRequest } = await import("../server-methods.js");
|
|
|
|
|
const respond = vi.fn();
|
2026-02-16 23:26:02 +00:00
|
|
|
const handler = vi.fn(
|
|
|
|
|
({ respond: send }: { respond: (ok: boolean, payload?: unknown) => void }) =>
|
|
|
|
|
send(true, { ok: true }),
|
2026-02-16 20:49:44 +05:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await handleGatewayRequest({
|
|
|
|
|
req: { id: "req-mesh-write", type: "req", method: "mesh.plan.auto", params: {} },
|
|
|
|
|
respond,
|
|
|
|
|
context: {} as Parameters<typeof handleGatewayRequest>[0]["context"],
|
2026-02-17 12:22:34 +09:00
|
|
|
client: {
|
|
|
|
|
connect: { role: "operator", scopes: ["operator.write"] },
|
|
|
|
|
} as unknown as Parameters<typeof handleGatewayRequest>[0]["client"],
|
2026-02-16 20:49:44 +05:00
|
|
|
isWebchatConnect: () => false,
|
|
|
|
|
extraHandlers: { "mesh.plan.auto": handler },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(handler).toHaveBeenCalledOnce();
|
|
|
|
|
expect(respond).toHaveBeenCalledWith(true, { ok: true });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-15 21:32:12 +00:00
|
|
|
describe("logs.tail", () => {
|
|
|
|
|
const logsNoop = () => false;
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
resetLogger();
|
|
|
|
|
setLoggerOverride(null);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("falls back to latest rolling log file when today is missing", async () => {
|
|
|
|
|
const tempDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "openclaw-logs-"));
|
|
|
|
|
const older = path.join(tempDir, "openclaw-2026-01-20.log");
|
|
|
|
|
const newer = path.join(tempDir, "openclaw-2026-01-21.log");
|
|
|
|
|
|
|
|
|
|
await fsPromises.writeFile(older, '{"msg":"old"}\n');
|
|
|
|
|
await fsPromises.writeFile(newer, '{"msg":"new"}\n');
|
|
|
|
|
await fsPromises.utimes(older, new Date(0), new Date(0));
|
|
|
|
|
await fsPromises.utimes(newer, new Date(), new Date());
|
|
|
|
|
|
|
|
|
|
setLoggerOverride({ file: path.join(tempDir, "openclaw-2026-01-22.log") });
|
|
|
|
|
|
|
|
|
|
const respond = vi.fn();
|
|
|
|
|
await logsHandlers["logs.tail"]({
|
|
|
|
|
params: {},
|
|
|
|
|
respond,
|
|
|
|
|
context: {} as unknown as Parameters<(typeof logsHandlers)["logs.tail"]>[0]["context"],
|
|
|
|
|
client: null,
|
|
|
|
|
req: { id: "req-1", type: "req", method: "logs.tail" },
|
|
|
|
|
isWebchatConnect: logsNoop,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(respond).toHaveBeenCalledWith(
|
|
|
|
|
true,
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
file: newer,
|
|
|
|
|
lines: ['{"msg":"new"}'],
|
|
|
|
|
}),
|
|
|
|
|
undefined,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await fsPromises.rm(tempDir, { recursive: true, force: true });
|
|
|
|
|
});
|
|
|
|
|
});
|