diff --git a/src/gateway/server-methods/agent-job.test.ts b/src/gateway/server-methods/agent-job.test.ts deleted file mode 100644 index d696d9e0830..00000000000 --- a/src/gateway/server-methods/agent-job.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { emitAgentEvent } from "../../infra/agent-events.js"; -import { waitForAgentJob } from "./agent-job.js"; - -describe("waitForAgentJob", () => { - 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); - }); -}); diff --git a/src/gateway/server-methods/agent-timestamp.test.ts b/src/gateway/server-methods/agent-timestamp.test.ts deleted file mode 100644 index 1482194c2eb..00000000000 --- a/src/gateway/server-methods/agent-timestamp.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js"; -import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js"; - -describe("injectTimestamp", () => { - beforeEach(() => { - vi.useFakeTimers(); - // Wednesday, January 28, 2026 at 8:30 PM EST (01:30 UTC Jan 29) - 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" }); - - // DOW prefix + formatZonedTimestamp format - 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" }); - - // 8:30 PM EST = 7:30 PM CST = 19:30 - expect(result).toMatch(/^\[Wed 2026-01-28 19:30 CST\]/); - }); - - it("defaults to UTC when no timezone specified", () => { - const result = injectTimestamp("hello", {}); - - // 2026-01-29T01:30:00Z - 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")); // midnight EST - - 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")); // 23:59 Jan 31 EST - - 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)", () => { - // EST (winter): UTC-5 → 2026-01-15T05:00Z = midnight Jan 15 - 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\]/); - - // EDT (summer): UTC-4 → 2026-07-15T04:00Z = midnight Jul 15 - 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"); // July 4, noon ET - - 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(); // resolveUserTimezone provides a default - }); -}); diff --git a/src/gateway/server-methods/attachment-normalize.test.ts b/src/gateway/server-methods/attachment-normalize.test.ts deleted file mode 100644 index 159bae80492..00000000000 --- a/src/gateway/server-methods/attachment-normalize.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeRpcAttachmentsToChatAttachments } from "./attachment-normalize.js"; - -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"); - }); -}); diff --git a/src/gateway/server-methods/chat.sanitize-message.test.ts b/src/gateway/server-methods/chat.sanitize-message.test.ts deleted file mode 100644 index dd41d4c883e..00000000000 --- a/src/gateway/server-methods/chat.sanitize-message.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { sanitizeChatSendMessageInput } from "./chat.js"; - -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é" }); - }); -}); diff --git a/src/gateway/server-methods/chat.transcript-writes.guardrail.test.ts b/src/gateway/server-methods/chat.transcript-writes.guardrail.test.ts deleted file mode 100644 index d6b098dc28f..00000000000 --- a/src/gateway/server-methods/chat.transcript-writes.guardrail.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import fs from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -// Guardrail: the "empty post-compaction context" regression came from gateway code appending -// Pi transcript message entries as raw JSONL without `parentId`. -// -// This test is intentionally simple and file-local: if someone reintroduces direct JSONL appends -// against `transcriptPath`, Pi's SessionManager parent chain can break again. -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"); - - // Disallow raw appends against the resolved transcript path variable. - // (The transcript header creation via writeFileSync is OK; the bug class is raw message appends.) - expect(src.includes("fs.appendFileSync(transcriptPath")).toBe(false); - - // Ensure we keep using SessionManager for transcript message appends. - expect(src).toContain("SessionManager.open(transcriptPath)"); - expect(src).toContain("appendMessage("); - }); -}); diff --git a/src/gateway/server-methods/exec-approval.test.ts b/src/gateway/server-methods/exec-approval.test.ts deleted file mode 100644 index ac0373343b0..00000000000 --- a/src/gateway/server-methods/exec-approval.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { ExecApprovalManager } from "../exec-approval-manager.js"; -import { validateExecApprovalRequestParams } from "../protocol/index.js"; -import { createExecApprovalHandlers } from "./exec-approval.js"; - -const noop = () => {}; - -describe("exec approval handlers", () => { - 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); - }); - - // Fixed: null is now accepted (Type.Union([Type.String(), Type.Null()])) - // This matches the calling code in bash-tools.exec.ts which passes null. - 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 () => { - const manager = new ExecApprovalManager(); - const handlers = createExecApprovalHandlers(manager); - const broadcasts: Array<{ event: string; payload: unknown }> = []; - - const respond = vi.fn(); - const context = { - broadcast: (event: string, payload: unknown) => { - broadcasts.push({ event, payload }); - }, - }; - - const requestPromise = handlers["exec.approval.request"]({ - params: { - command: "echo ok", - cwd: "/tmp", - host: "node", - timeoutMs: 2000, - twoPhase: true, - }, - respond, - context: context as unknown as Parameters< - (typeof handlers)["exec.approval.request"] - >[0]["context"], - client: null, - req: { id: "req-1", type: "req", method: "exec.approval.request" }, - isWebchatConnect: noop, - }); - - 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(""); - - // First response should be "accepted" (registration confirmation) - expect(respond).toHaveBeenCalledWith( - true, - expect.objectContaining({ status: "accepted", id }), - undefined, - ); - - const resolveRespond = vi.fn(); - await handlers["exec.approval.resolve"]({ - params: { id, decision: "allow-once" }, - respond: resolveRespond, - context: context as unknown as Parameters< - (typeof handlers)["exec.approval.resolve"] - >[0]["context"], - client: { connect: { client: { id: "cli", displayName: "CLI" } } }, - req: { id: "req-2", type: "req", method: "exec.approval.resolve" }, - isWebchatConnect: noop, - }); - - await requestPromise; - - expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); - // Second response should contain the decision - 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(); - const resolveRespond = vi.fn(); - - const resolveContext = { - broadcast: () => {}, - }; - - const context = { - broadcast: (event: string, payload: unknown) => { - if (event !== "exec.approval.requested") { - return; - } - const id = (payload as { id?: string })?.id ?? ""; - void handlers["exec.approval.resolve"]({ - params: { id, decision: "allow-once" }, - respond: resolveRespond, - context: resolveContext as unknown as Parameters< - (typeof handlers)["exec.approval.resolve"] - >[0]["context"], - client: { connect: { client: { id: "cli", displayName: "CLI" } } }, - req: { id: "req-2", type: "req", method: "exec.approval.resolve" }, - isWebchatConnect: noop, - }); - }, - }; - - await handlers["exec.approval.request"]({ - params: { - command: "echo ok", - cwd: "/tmp", - host: "node", - timeoutMs: 2000, - }, - respond, - context: context as unknown as Parameters< - (typeof handlers)["exec.approval.request"] - >[0]["context"], - client: null, - req: { id: "req-1", type: "req", method: "exec.approval.request" }, - isWebchatConnect: noop, - }); - - expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); - expect(respond).toHaveBeenCalledWith( - true, - expect.objectContaining({ decision: "allow-once" }), - undefined, - ); - }); - - it("accepts explicit approval ids", async () => { - const manager = new ExecApprovalManager(); - const handlers = createExecApprovalHandlers(manager); - const broadcasts: Array<{ event: string; payload: unknown }> = []; - - const respond = vi.fn(); - const context = { - broadcast: (event: string, payload: unknown) => { - broadcasts.push({ event, payload }); - }, - }; - - const requestPromise = handlers["exec.approval.request"]({ - params: { - id: "approval-123", - command: "echo ok", - cwd: "/tmp", - host: "gateway", - timeoutMs: 2000, - }, - respond, - context: context as unknown as Parameters< - (typeof handlers)["exec.approval.request"] - >[0]["context"], - client: null, - req: { id: "req-1", type: "req", method: "exec.approval.request" }, - isWebchatConnect: noop, - }); - - 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(); - await handlers["exec.approval.resolve"]({ - params: { id, decision: "allow-once" }, - respond: resolveRespond, - context: context as unknown as Parameters< - (typeof handlers)["exec.approval.resolve"] - >[0]["context"], - client: { connect: { client: { id: "cli", displayName: "CLI" } } }, - req: { id: "req-2", type: "req", method: "exec.approval.resolve" }, - isWebchatConnect: noop, - }); - - await requestPromise; - expect(respond).toHaveBeenCalledWith( - true, - expect.objectContaining({ id: "approval-123", decision: "allow-once" }), - undefined, - ); - }); - - it("rejects duplicate approval ids", async () => { - const manager = new ExecApprovalManager(); - const handlers = createExecApprovalHandlers(manager); - const respondA = vi.fn(); - const respondB = vi.fn(); - const broadcasts: Array<{ event: string; payload: unknown }> = []; - const context = { - broadcast: (event: string, payload: unknown) => { - broadcasts.push({ event, payload }); - }, - }; - - const requestPromise = handlers["exec.approval.request"]({ - params: { - id: "dup-1", - command: "echo ok", - }, - respond: respondA, - context: context as unknown as Parameters< - (typeof handlers)["exec.approval.request"] - >[0]["context"], - client: null, - req: { id: "req-1", type: "req", method: "exec.approval.request" }, - isWebchatConnect: noop, - }); - - await handlers["exec.approval.request"]({ - params: { - id: "dup-1", - command: "echo again", - }, - respond: respondB, - context: context as unknown as Parameters< - (typeof handlers)["exec.approval.request"] - >[0]["context"], - client: null, - req: { id: "req-2", type: "req", method: "exec.approval.request" }, - isWebchatConnect: noop, - }); - - expect(respondB).toHaveBeenCalledWith( - false, - undefined, - expect.objectContaining({ message: "approval id already pending" }), - ); - - const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested"); - const id = (requested?.payload as { id?: string })?.id ?? ""; - const resolveRespond = vi.fn(); - await handlers["exec.approval.resolve"]({ - params: { id, decision: "deny" }, - respond: resolveRespond, - context: context as unknown as Parameters< - (typeof handlers)["exec.approval.resolve"] - >[0]["context"], - client: { connect: { client: { id: "cli", displayName: "CLI" } } }, - req: { id: "req-3", type: "req", method: "exec.approval.resolve" }, - isWebchatConnect: noop, - }); - - await requestPromise; - }); -}); diff --git a/src/gateway/server-methods/logs.test.ts b/src/gateway/server-methods/logs.test.ts deleted file mode 100644 index fd9a46f920b..00000000000 --- a/src/gateway/server-methods/logs.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { resetLogger, setLoggerOverride } from "../../logging.js"; -import { logsHandlers } from "./logs.js"; - -const noop = () => false; - -describe("logs.tail", () => { - afterEach(() => { - resetLogger(); - setLoggerOverride(null); - }); - - it("falls back to latest rolling log file when today is missing", async () => { - const tempDir = await fs.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 fs.writeFile(older, '{"msg":"old"}\n'); - await fs.writeFile(newer, '{"msg":"new"}\n'); - await fs.utimes(older, new Date(0), new Date(0)); - await fs.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: noop, - }); - - expect(respond).toHaveBeenCalledWith( - true, - expect.objectContaining({ - file: newer, - lines: ['{"msg":"new"}'], - }), - undefined, - ); - - await fs.rm(tempDir, { recursive: true, force: true }); - }); -}); diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts new file mode 100644 index 00000000000..dfc11df9436 --- /dev/null +++ b/src/gateway/server-methods/server-methods.test.ts @@ -0,0 +1,490 @@ +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"; + +describe("waitForAgentJob", () => { + 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); + }); +}); + +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", () => { + const execApprovalNoop = () => {}; + + 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 () => { + const manager = new ExecApprovalManager(); + const handlers = createExecApprovalHandlers(manager); + const broadcasts: Array<{ event: string; payload: unknown }> = []; + + const respond = vi.fn(); + const context = { + broadcast: (event: string, payload: unknown) => { + broadcasts.push({ event, payload }); + }, + }; + + const requestPromise = handlers["exec.approval.request"]({ + params: { + command: "echo ok", + cwd: "/tmp", + host: "node", + timeoutMs: 2000, + twoPhase: true, + }, + respond, + context: context as unknown as Parameters< + (typeof handlers)["exec.approval.request"] + >[0]["context"], + client: null, + req: { id: "req-1", type: "req", method: "exec.approval.request" }, + isWebchatConnect: execApprovalNoop, + }); + + 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, + ); + + const resolveRespond = vi.fn(); + await handlers["exec.approval.resolve"]({ + params: { id, decision: "allow-once" }, + respond: resolveRespond, + context: context as unknown as Parameters< + (typeof handlers)["exec.approval.resolve"] + >[0]["context"], + client: { connect: { client: { id: "cli", displayName: "CLI" } } }, + req: { id: "req-2", type: "req", method: "exec.approval.resolve" }, + isWebchatConnect: execApprovalNoop, + }); + + 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(); + const resolveRespond = vi.fn(); + + const resolveContext = { + broadcast: () => {}, + }; + + const context = { + broadcast: (event: string, payload: unknown) => { + if (event !== "exec.approval.requested") { + return; + } + const id = (payload as { id?: string })?.id ?? ""; + void handlers["exec.approval.resolve"]({ + params: { id, decision: "allow-once" }, + respond: resolveRespond, + context: resolveContext as unknown as Parameters< + (typeof handlers)["exec.approval.resolve"] + >[0]["context"], + client: { connect: { client: { id: "cli", displayName: "CLI" } } }, + req: { id: "req-2", type: "req", method: "exec.approval.resolve" }, + isWebchatConnect: execApprovalNoop, + }); + }, + }; + + await handlers["exec.approval.request"]({ + params: { + command: "echo ok", + cwd: "/tmp", + host: "node", + timeoutMs: 2000, + }, + respond, + context: context as unknown as Parameters< + (typeof handlers)["exec.approval.request"] + >[0]["context"], + client: null, + req: { id: "req-1", type: "req", method: "exec.approval.request" }, + isWebchatConnect: execApprovalNoop, + }); + + expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ decision: "allow-once" }), + undefined, + ); + }); + + it("accepts explicit approval ids", async () => { + const manager = new ExecApprovalManager(); + const handlers = createExecApprovalHandlers(manager); + const broadcasts: Array<{ event: string; payload: unknown }> = []; + + const respond = vi.fn(); + const context = { + broadcast: (event: string, payload: unknown) => { + broadcasts.push({ event, payload }); + }, + }; + + const requestPromise = handlers["exec.approval.request"]({ + params: { + id: "approval-123", + command: "echo ok", + cwd: "/tmp", + host: "gateway", + timeoutMs: 2000, + }, + respond, + context: context as unknown as Parameters< + (typeof handlers)["exec.approval.request"] + >[0]["context"], + client: null, + req: { id: "req-1", type: "req", method: "exec.approval.request" }, + isWebchatConnect: execApprovalNoop, + }); + + 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(); + await handlers["exec.approval.resolve"]({ + params: { id, decision: "allow-once" }, + respond: resolveRespond, + context: context as unknown as Parameters< + (typeof handlers)["exec.approval.resolve"] + >[0]["context"], + client: { connect: { client: { id: "cli", displayName: "CLI" } } }, + req: { id: "req-2", type: "req", method: "exec.approval.resolve" }, + isWebchatConnect: execApprovalNoop, + }); + + await requestPromise; + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ id: "approval-123", decision: "allow-once" }), + undefined, + ); + expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined); + }); +}); + +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 }); + }); +});