From 48e6b4fca3cbe3d2c1760790a6b224041f6ffa85 Mon Sep 17 00:00:00 2001 From: Marcus Castro Date: Thu, 19 Feb 2026 02:58:56 -0300 Subject: [PATCH 0001/2904] fix: run BOOT.md for each configured agent at startup (#20569) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 9098a4cc64487070464371022181f64633f142c2 Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + src/gateway/boot.test.ts | 20 ++- src/gateway/boot.ts | 6 +- src/hooks/bundled/boot-md/HOOK.md | 3 +- ...andler.gateway-startup.integration.test.ts | 56 ++++++++ src/hooks/bundled/boot-md/handler.test.ts | 122 ++++++++++++++++++ src/hooks/bundled/boot-md/handler.ts | 41 ++++-- src/hooks/internal-hooks.test.ts | 17 +++ src/hooks/internal-hooks.ts | 21 +++ 9 files changed, 272 insertions(+), 15 deletions(-) create mode 100644 src/hooks/bundled/boot-md/handler.gateway-startup.integration.test.ts create mode 100644 src/hooks/bundled/boot-md/handler.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 015f032c983..ba7f945fad7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Hooks: run BOOT.md startup checks per configured agent scope, including per-agent session-key resolution, startup-hook regression coverage, and non-success boot outcome logging for diagnosability. (#20569) thanks @mcaxtr. - Telegram: unify message-like inbound handling so `message` and `channel_post` share the same dedupe/access/media pipeline and remain behaviorally consistent. (#20591) Thanks @obviyus. - Telegram/Agents: gate exec/bash tool-failure warnings behind verbose mode so default Telegram replies stay clean while verbose sessions still surface diagnostics. (#20560) Thanks @obviyus. - Gateway/Daemon: forward `TMPDIR` into installed service environments so macOS LaunchAgent gateway runs can open SQLite temp/journal files reliably instead of failing with `SQLITE_CANTOPEN`. (#20512) Thanks @Clawborn. diff --git a/src/gateway/boot.test.ts b/src/gateway/boot.test.ts index 932707d11b7..8a017c14ce6 100644 --- a/src/gateway/boot.test.ts +++ b/src/gateway/boot.test.ts @@ -9,7 +9,7 @@ const agentCommand = vi.fn(); vi.mock("../commands/agent.js", () => ({ agentCommand })); const { runBootOnce } = await import("./boot.js"); -const { resolveAgentIdFromSessionKey, resolveMainSessionKey } = +const { resolveAgentIdFromSessionKey, resolveAgentMainSessionKey, resolveMainSessionKey } = await import("../config/sessions/main-session.js"); const { resolveStorePath } = await import("../config/sessions/paths.js"); const { loadSessionStore, saveSessionStore } = await import("../config/sessions/store.js"); @@ -99,6 +99,24 @@ describe("runBootOnce", () => { await fs.rm(workspaceDir, { recursive: true, force: true }); }); + it("uses per-agent session key when agentId is provided", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-boot-")); + await fs.writeFile(path.join(workspaceDir, "BOOT.md"), "Check status.", "utf-8"); + + agentCommand.mockResolvedValue(undefined); + const cfg = {}; + const agentId = "ops"; + await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir, agentId })).resolves.toEqual({ + status: "ran", + }); + + expect(agentCommand).toHaveBeenCalledTimes(1); + const perAgentCall = agentCommand.mock.calls[0]?.[0]; + expect(perAgentCall?.sessionKey).toBe(resolveAgentMainSessionKey({ cfg, agentId })); + + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); + it("generates new session ID when no existing session exists", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-boot-")); const content = "Say hello when you wake up."; diff --git a/src/gateway/boot.ts b/src/gateway/boot.ts index ff36af7eebf..edf1f2b5310 100644 --- a/src/gateway/boot.ts +++ b/src/gateway/boot.ts @@ -7,6 +7,7 @@ import { agentCommand } from "../commands/agent.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveAgentIdFromSessionKey, + resolveAgentMainSessionKey, resolveMainSessionKey, } from "../config/sessions/main-session.js"; import { resolveStorePath } from "../config/sessions/paths.js"; @@ -138,6 +139,7 @@ export async function runBootOnce(params: { cfg: OpenClawConfig; deps: CliDeps; workspaceDir: string; + agentId?: string; }): Promise { const bootRuntime: RuntimeEnv = { log: () => {}, @@ -157,7 +159,9 @@ export async function runBootOnce(params: { return { status: "skipped", reason: result.status }; } - const sessionKey = resolveMainSessionKey(params.cfg); + const sessionKey = params.agentId + ? resolveAgentMainSessionKey({ cfg: params.cfg, agentId: params.agentId }) + : resolveMainSessionKey(params.cfg); const message = buildBootPrompt(result.content ?? ""); const sessionId = generateBootSessionId(); const mappingSnapshot = snapshotMainSessionMapping({ diff --git a/src/hooks/bundled/boot-md/HOOK.md b/src/hooks/bundled/boot-md/HOOK.md index 183325c6b1d..b31c97727d4 100644 --- a/src/hooks/bundled/boot-md/HOOK.md +++ b/src/hooks/bundled/boot-md/HOOK.md @@ -16,4 +16,5 @@ metadata: # Boot Checklist Hook -Runs `BOOT.md` every time the gateway starts, if the file exists in the workspace. +Runs `BOOT.md` at gateway startup for each configured agent scope, if the file exists in that +agent's resolved workspace. diff --git a/src/hooks/bundled/boot-md/handler.gateway-startup.integration.test.ts b/src/hooks/bundled/boot-md/handler.gateway-startup.integration.test.ts new file mode 100644 index 00000000000..cfbc0bb420b --- /dev/null +++ b/src/hooks/bundled/boot-md/handler.gateway-startup.integration.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CliDeps } from "../../../cli/deps.js"; +import type { OpenClawConfig } from "../../../config/config.js"; + +const runBootOnce = vi.fn(); + +vi.mock("../../../gateway/boot.js", () => ({ runBootOnce })); +vi.mock("../../../logging/subsystem.js", () => ({ + createSubsystemLogger: () => ({ + warn: vi.fn(), + debug: vi.fn(), + }), +})); + +const { default: runBootChecklist } = await import("./handler.js"); +const { clearInternalHooks, createInternalHookEvent, registerInternalHook, triggerInternalHook } = + await import("../../internal-hooks.js"); + +describe("boot-md startup hook integration", () => { + beforeEach(() => { + runBootOnce.mockReset(); + clearInternalHooks(); + }); + + afterEach(() => { + clearInternalHooks(); + }); + + it("dispatches gateway:startup through internal hooks and runs BOOT for each configured agent scope", async () => { + const cfg = { + hooks: { internal: { enabled: true } }, + agents: { + list: [ + { id: "main", default: true, workspace: "/ws/main" }, + { id: "ops", workspace: "/ws/ops" }, + ], + }, + } as OpenClawConfig; + const deps = {} as CliDeps; + runBootOnce.mockResolvedValue({ status: "ran" }); + + registerInternalHook("gateway:startup", runBootChecklist); + const event = createInternalHookEvent("gateway", "startup", "gateway:startup", { cfg, deps }); + await triggerInternalHook(event); + + expect(runBootOnce).toHaveBeenCalledTimes(2); + expect(runBootOnce).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ cfg, deps, workspaceDir: "/ws/main", agentId: "main" }), + ); + expect(runBootOnce).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ cfg, deps, workspaceDir: "/ws/ops", agentId: "ops" }), + ); + }); +}); diff --git a/src/hooks/bundled/boot-md/handler.test.ts b/src/hooks/bundled/boot-md/handler.test.ts new file mode 100644 index 00000000000..ee19f7cc1e9 --- /dev/null +++ b/src/hooks/bundled/boot-md/handler.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { InternalHookEvent } from "../../internal-hooks.js"; + +const runBootOnce = vi.fn(); +const listAgentIds = vi.fn(); +const resolveAgentWorkspaceDir = vi.fn(); +const logWarn = vi.fn(); +const logDebug = vi.fn(); + +vi.mock("../../../gateway/boot.js", () => ({ runBootOnce })); +vi.mock("../../../agents/agent-scope.js", () => ({ + listAgentIds, + resolveAgentWorkspaceDir, +})); +vi.mock("../../../logging/subsystem.js", () => ({ + createSubsystemLogger: () => ({ + warn: logWarn, + debug: logDebug, + }), +})); + +const { default: runBootChecklist } = await import("./handler.js"); + +function makeEvent(overrides?: Partial): InternalHookEvent { + return { + type: "gateway", + action: "startup", + sessionKey: "test", + context: {}, + timestamp: new Date(), + messages: [], + ...overrides, + }; +} + +describe("boot-md handler", () => { + beforeEach(() => { + vi.clearAllMocks(); + logWarn.mockReset(); + logDebug.mockReset(); + }); + + it("skips non-gateway events", async () => { + await runBootChecklist(makeEvent({ type: "command", action: "new" })); + expect(runBootOnce).not.toHaveBeenCalled(); + }); + + it("skips non-startup actions", async () => { + await runBootChecklist(makeEvent({ action: "shutdown" })); + expect(runBootOnce).not.toHaveBeenCalled(); + }); + + it("skips when cfg is missing from context", async () => { + await runBootChecklist(makeEvent({ context: { workspaceDir: "/tmp" } })); + expect(runBootOnce).not.toHaveBeenCalled(); + }); + + it("runs boot for each agent", async () => { + const cfg = { agents: { list: [{ id: "main" }, { id: "ops" }] } }; + listAgentIds.mockReturnValue(["main", "ops"]); + resolveAgentWorkspaceDir.mockImplementation((_cfg: unknown, id: string) => `/ws/${id}`); + runBootOnce.mockResolvedValue({ status: "ran" }); + + await runBootChecklist(makeEvent({ context: { cfg } })); + + expect(listAgentIds).toHaveBeenCalledWith(cfg); + expect(runBootOnce).toHaveBeenCalledTimes(2); + expect(runBootOnce).toHaveBeenCalledWith( + expect.objectContaining({ cfg, workspaceDir: "/ws/main", agentId: "main" }), + ); + expect(runBootOnce).toHaveBeenCalledWith( + expect.objectContaining({ cfg, workspaceDir: "/ws/ops", agentId: "ops" }), + ); + }); + + it("runs boot for single default agent when no agents configured", async () => { + const cfg = {}; + listAgentIds.mockReturnValue(["main"]); + resolveAgentWorkspaceDir.mockReturnValue("/ws/main"); + runBootOnce.mockResolvedValue({ status: "skipped", reason: "missing" }); + + await runBootChecklist(makeEvent({ context: { cfg } })); + + expect(runBootOnce).toHaveBeenCalledTimes(1); + expect(runBootOnce).toHaveBeenCalledWith( + expect.objectContaining({ cfg, workspaceDir: "/ws/main", agentId: "main" }), + ); + }); + + it("logs warning details when a per-agent boot run fails", async () => { + const cfg = { agents: { list: [{ id: "main" }, { id: "ops" }] } }; + listAgentIds.mockReturnValue(["main", "ops"]); + resolveAgentWorkspaceDir.mockImplementation((_cfg: unknown, id: string) => `/ws/${id}`); + runBootOnce + .mockResolvedValueOnce({ status: "ran" }) + .mockResolvedValueOnce({ status: "failed", reason: "agent failed" }); + + await runBootChecklist(makeEvent({ context: { cfg } })); + + expect(logWarn).toHaveBeenCalledTimes(1); + expect(logWarn).toHaveBeenCalledWith("boot-md failed for agent startup run", { + agentId: "ops", + workspaceDir: "/ws/ops", + reason: "agent failed", + }); + }); + + it("logs debug details when a per-agent boot run is skipped", async () => { + const cfg = { agents: { list: [{ id: "main" }] } }; + listAgentIds.mockReturnValue(["main"]); + resolveAgentWorkspaceDir.mockReturnValue("/ws/main"); + runBootOnce.mockResolvedValue({ status: "skipped", reason: "missing" }); + + await runBootChecklist(makeEvent({ context: { cfg } })); + + expect(logDebug).toHaveBeenCalledWith("boot-md skipped for agent startup run", { + agentId: "main", + workspaceDir: "/ws/main", + reason: "missing", + }); + }); +}); diff --git a/src/hooks/bundled/boot-md/handler.ts b/src/hooks/bundled/boot-md/handler.ts index 6d41a144b4c..b5fcb065ac6 100644 --- a/src/hooks/bundled/boot-md/handler.ts +++ b/src/hooks/bundled/boot-md/handler.ts @@ -1,27 +1,44 @@ -import type { CliDeps } from "../../../cli/deps.js"; +import { listAgentIds, resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js"; import { createDefaultDeps } from "../../../cli/deps.js"; -import type { OpenClawConfig } from "../../../config/config.js"; import { runBootOnce } from "../../../gateway/boot.js"; +import { createSubsystemLogger } from "../../../logging/subsystem.js"; import type { HookHandler } from "../../hooks.js"; +import { isGatewayStartupEvent } from "../../internal-hooks.js"; -type BootHookContext = { - cfg?: OpenClawConfig; - workspaceDir?: string; - deps?: CliDeps; -}; +const log = createSubsystemLogger("hooks/boot-md"); const runBootChecklist: HookHandler = async (event) => { - if (event.type !== "gateway" || event.action !== "startup") { + if (!isGatewayStartupEvent(event)) { return; } - const context = (event.context ?? {}) as BootHookContext; - if (!context.cfg || !context.workspaceDir) { + if (!event.context.cfg) { return; } - const deps = context.deps ?? createDefaultDeps(); - await runBootOnce({ cfg: context.cfg, deps, workspaceDir: context.workspaceDir }); + const cfg = event.context.cfg; + const deps = event.context.deps ?? createDefaultDeps(); + const agentIds = listAgentIds(cfg); + + for (const agentId of agentIds) { + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); + const result = await runBootOnce({ cfg, deps, workspaceDir, agentId }); + if (result.status === "failed") { + log.warn("boot-md failed for agent startup run", { + agentId, + workspaceDir, + reason: result.reason, + }); + continue; + } + if (result.status === "skipped") { + log.debug("boot-md skipped for agent startup run", { + agentId, + workspaceDir, + reason: result.reason, + }); + } + } }; export default runBootChecklist; diff --git a/src/hooks/internal-hooks.test.ts b/src/hooks/internal-hooks.test.ts index 9a2b7998a58..110e72cde6e 100644 --- a/src/hooks/internal-hooks.test.ts +++ b/src/hooks/internal-hooks.test.ts @@ -4,12 +4,14 @@ import { createInternalHookEvent, getRegisteredEventKeys, isAgentBootstrapEvent, + isGatewayStartupEvent, isMessageReceivedEvent, isMessageSentEvent, registerInternalHook, triggerInternalHook, unregisterInternalHook, type AgentBootstrapHookContext, + type GatewayStartupHookContext, type MessageReceivedHookContext, type MessageSentHookContext, } from "./internal-hooks.js"; @@ -185,6 +187,21 @@ describe("hooks", () => { }); }); + describe("isGatewayStartupEvent", () => { + it("returns true for gateway:startup events with expected context", () => { + const context: GatewayStartupHookContext = { + cfg: {}, + }; + const event = createInternalHookEvent("gateway", "startup", "gateway:startup", context); + expect(isGatewayStartupEvent(event)).toBe(true); + }); + + it("returns false for non-startup gateway events", () => { + const event = createInternalHookEvent("gateway", "shutdown", "gateway:shutdown", {}); + expect(isGatewayStartupEvent(event)).toBe(false); + }); + }); + describe("isMessageReceivedEvent", () => { it("returns true for message:received events with expected context", () => { const context: MessageReceivedHookContext = { diff --git a/src/hooks/internal-hooks.ts b/src/hooks/internal-hooks.ts index 428c5ddf412..1e69057e4a8 100644 --- a/src/hooks/internal-hooks.ts +++ b/src/hooks/internal-hooks.ts @@ -6,6 +6,7 @@ */ import type { WorkspaceBootstrapFile } from "../agents/workspace.js"; +import type { CliDeps } from "../cli/deps.js"; import type { OpenClawConfig } from "../config/config.js"; export type InternalHookEventType = "command" | "session" | "agent" | "gateway" | "message"; @@ -25,6 +26,18 @@ export type AgentBootstrapHookEvent = InternalHookEvent & { context: AgentBootstrapHookContext; }; +export type GatewayStartupHookContext = { + cfg?: OpenClawConfig; + deps?: CliDeps; + workspaceDir?: string; +}; + +export type GatewayStartupHookEvent = InternalHookEvent & { + type: "gateway"; + action: "startup"; + context: GatewayStartupHookContext; +}; + // ============================================================================ // Message Hook Events // ============================================================================ @@ -234,6 +247,14 @@ export function isAgentBootstrapEvent(event: InternalHookEvent): event is AgentB return Array.isArray(context.bootstrapFiles); } +export function isGatewayStartupEvent(event: InternalHookEvent): event is GatewayStartupHookEvent { + if (event.type !== "gateway" || event.action !== "startup") { + return false; + } + const context = event.context as GatewayStartupHookContext | null; + return Boolean(context && typeof context === "object"); +} + export function isMessageReceivedEvent( event: InternalHookEvent, ): event is MessageReceivedHookEvent { From f855d0be4ff267d5cac24988a6997d71874d411e Mon Sep 17 00:00:00 2001 From: vikpos Date: Thu, 19 Feb 2026 06:09:33 +0000 Subject: [PATCH 0002/2904] fix: skip heartbeat when HEARTBEAT.md does not exist (#20461) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: f6e5f8172a334e2455ace5e93037e31567247271 Co-authored-by: vikpos <24960005+vikpos@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + docs/automation/troubleshooting.md | 3 +- src/infra/heartbeat-reason.test.ts | 52 ++++ src/infra/heartbeat-reason.ts | 54 +++++ .../heartbeat-runner.ghost-reminder.test.ts | 1 + .../heartbeat-runner.model-override.test.ts | 5 +- ...tbeat-runner.returns-default-unset.test.ts | 227 +++++++++++++++++- ...ner.sender-prefers-delivery-target.test.ts | 1 + src/infra/heartbeat-runner.test-utils.ts | 1 + src/infra/heartbeat-runner.ts | 143 ++++++++--- src/infra/heartbeat-wake.ts | 24 +- 11 files changed, 456 insertions(+), 56 deletions(-) create mode 100644 src/infra/heartbeat-reason.test.ts create mode 100644 src/infra/heartbeat-reason.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ba7f945fad7..3bd130a45ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Gateway/Hooks: run BOOT.md startup checks per configured agent scope, including per-agent session-key resolution, startup-hook regression coverage, and non-success boot outcome logging for diagnosability. (#20569) thanks @mcaxtr. - Telegram: unify message-like inbound handling so `message` and `channel_post` share the same dedupe/access/media pipeline and remain behaviorally consistent. (#20591) Thanks @obviyus. +- Heartbeat/Cron: skip interval heartbeats when `HEARTBEAT.md` is missing or empty and no tagged cron events are queued, while preserving cron-event fallback for queued tagged reminders. (#20461) thanks @vikpos. - Telegram/Agents: gate exec/bash tool-failure warnings behind verbose mode so default Telegram replies stay clean while verbose sessions still surface diagnostics. (#20560) Thanks @obviyus. - Gateway/Daemon: forward `TMPDIR` into installed service environments so macOS LaunchAgent gateway runs can open SQLite temp/journal files reliably instead of failing with `SQLITE_CANTOPEN`. (#20512) Thanks @Clawborn. - Agents/Billing: include the active model that produced a billing error in user-facing billing messages (for example, `OpenAI (gpt-5.3)`) across payload, failover, and lifecycle error paths, so users can identify exactly which key needs credits. (#20510) Thanks @echoVic. diff --git a/docs/automation/troubleshooting.md b/docs/automation/troubleshooting.md index 51f2aa209cf..a189d805221 100644 --- a/docs/automation/troubleshooting.md +++ b/docs/automation/troubleshooting.md @@ -89,7 +89,8 @@ Common signatures: - `heartbeat skipped` with `reason=quiet-hours` → outside `activeHours`. - `requests-in-flight` → main lane busy; heartbeat deferred. -- `empty-heartbeat-file` → `HEARTBEAT.md` exists but has no actionable content. +- `empty-heartbeat-file` → interval heartbeat skipped because `HEARTBEAT.md` has no actionable content and no tagged cron event is queued. +- `no-heartbeat-file` → interval heartbeat skipped because `HEARTBEAT.md` is missing and no tagged cron event is queued. - `alerts-disabled` → visibility settings suppress outbound heartbeat messages. ## Timezone and activeHours gotchas diff --git a/src/infra/heartbeat-reason.test.ts b/src/infra/heartbeat-reason.test.ts new file mode 100644 index 00000000000..6c2fdc68f97 --- /dev/null +++ b/src/infra/heartbeat-reason.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + isHeartbeatActionWakeReason, + isHeartbeatEventDrivenReason, + normalizeHeartbeatWakeReason, + resolveHeartbeatReasonKind, +} from "./heartbeat-reason.js"; + +describe("heartbeat-reason", () => { + it("normalizes wake reasons with trim + requested fallback", () => { + expect(normalizeHeartbeatWakeReason(" cron:job-1 ")).toBe("cron:job-1"); + expect(normalizeHeartbeatWakeReason(" ")).toBe("requested"); + expect(normalizeHeartbeatWakeReason(undefined)).toBe("requested"); + }); + + it("classifies known reason kinds", () => { + expect(resolveHeartbeatReasonKind("retry")).toBe("retry"); + expect(resolveHeartbeatReasonKind("interval")).toBe("interval"); + expect(resolveHeartbeatReasonKind("manual")).toBe("manual"); + expect(resolveHeartbeatReasonKind("exec-event")).toBe("exec-event"); + expect(resolveHeartbeatReasonKind("wake")).toBe("wake"); + expect(resolveHeartbeatReasonKind("cron:job-1")).toBe("cron"); + expect(resolveHeartbeatReasonKind("hook:wake")).toBe("hook"); + expect(resolveHeartbeatReasonKind(" hook:wake ")).toBe("hook"); + }); + + it("classifies unknown reasons as other", () => { + expect(resolveHeartbeatReasonKind("requested")).toBe("other"); + expect(resolveHeartbeatReasonKind("slow")).toBe("other"); + expect(resolveHeartbeatReasonKind("")).toBe("other"); + expect(resolveHeartbeatReasonKind(undefined)).toBe("other"); + }); + + it("matches event-driven behavior used by heartbeat preflight", () => { + expect(isHeartbeatEventDrivenReason("exec-event")).toBe(true); + expect(isHeartbeatEventDrivenReason("cron:job-1")).toBe(true); + expect(isHeartbeatEventDrivenReason("wake")).toBe(true); + expect(isHeartbeatEventDrivenReason("hook:gmail:sync")).toBe(true); + expect(isHeartbeatEventDrivenReason("interval")).toBe(false); + expect(isHeartbeatEventDrivenReason("manual")).toBe(false); + expect(isHeartbeatEventDrivenReason("other")).toBe(false); + }); + + it("matches action-priority wake behavior", () => { + expect(isHeartbeatActionWakeReason("manual")).toBe(true); + expect(isHeartbeatActionWakeReason("exec-event")).toBe(true); + expect(isHeartbeatActionWakeReason("hook:wake")).toBe(true); + expect(isHeartbeatActionWakeReason("interval")).toBe(false); + expect(isHeartbeatActionWakeReason("cron:job-1")).toBe(false); + expect(isHeartbeatActionWakeReason("retry")).toBe(false); + }); +}); diff --git a/src/infra/heartbeat-reason.ts b/src/infra/heartbeat-reason.ts new file mode 100644 index 00000000000..968b1e24062 --- /dev/null +++ b/src/infra/heartbeat-reason.ts @@ -0,0 +1,54 @@ +export type HeartbeatReasonKind = + | "retry" + | "interval" + | "manual" + | "exec-event" + | "wake" + | "cron" + | "hook" + | "other"; + +function trimReason(reason?: string): string { + return typeof reason === "string" ? reason.trim() : ""; +} + +export function normalizeHeartbeatWakeReason(reason?: string): string { + const trimmed = trimReason(reason); + return trimmed.length > 0 ? trimmed : "requested"; +} + +export function resolveHeartbeatReasonKind(reason?: string): HeartbeatReasonKind { + const trimmed = trimReason(reason); + if (trimmed === "retry") { + return "retry"; + } + if (trimmed === "interval") { + return "interval"; + } + if (trimmed === "manual") { + return "manual"; + } + if (trimmed === "exec-event") { + return "exec-event"; + } + if (trimmed === "wake") { + return "wake"; + } + if (trimmed.startsWith("cron:")) { + return "cron"; + } + if (trimmed.startsWith("hook:")) { + return "hook"; + } + return "other"; +} + +export function isHeartbeatEventDrivenReason(reason?: string): boolean { + const kind = resolveHeartbeatReasonKind(reason); + return kind === "exec-event" || kind === "cron" || kind === "wake" || kind === "hook"; +} + +export function isHeartbeatActionWakeReason(reason?: string): boolean { + const kind = resolveHeartbeatReasonKind(reason); + return kind === "manual" || kind === "exec-event" || kind === "hook"; +} diff --git a/src/infra/heartbeat-runner.ghost-reminder.test.ts b/src/infra/heartbeat-runner.ghost-reminder.test.ts index 28bf9a310a0..e0e66dd3105 100644 --- a/src/infra/heartbeat-runner.ghost-reminder.test.ts +++ b/src/infra/heartbeat-runner.ghost-reminder.test.ts @@ -182,6 +182,7 @@ describe("Ghost reminder bug (issue #13317)", () => { it("uses CRON_EVENT_PROMPT for tagged cron events on interval wake", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-interval-")); + await fs.writeFile(path.join(tmpDir, "HEARTBEAT.md"), "- Check status\n", "utf-8"); const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "155462274", diff --git a/src/infra/heartbeat-runner.model-override.test.ts b/src/infra/heartbeat-runner.model-override.test.ts index f3fe5ea6e69..fd5aa40fd23 100644 --- a/src/infra/heartbeat-runner.model-override.test.ts +++ b/src/infra/heartbeat-runner.model-override.test.ts @@ -51,6 +51,8 @@ async function withHeartbeatFixture( ); }; + await fs.writeFile(path.join(tmpDir, "HEARTBEAT.md"), "- Check status\n", "utf-8"); + try { return await run({ tmpDir, storePath, seedSession }); } finally { @@ -136,7 +138,7 @@ describe("runHeartbeatOnce – heartbeat model override", () => { }); it("passes per-agent heartbeat model override (merged with defaults)", async () => { - await withHeartbeatFixture(async ({ storePath, seedSession }) => { + await withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => { const cfg: OpenClawConfig = { agents: { defaults: { @@ -149,6 +151,7 @@ describe("runHeartbeatOnce – heartbeat model override", () => { { id: "main", default: true }, { id: "ops", + workspace: tmpDir, heartbeat: { every: "5m", target: "whatsapp", diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 299a7382199..c9c302141ae 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -25,6 +25,7 @@ import { resolveHeartbeatDeliveryTarget, resolveHeartbeatSenderContext, } from "./outbound/targets.js"; +import { enqueueSystemEvent, resetSystemEventsForTest } from "./system-events.js"; // Avoid pulling optional runtime deps during isolated runs. vi.mock("jiti", () => ({ createJiti: () => () => ({}) })); @@ -35,9 +36,12 @@ let testRegistry: ReturnType | null = null; let fixtureRoot = ""; let fixtureCount = 0; -const createCaseDir = async (prefix: string) => { +const createCaseDir = async (prefix: string, { skipHeartbeatFile = false } = {}) => { const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`); await fs.mkdir(dir, { recursive: true }); + if (!skipHeartbeatFile) { + await fs.writeFile(path.join(dir, "HEARTBEAT.md"), "- Check status\n", "utf-8"); + } return dir; }; @@ -101,6 +105,7 @@ beforeAll(async () => { }); beforeEach(() => { + resetSystemEventsForTest(); if (testRegistry) { setActivePluginRegistry(testRegistry); } @@ -542,6 +547,7 @@ describe("runHeartbeatOnce", () => { { id: "main", default: true }, { id: "ops", + workspace: tmpDir, heartbeat: { every: "5m", target: "whatsapp", prompt: "Ops check" }, }, ], @@ -611,6 +617,7 @@ describe("runHeartbeatOnce", () => { { id: "main", default: true }, { id: agentId, + workspace: tmpDir, heartbeat: { every: "5m", target: "whatsapp", prompt: "Ops check" }, }, ], @@ -1221,6 +1228,81 @@ describe("runHeartbeatOnce", () => { } }); + it("does not skip interval heartbeat when HEARTBEAT.md is empty but tagged cron events are queued", async () => { + const tmpDir = await createCaseDir("openclaw-hb"); + const storePath = path.join(tmpDir, "sessions.json"); + const workspaceDir = path.join(tmpDir, "workspace"); + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + try { + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.writeFile( + path.join(workspaceDir, "HEARTBEAT.md"), + "# HEARTBEAT.md\n\n## Tasks\n\n", + "utf-8", + ); + + const cfg: OpenClawConfig = { + agents: { + defaults: { + workspace: workspaceDir, + heartbeat: { every: "5m", target: "whatsapp" }, + }, + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storePath }, + }; + const sessionKey = resolveMainSessionKey(cfg); + + await fs.writeFile( + storePath, + JSON.stringify( + { + [sessionKey]: { + sessionId: "sid", + updatedAt: Date.now(), + lastChannel: "whatsapp", + lastTo: "+1555", + }, + }, + null, + 2, + ), + ); + + enqueueSystemEvent("Cron: QMD maintenance completed", { + sessionKey, + contextKey: "cron:qmd-maintenance", + }); + + replySpy.mockResolvedValue({ text: "Relay this cron update now" }); + const sendWhatsApp = vi.fn().mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); + + const res = await runHeartbeatOnce({ + cfg, + reason: "interval", + deps: { + sendWhatsApp, + getQueueSize: () => 0, + nowMs: () => 0, + webAuthExists: async () => true, + hasActiveWebListener: () => true, + }, + }); + + expect(res.status).toBe("ran"); + expect(replySpy).toHaveBeenCalledTimes(1); + const calledCtx = replySpy.mock.calls[0]?.[0] as { Provider?: string; Body?: string }; + expect(calledCtx.Provider).toBe("cron-event"); + expect(calledCtx.Body).toContain("scheduled reminder has been triggered"); + expect(sendWhatsApp).toHaveBeenCalledTimes(1); + } finally { + replySpy.mockRestore(); + } + }); + it("runs heartbeat when HEARTBEAT.md has actionable content", async () => { const tmpDir = await createCaseDir("openclaw-hb"); const storePath = path.join(tmpDir, "sessions.json"); @@ -1290,7 +1372,7 @@ describe("runHeartbeatOnce", () => { } }); - it("runs heartbeat when HEARTBEAT.md does not exist (lets LLM decide)", async () => { + it("skips heartbeat when HEARTBEAT.md does not exist (saves API calls)", async () => { const tmpDir = await createCaseDir("openclaw-hb"); const storePath = path.join(tmpDir, "sessions.json"); const workspaceDir = path.join(tmpDir, "workspace"); @@ -1344,9 +1426,148 @@ describe("runHeartbeatOnce", () => { }, }); - // Should run (not skip) - let LLM decide since file doesn't exist + // Should skip - no HEARTBEAT.md means nothing actionable + expect(res.status).toBe("skipped"); + if (res.status === "skipped") { + expect(res.reason).toBe("no-heartbeat-file"); + } + expect(replySpy).not.toHaveBeenCalled(); + expect(sendWhatsApp).not.toHaveBeenCalled(); + } finally { + replySpy.mockRestore(); + } + }); + + it("does not skip wake-triggered heartbeat when HEARTBEAT.md does not exist", async () => { + const tmpDir = await createCaseDir("openclaw-hb"); + const storePath = path.join(tmpDir, "sessions.json"); + const workspaceDir = path.join(tmpDir, "workspace"); + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + try { + await fs.mkdir(workspaceDir, { recursive: true }); + // Don't create HEARTBEAT.md + + const cfg: OpenClawConfig = { + agents: { + defaults: { + workspace: workspaceDir, + heartbeat: { every: "5m", target: "whatsapp" }, + }, + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storePath }, + }; + const sessionKey = resolveMainSessionKey(cfg); + + await fs.writeFile( + storePath, + JSON.stringify( + { + [sessionKey]: { + sessionId: "sid", + updatedAt: Date.now(), + lastChannel: "whatsapp", + lastTo: "+1555", + }, + }, + null, + 2, + ), + ); + + replySpy.mockResolvedValue({ text: "wake event processed" }); + const sendWhatsApp = vi.fn().mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); + + const res = await runHeartbeatOnce({ + cfg, + reason: "wake", + deps: { + sendWhatsApp, + getQueueSize: () => 0, + nowMs: () => 0, + webAuthExists: async () => true, + hasActiveWebListener: () => true, + }, + }); + + // Wake events should still run even without HEARTBEAT.md expect(res.status).toBe("ran"); expect(replySpy).toHaveBeenCalled(); + expect(sendWhatsApp).toHaveBeenCalledTimes(1); + } finally { + replySpy.mockRestore(); + } + }); + + it("does not skip interval heartbeat when tagged cron events are queued and HEARTBEAT.md is missing", async () => { + const tmpDir = await createCaseDir("openclaw-hb"); + const storePath = path.join(tmpDir, "sessions.json"); + const workspaceDir = path.join(tmpDir, "workspace"); + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + try { + await fs.mkdir(workspaceDir, { recursive: true }); + // Don't create HEARTBEAT.md + + const cfg: OpenClawConfig = { + agents: { + defaults: { + workspace: workspaceDir, + heartbeat: { every: "5m", target: "whatsapp" }, + }, + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storePath }, + }; + const sessionKey = resolveMainSessionKey(cfg); + + await fs.writeFile( + storePath, + JSON.stringify( + { + [sessionKey]: { + sessionId: "sid", + updatedAt: Date.now(), + lastChannel: "whatsapp", + lastTo: "+1555", + }, + }, + null, + 2, + ), + ); + + enqueueSystemEvent("Cron: QMD maintenance completed", { + sessionKey, + contextKey: "cron:qmd-maintenance", + }); + + replySpy.mockResolvedValue({ text: "Relay this cron update now" }); + const sendWhatsApp = vi.fn().mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); + + const res = await runHeartbeatOnce({ + cfg, + reason: "interval", + deps: { + sendWhatsApp, + getQueueSize: () => 0, + nowMs: () => 0, + webAuthExists: async () => true, + hasActiveWebListener: () => true, + }, + }); + + expect(res.status).toBe("ran"); + expect(replySpy).toHaveBeenCalledTimes(1); + const calledCtx = replySpy.mock.calls[0]?.[0] as { Provider?: string; Body?: string }; + expect(calledCtx.Provider).toBe("cron-event"); + expect(calledCtx.Body).toContain("scheduled reminder has been triggered"); + expect(sendWhatsApp).toHaveBeenCalledTimes(1); } finally { replySpy.mockRestore(); } diff --git a/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts b/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts index b244ef669e4..6c13476cd3b 100644 --- a/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts +++ b/src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts @@ -16,6 +16,7 @@ installHeartbeatRunnerTestRuntime({ includeSlack: true }); describe("runHeartbeatOnce", () => { it("uses the delivery target as sender when lastTo differs", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-")); + await fs.writeFile(path.join(tmpDir, "HEARTBEAT.md"), "- Check status\n", "utf-8"); const storePath = path.join(tmpDir, "sessions.json"); const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); try { diff --git a/src/infra/heartbeat-runner.test-utils.ts b/src/infra/heartbeat-runner.test-utils.ts index 8a187423e58..7e7ccdc211c 100644 --- a/src/infra/heartbeat-runner.test-utils.ts +++ b/src/infra/heartbeat-runner.test-utils.ts @@ -45,6 +45,7 @@ export async function withTempHeartbeatSandbox( }, ): Promise { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), options?.prefix ?? "openclaw-hb-")); + await fs.writeFile(path.join(tmpDir, "HEARTBEAT.md"), "- Check status\n", "utf-8"); const storePath = path.join(tmpDir, "sessions.json"); const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); const previousEnv = new Map(); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index d8b0f5db92b..70ee5e34e10 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -49,6 +49,7 @@ import { isExecCompletionEvent, } from "./heartbeat-events-filter.js"; import { emitHeartbeatEvent, resolveIndicatorType } from "./heartbeat-events.js"; +import { resolveHeartbeatReasonKind } from "./heartbeat-reason.js"; import { resolveHeartbeatVisibility } from "./heartbeat-visibility.js"; import { type HeartbeatRunResult, @@ -474,6 +475,94 @@ function normalizeHeartbeatReply( return { shouldSkip: false, text: finalText, hasMedia }; } +type HeartbeatReasonFlags = { + isExecEventReason: boolean; + isCronEventReason: boolean; + isWakeReason: boolean; +}; + +type HeartbeatSkipReason = "empty-heartbeat-file" | "no-heartbeat-file"; + +type HeartbeatPreflight = HeartbeatReasonFlags & { + session: ReturnType; + pendingEventEntries: ReturnType; + hasTaggedCronEvents: boolean; + shouldInspectPendingEvents: boolean; + skipReason?: HeartbeatSkipReason; +}; + +function resolveHeartbeatReasonFlags(reason?: string): HeartbeatReasonFlags { + const reasonKind = resolveHeartbeatReasonKind(reason); + return { + isExecEventReason: reasonKind === "exec-event", + isCronEventReason: reasonKind === "cron", + isWakeReason: reasonKind === "wake" || reasonKind === "hook", + }; +} + +async function resolveHeartbeatPreflight(params: { + cfg: OpenClawConfig; + agentId: string; + heartbeat?: HeartbeatConfig; + forcedSessionKey?: string; + reason?: string; +}): Promise { + const reasonFlags = resolveHeartbeatReasonFlags(params.reason); + const session = resolveHeartbeatSession( + params.cfg, + params.agentId, + params.heartbeat, + params.forcedSessionKey, + ); + const pendingEventEntries = peekSystemEventEntries(session.sessionKey); + const hasTaggedCronEvents = pendingEventEntries.some((event) => + event.contextKey?.startsWith("cron:"), + ); + const shouldInspectPendingEvents = + reasonFlags.isExecEventReason || reasonFlags.isCronEventReason || hasTaggedCronEvents; + const shouldBypassFileGates = + reasonFlags.isExecEventReason || + reasonFlags.isCronEventReason || + reasonFlags.isWakeReason || + hasTaggedCronEvents; + + const workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId); + const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME); + try { + const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8"); + if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) && !shouldBypassFileGates) { + return { + ...reasonFlags, + session, + pendingEventEntries, + hasTaggedCronEvents, + shouldInspectPendingEvents, + skipReason: "empty-heartbeat-file", + }; + } + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException)?.code === "ENOENT" && !shouldBypassFileGates) { + return { + ...reasonFlags, + session, + pendingEventEntries, + hasTaggedCronEvents, + shouldInspectPendingEvents, + skipReason: "no-heartbeat-file", + }; + } + // For other read errors, proceed with heartbeat as before. + } + + return { + ...reasonFlags, + session, + pendingEventEntries, + hasTaggedCronEvents, + shouldInspectPendingEvents, + }; +} + export async function runHeartbeatOnce(opts: { cfg?: OpenClawConfig; agentId?: string; @@ -505,41 +594,24 @@ export async function runHeartbeatOnce(opts: { return { status: "skipped", reason: "requests-in-flight" }; } - // Skip heartbeat if HEARTBEAT.md exists but has no actionable content. - // This saves API calls/costs when the file is effectively empty (only comments/headers). - // EXCEPTION: Don't skip for exec events, cron events, or explicit wake requests - - // they have pending system events to process regardless of HEARTBEAT.md content. - const isExecEventReason = opts.reason === "exec-event"; - const isCronEventReason = Boolean(opts.reason?.startsWith("cron:")); - const isWakeReason = opts.reason === "wake" || Boolean(opts.reason?.startsWith("hook:")); - const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); - const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME); - try { - const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8"); - if ( - isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) && - !isExecEventReason && - !isCronEventReason && - !isWakeReason - ) { - emitHeartbeatEvent({ - status: "skipped", - reason: "empty-heartbeat-file", - durationMs: Date.now() - startedAt, - }); - return { status: "skipped", reason: "empty-heartbeat-file" }; - } - } catch { - // File doesn't exist or can't be read - proceed with heartbeat. - // The LLM prompt says "if it exists" so this is expected behavior. - } - - const { entry, sessionKey, storePath } = resolveHeartbeatSession( + // Preflight centralizes trigger classification, event inspection, and HEARTBEAT.md gating. + const preflight = await resolveHeartbeatPreflight({ cfg, agentId, heartbeat, - opts.sessionKey, - ); + forcedSessionKey: opts.sessionKey, + reason: opts.reason, + }); + if (preflight.skipReason) { + emitHeartbeatEvent({ + status: "skipped", + reason: preflight.skipReason, + durationMs: Date.now() - startedAt, + }); + return { status: "skipped", reason: preflight.skipReason }; + } + const { entry, sessionKey, storePath } = preflight.session; + const { isCronEventReason, pendingEventEntries } = preflight; const previousUpdatedAt = entry?.updatedAt; const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat }); const heartbeatAccountId = heartbeat?.accountId?.trim(); @@ -572,12 +644,7 @@ export async function runHeartbeatOnce(opts: { // Check if this is an exec event or cron event with pending system events. // If so, use a specialized prompt that instructs the model to relay the result // instead of the standard heartbeat prompt with "reply HEARTBEAT_OK". - const isExecEvent = opts.reason === "exec-event"; - const pendingEventEntries = peekSystemEventEntries(sessionKey); - const hasTaggedCronEvents = pendingEventEntries.some((event) => - event.contextKey?.startsWith("cron:"), - ); - const shouldInspectPendingEvents = isExecEvent || isCronEventReason || hasTaggedCronEvents; + const shouldInspectPendingEvents = preflight.shouldInspectPendingEvents; const pendingEvents = shouldInspectPendingEvents ? pendingEventEntries.map((event) => event.text) : []; diff --git a/src/infra/heartbeat-wake.ts b/src/infra/heartbeat-wake.ts index d1dcfb03953..bccfdfe9829 100644 --- a/src/infra/heartbeat-wake.ts +++ b/src/infra/heartbeat-wake.ts @@ -1,3 +1,9 @@ +import { + isHeartbeatActionWakeReason, + normalizeHeartbeatWakeReason, + resolveHeartbeatReasonKind, +} from "./heartbeat-reason.js"; + export type HeartbeatRunResult = | { status: "ran"; durationMs: number } | { status: "skipped"; reason: string } @@ -29,7 +35,6 @@ let timerKind: WakeTimerKind | null = null; const DEFAULT_COALESCE_MS = 250; const DEFAULT_RETRY_MS = 1_000; -const HOOK_REASON_PREFIX = "hook:"; const REASON_PRIORITY = { RETRY: 0, INTERVAL: 1, @@ -37,29 +42,22 @@ const REASON_PRIORITY = { ACTION: 3, } as const; -function isActionWakeReason(reason: string): boolean { - return reason === "manual" || reason === "exec-event" || reason.startsWith(HOOK_REASON_PREFIX); -} - function resolveReasonPriority(reason: string): number { - if (reason === "retry") { + const kind = resolveHeartbeatReasonKind(reason); + if (kind === "retry") { return REASON_PRIORITY.RETRY; } - if (reason === "interval") { + if (kind === "interval") { return REASON_PRIORITY.INTERVAL; } - if (isActionWakeReason(reason)) { + if (isHeartbeatActionWakeReason(reason)) { return REASON_PRIORITY.ACTION; } return REASON_PRIORITY.DEFAULT; } function normalizeWakeReason(reason?: string): string { - if (typeof reason !== "string") { - return "requested"; - } - const trimmed = reason.trim(); - return trimmed.length > 0 ? trimmed : "requested"; + return normalizeHeartbeatWakeReason(reason); } function normalizeWakeTarget(value?: string): string | undefined { From 6355bae1f90fd037f513c0ac1bd5500eac79ee23 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 19 Feb 2026 01:14:06 -0500 Subject: [PATCH 0003/2904] test: make boot-md startup integration workspace assertion cross-platform --- .../boot-md/handler.gateway-startup.integration.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/hooks/bundled/boot-md/handler.gateway-startup.integration.test.ts b/src/hooks/bundled/boot-md/handler.gateway-startup.integration.test.ts index cfbc0bb420b..0bd0f264a64 100644 --- a/src/hooks/bundled/boot-md/handler.gateway-startup.integration.test.ts +++ b/src/hooks/bundled/boot-md/handler.gateway-startup.integration.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js"; import type { CliDeps } from "../../../cli/deps.js"; import type { OpenClawConfig } from "../../../config/config.js"; @@ -43,14 +44,17 @@ describe("boot-md startup hook integration", () => { const event = createInternalHookEvent("gateway", "startup", "gateway:startup", { cfg, deps }); await triggerInternalHook(event); + const mainWorkspaceDir = resolveAgentWorkspaceDir(cfg, "main"); + const opsWorkspaceDir = resolveAgentWorkspaceDir(cfg, "ops"); + expect(runBootOnce).toHaveBeenCalledTimes(2); expect(runBootOnce).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ cfg, deps, workspaceDir: "/ws/main", agentId: "main" }), + expect.objectContaining({ cfg, deps, workspaceDir: mainWorkspaceDir, agentId: "main" }), ); expect(runBootOnce).toHaveBeenNthCalledWith( 2, - expect.objectContaining({ cfg, deps, workspaceDir: "/ws/ops", agentId: "ops" }), + expect.objectContaining({ cfg, deps, workspaceDir: opsWorkspaceDir, agentId: "ops" }), ); }); }); From 8d048d412f5eed7639de155643c3575d9392d504 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 06:43:17 +0000 Subject: [PATCH 0004/2904] refactor(queue): share next-item drain helper across queue drains --- src/agents/subagent-announce-queue.ts | 26 ++++++++------------- src/auto-reply/reply/queue/drain.ts | 33 +++++++++++---------------- src/utils/queue-helpers.ts | 13 +++++++++++ 3 files changed, 36 insertions(+), 36 deletions(-) diff --git a/src/agents/subagent-announce-queue.ts b/src/agents/subagent-announce-queue.ts index 864f2cbe7d3..e0dc8fcbfa2 100644 --- a/src/agents/subagent-announce-queue.ts +++ b/src/agents/subagent-announce-queue.ts @@ -10,6 +10,7 @@ import { applyQueueDropPolicy, buildCollectPrompt, clearQueueSummaryState, + drainNextQueueItem, hasCrossChannelItems, previewQueueSummaryPrompt, waitForQueueDebounce, @@ -108,12 +109,9 @@ function scheduleAnnounceDrain(key: string) { await waitForQueueDebounce(queue); if (queue.mode === "collect") { if (forceIndividualCollect) { - const next = queue.items[0]; - if (!next) { + if (!(await drainNextQueueItem(queue.items, async (item) => await queue.send(item)))) { break; } - await queue.send(next); - queue.items.shift(); continue; } const isCrossChannel = hasCrossChannelItems(queue.items, (item) => { @@ -127,12 +125,9 @@ function scheduleAnnounceDrain(key: string) { }); if (isCrossChannel) { forceIndividualCollect = true; - const next = queue.items[0]; - if (!next) { + if (!(await drainNextQueueItem(queue.items, async (item) => await queue.send(item)))) { break; } - await queue.send(next); - queue.items.shift(); continue; } const items = queue.items.slice(); @@ -157,22 +152,21 @@ function scheduleAnnounceDrain(key: string) { const summaryPrompt = previewQueueSummaryPrompt({ state: queue, noun: "announce" }); if (summaryPrompt) { - const next = queue.items[0]; - if (!next) { + if ( + !(await drainNextQueueItem( + queue.items, + async (item) => await queue.send({ ...item, prompt: summaryPrompt }), + )) + ) { break; } - await queue.send({ ...next, prompt: summaryPrompt }); - queue.items.shift(); clearQueueSummaryState(queue); continue; } - const next = queue.items[0]; - if (!next) { + if (!(await drainNextQueueItem(queue.items, async (item) => await queue.send(item)))) { break; } - await queue.send(next); - queue.items.shift(); } } catch (err) { // Keep items in queue and retry after debounce; avoid hot-loop retries. diff --git a/src/auto-reply/reply/queue/drain.ts b/src/auto-reply/reply/queue/drain.ts index 3d739b3dc31..be409b3c742 100644 --- a/src/auto-reply/reply/queue/drain.ts +++ b/src/auto-reply/reply/queue/drain.ts @@ -2,6 +2,7 @@ import { defaultRuntime } from "../../../runtime.js"; import { buildCollectPrompt, clearQueueSummaryState, + drainNextQueueItem, hasCrossChannelItems, previewQueueSummaryPrompt, waitForQueueDebounce, @@ -30,12 +31,9 @@ export function scheduleFollowupDrain( // // Debug: `pnpm test src/auto-reply/reply/queue.collect-routing.test.ts` if (forceIndividualCollect) { - const next = queue.items[0]; - if (!next) { + if (!(await drainNextQueueItem(queue.items, runFollowup))) { break; } - await runFollowup(next); - queue.items.shift(); continue; } @@ -60,12 +58,9 @@ export function scheduleFollowupDrain( if (isCrossChannel) { forceIndividualCollect = true; - const next = queue.items[0]; - if (!next) { + if (!(await drainNextQueueItem(queue.items, runFollowup))) { break; } - await runFollowup(next); - queue.items.shift(); continue; } @@ -114,26 +109,24 @@ export function scheduleFollowupDrain( if (!run) { break; } - const next = queue.items[0]; - if (!next) { + if ( + !(await drainNextQueueItem(queue.items, async () => { + await runFollowup({ + prompt: summaryPrompt, + run, + enqueuedAt: Date.now(), + }); + })) + ) { break; } - await runFollowup({ - prompt: summaryPrompt, - run, - enqueuedAt: Date.now(), - }); - queue.items.shift(); clearQueueSummaryState(queue); continue; } - const next = queue.items[0]; - if (!next) { + if (!(await drainNextQueueItem(queue.items, runFollowup))) { break; } - await runFollowup(next); - queue.items.shift(); } } catch (err) { queue.lastEnqueuedAt = Date.now(); diff --git a/src/utils/queue-helpers.ts b/src/utils/queue-helpers.ts index 1a5d310c062..4ebb627e89a 100644 --- a/src/utils/queue-helpers.ts +++ b/src/utils/queue-helpers.ts @@ -129,6 +129,19 @@ export function waitForQueueDebounce(queue: { }); } +export async function drainNextQueueItem( + items: T[], + run: (item: T) => Promise, +): Promise { + const next = items[0]; + if (!next) { + return false; + } + await run(next); + items.shift(); + return true; +} + export function buildQueueSummaryPrompt(params: { state: QueueSummaryState; noun: string; From fa31f1cad2c2c7d14ec0b2e2be7058d3c08d774f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 06:43:22 +0000 Subject: [PATCH 0005/2904] refactor(cli): reuse allowlist mutation flow in approvals CLI --- src/cli/exec-approvals-cli.test.ts | 36 +++++++++ src/cli/exec-approvals-cli.ts | 114 ++++++++++++++++++----------- 2 files changed, 106 insertions(+), 44 deletions(-) diff --git a/src/cli/exec-approvals-cli.test.ts b/src/cli/exec-approvals-cli.test.ts index cfff096a110..73d511da053 100644 --- a/src/cli/exec-approvals-cli.test.ts +++ b/src/cli/exec-approvals-cli.test.ts @@ -24,6 +24,10 @@ const localSnapshot = { file: { version: 1, agents: {} }, }; +function resetLocalSnapshot() { + localSnapshot.file = { version: 1, agents: {} }; +} + vi.mock("./gateway-rpc.js", () => ({ callGatewayFromCli: (method: string, opts: unknown, params?: unknown) => callGatewayFromCli(method, opts, params), @@ -64,6 +68,7 @@ describe("exec approvals CLI", () => { }; it("routes get command to local, gateway, and node modes", async () => { + resetLocalSnapshot(); resetRuntimeCapture(); callGatewayFromCli.mockClear(); @@ -91,6 +96,7 @@ describe("exec approvals CLI", () => { }); it("defaults allowlist add to wildcard agent", async () => { + resetLocalSnapshot(); resetRuntimeCapture(); callGatewayFromCli.mockClear(); @@ -116,4 +122,34 @@ describe("exec approvals CLI", () => { }), ); }); + + it("removes wildcard allowlist entry and prunes empty agent", async () => { + resetLocalSnapshot(); + localSnapshot.file = { + version: 1, + agents: { + "*": { + allowlist: [{ pattern: "/usr/bin/uname", lastUsedAt: Date.now() }], + }, + }, + }; + resetRuntimeCapture(); + callGatewayFromCli.mockClear(); + + const saveExecApprovals = vi.mocked(execApprovals.saveExecApprovals); + saveExecApprovals.mockClear(); + + const program = createProgram(); + await program.parseAsync(["approvals", "allowlist", "remove", "/usr/bin/uname"], { + from: "user", + }); + + expect(saveExecApprovals).toHaveBeenCalledWith( + expect.objectContaining({ + version: 1, + agents: undefined, + }), + ); + expect(runtimeErrors).toHaveLength(0); + }); }); diff --git a/src/cli/exec-approvals-cli.ts b/src/cli/exec-approvals-cli.ts index b09938a2a15..291617df74b 100644 --- a/src/cli/exec-approvals-cli.ts +++ b/src/cli/exec-approvals-cli.ts @@ -292,6 +292,36 @@ async function loadWritableAllowlistAgent(opts: ExecApprovalsCliOpts): Promise<{ return { nodeId, source, targetLabel, baseHash, file, agentKey, agent, allowlistEntries }; } +type WritableAllowlistAgentContext = Awaited> & { + trimmedPattern: string; +}; + +async function runAllowlistMutation( + pattern: string, + opts: ExecApprovalsCliOpts, + mutate: (context: WritableAllowlistAgentContext) => boolean | Promise, +): Promise { + try { + const trimmedPattern = requireTrimmedNonEmpty(pattern, "Pattern required."); + const context = await loadWritableAllowlistAgent(opts); + const shouldSave = await mutate({ ...context, trimmedPattern }); + if (!shouldSave) { + return; + } + await saveSnapshotTargeted({ + opts, + source: context.source, + nodeId: context.nodeId, + file: context.file, + baseHash: context.baseHash, + targetLabel: context.targetLabel, + }); + } catch (err) { + defaultRuntime.error(formatCliError(err)); + defaultRuntime.exit(1); + } +} + export function registerExecApprovalsCli(program: Command) { const formatExample = (cmd: string, desc: string) => ` ${theme.command(cmd)}\n ${theme.muted(desc)}`; @@ -393,22 +423,20 @@ export function registerExecApprovalsCli(program: Command) { .option("--gateway", "Force gateway approvals", false) .option("--agent ", 'Agent id (defaults to "*")') .action(async (pattern: string, opts: ExecApprovalsCliOpts) => { - try { - const trimmed = requireTrimmedNonEmpty(pattern, "Pattern required."); - const { nodeId, source, targetLabel, baseHash, file, agentKey, agent, allowlistEntries } = - await loadWritableAllowlistAgent(opts); - if (allowlistEntries.some((entry) => normalizeAllowlistEntry(entry) === trimmed)) { - defaultRuntime.log("Already allowlisted."); - return; - } - allowlistEntries.push({ pattern: trimmed, lastUsedAt: Date.now() }); - agent.allowlist = allowlistEntries; - file.agents = { ...file.agents, [agentKey]: agent }; - await saveSnapshotTargeted({ opts, source, nodeId, file, baseHash, targetLabel }); - } catch (err) { - defaultRuntime.error(formatCliError(err)); - defaultRuntime.exit(1); - } + await runAllowlistMutation( + pattern, + opts, + ({ trimmedPattern, file, agent, agentKey, allowlistEntries }) => { + if (allowlistEntries.some((entry) => normalizeAllowlistEntry(entry) === trimmedPattern)) { + defaultRuntime.log("Already allowlisted."); + return false; + } + allowlistEntries.push({ pattern: trimmedPattern, lastUsedAt: Date.now() }); + agent.allowlist = allowlistEntries; + file.agents = { ...file.agents, [agentKey]: agent }; + return true; + }, + ); }); nodesCallOpts(allowlistAdd); @@ -419,34 +447,32 @@ export function registerExecApprovalsCli(program: Command) { .option("--gateway", "Force gateway approvals", false) .option("--agent ", 'Agent id (defaults to "*")') .action(async (pattern: string, opts: ExecApprovalsCliOpts) => { - try { - const trimmed = requireTrimmedNonEmpty(pattern, "Pattern required."); - const { nodeId, source, targetLabel, baseHash, file, agentKey, agent, allowlistEntries } = - await loadWritableAllowlistAgent(opts); - const nextEntries = allowlistEntries.filter( - (entry) => normalizeAllowlistEntry(entry) !== trimmed, - ); - if (nextEntries.length === allowlistEntries.length) { - defaultRuntime.log("Pattern not found."); - return; - } - if (nextEntries.length === 0) { - delete agent.allowlist; - } else { - agent.allowlist = nextEntries; - } - if (isEmptyAgent(agent)) { - const agents = { ...file.agents }; - delete agents[agentKey]; - file.agents = Object.keys(agents).length > 0 ? agents : undefined; - } else { - file.agents = { ...file.agents, [agentKey]: agent }; - } - await saveSnapshotTargeted({ opts, source, nodeId, file, baseHash, targetLabel }); - } catch (err) { - defaultRuntime.error(formatCliError(err)); - defaultRuntime.exit(1); - } + await runAllowlistMutation( + pattern, + opts, + ({ trimmedPattern, file, agent, agentKey, allowlistEntries }) => { + const nextEntries = allowlistEntries.filter( + (entry) => normalizeAllowlistEntry(entry) !== trimmedPattern, + ); + if (nextEntries.length === allowlistEntries.length) { + defaultRuntime.log("Pattern not found."); + return false; + } + if (nextEntries.length === 0) { + delete agent.allowlist; + } else { + agent.allowlist = nextEntries; + } + if (isEmptyAgent(agent)) { + const agents = { ...file.agents }; + delete agents[agentKey]; + file.agents = Object.keys(agents).length > 0 ? agents : undefined; + } else { + file.agents = { ...file.agents, [agentKey]: agent }; + } + return true; + }, + ); }); nodesCallOpts(allowlistRemove); } From 858286aecb82da6d6bb8f23ceb098e0700889fea Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 06:43:26 +0000 Subject: [PATCH 0006/2904] refactor(cli): centralize memory manager setup wiring --- src/cli/memory-cli.ts | 60 ++++++++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/src/cli/memory-cli.ts b/src/cli/memory-cli.ts index 82dbb50e70e..6449653f8ac 100644 --- a/src/cli/memory-cli.ts +++ b/src/cli/memory-cli.ts @@ -28,6 +28,7 @@ type MemoryCommandOptions = { }; type MemoryManager = NonNullable; +type MemoryManagerPurpose = Parameters[0]["purpose"]; type MemorySourceName = "memory" | "sessions"; @@ -82,6 +83,31 @@ function formatExtraPaths(workspaceDir: string, extraPaths: string[]): string[] return normalizeExtraMemoryPaths(workspaceDir, extraPaths).map((entry) => shortenHomePath(entry)); } +async function withMemoryManagerForAgent(params: { + cfg: ReturnType; + agentId: string; + purpose?: MemoryManagerPurpose; + run: (manager: MemoryManager) => Promise; +}): Promise { + const managerParams: Parameters[0] = { + cfg: params.cfg, + agentId: params.agentId, + }; + if (params.purpose) { + managerParams.purpose = params.purpose; + } + await withManager({ + getManager: () => getMemorySearchManager(managerParams), + onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."), + onCloseError: (err) => + defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`), + close: async (manager) => { + await manager.close?.(); + }, + run: params.run, + }); +} + async function checkReadableFile(pathname: string): Promise<{ exists: boolean; issue?: string }> { try { await fs.access(pathname, fsSync.constants.R_OK); @@ -283,14 +309,10 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) { for (const agentId of agentIds) { const managerPurpose = opts.index ? "default" : "status"; - await withManager({ - getManager: () => getMemorySearchManager({ cfg, agentId, purpose: managerPurpose }), - onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."), - onCloseError: (err) => - defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`), - close: async (manager) => { - await manager.close?.(); - }, + await withMemoryManagerForAgent({ + cfg, + agentId, + purpose: managerPurpose, run: async (manager) => { const deep = Boolean(opts.deep || opts.index); let embeddingProbe: @@ -551,14 +573,9 @@ export function registerMemoryCli(program: Command) { const cfg = loadConfig(); const agentIds = resolveAgentIds(cfg, opts.agent); for (const agentId of agentIds) { - await withManager({ - getManager: () => getMemorySearchManager({ cfg, agentId }), - onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."), - onCloseError: (err) => - defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`), - close: async (manager) => { - await manager.close?.(); - }, + await withMemoryManagerForAgent({ + cfg, + agentId, run: async (manager) => { try { const syncFn = manager.sync ? manager.sync.bind(manager) : undefined; @@ -700,14 +717,9 @@ export function registerMemoryCli(program: Command) { ) => { const cfg = loadConfig(); const agentId = resolveAgent(cfg, opts.agent); - await withManager({ - getManager: () => getMemorySearchManager({ cfg, agentId }), - onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."), - onCloseError: (err) => - defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`), - close: async (manager) => { - await manager.close?.(); - }, + await withMemoryManagerForAgent({ + cfg, + agentId, run: async (manager) => { let results: Awaited>; try { From d5c58ce8d9f6dff3a6a22593041e83ff72fc3670 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 06:43:29 +0000 Subject: [PATCH 0007/2904] test: normalize boot-md mock workspace paths for cross-platform --- src/hooks/bundled/boot-md/handler.test.ts | 25 +++++++++++++++-------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/hooks/bundled/boot-md/handler.test.ts b/src/hooks/bundled/boot-md/handler.test.ts index ee19f7cc1e9..62fdc990175 100644 --- a/src/hooks/bundled/boot-md/handler.test.ts +++ b/src/hooks/bundled/boot-md/handler.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { InternalHookEvent } from "../../internal-hooks.js"; @@ -6,6 +7,8 @@ const listAgentIds = vi.fn(); const resolveAgentWorkspaceDir = vi.fn(); const logWarn = vi.fn(); const logDebug = vi.fn(); +const MAIN_WORKSPACE_DIR = path.join(path.sep, "ws", "main"); +const OPS_WORKSPACE_DIR = path.join(path.sep, "ws", "ops"); vi.mock("../../../gateway/boot.js", () => ({ runBootOnce })); vi.mock("../../../agents/agent-scope.js", () => ({ @@ -58,7 +61,9 @@ describe("boot-md handler", () => { it("runs boot for each agent", async () => { const cfg = { agents: { list: [{ id: "main" }, { id: "ops" }] } }; listAgentIds.mockReturnValue(["main", "ops"]); - resolveAgentWorkspaceDir.mockImplementation((_cfg: unknown, id: string) => `/ws/${id}`); + resolveAgentWorkspaceDir.mockImplementation((_cfg: unknown, id: string) => + id === "main" ? MAIN_WORKSPACE_DIR : OPS_WORKSPACE_DIR, + ); runBootOnce.mockResolvedValue({ status: "ran" }); await runBootChecklist(makeEvent({ context: { cfg } })); @@ -66,31 +71,33 @@ describe("boot-md handler", () => { expect(listAgentIds).toHaveBeenCalledWith(cfg); expect(runBootOnce).toHaveBeenCalledTimes(2); expect(runBootOnce).toHaveBeenCalledWith( - expect.objectContaining({ cfg, workspaceDir: "/ws/main", agentId: "main" }), + expect.objectContaining({ cfg, workspaceDir: MAIN_WORKSPACE_DIR, agentId: "main" }), ); expect(runBootOnce).toHaveBeenCalledWith( - expect.objectContaining({ cfg, workspaceDir: "/ws/ops", agentId: "ops" }), + expect.objectContaining({ cfg, workspaceDir: OPS_WORKSPACE_DIR, agentId: "ops" }), ); }); it("runs boot for single default agent when no agents configured", async () => { const cfg = {}; listAgentIds.mockReturnValue(["main"]); - resolveAgentWorkspaceDir.mockReturnValue("/ws/main"); + resolveAgentWorkspaceDir.mockReturnValue(MAIN_WORKSPACE_DIR); runBootOnce.mockResolvedValue({ status: "skipped", reason: "missing" }); await runBootChecklist(makeEvent({ context: { cfg } })); expect(runBootOnce).toHaveBeenCalledTimes(1); expect(runBootOnce).toHaveBeenCalledWith( - expect.objectContaining({ cfg, workspaceDir: "/ws/main", agentId: "main" }), + expect.objectContaining({ cfg, workspaceDir: MAIN_WORKSPACE_DIR, agentId: "main" }), ); }); it("logs warning details when a per-agent boot run fails", async () => { const cfg = { agents: { list: [{ id: "main" }, { id: "ops" }] } }; listAgentIds.mockReturnValue(["main", "ops"]); - resolveAgentWorkspaceDir.mockImplementation((_cfg: unknown, id: string) => `/ws/${id}`); + resolveAgentWorkspaceDir.mockImplementation((_cfg: unknown, id: string) => + id === "main" ? MAIN_WORKSPACE_DIR : OPS_WORKSPACE_DIR, + ); runBootOnce .mockResolvedValueOnce({ status: "ran" }) .mockResolvedValueOnce({ status: "failed", reason: "agent failed" }); @@ -100,7 +107,7 @@ describe("boot-md handler", () => { expect(logWarn).toHaveBeenCalledTimes(1); expect(logWarn).toHaveBeenCalledWith("boot-md failed for agent startup run", { agentId: "ops", - workspaceDir: "/ws/ops", + workspaceDir: OPS_WORKSPACE_DIR, reason: "agent failed", }); }); @@ -108,14 +115,14 @@ describe("boot-md handler", () => { it("logs debug details when a per-agent boot run is skipped", async () => { const cfg = { agents: { list: [{ id: "main" }] } }; listAgentIds.mockReturnValue(["main"]); - resolveAgentWorkspaceDir.mockReturnValue("/ws/main"); + resolveAgentWorkspaceDir.mockReturnValue(MAIN_WORKSPACE_DIR); runBootOnce.mockResolvedValue({ status: "skipped", reason: "missing" }); await runBootChecklist(makeEvent({ context: { cfg } })); expect(logDebug).toHaveBeenCalledWith("boot-md skipped for agent startup run", { agentId: "main", - workspaceDir: "/ws/main", + workspaceDir: MAIN_WORKSPACE_DIR, reason: "missing", }); }); From 2f6b8663ffe4bf42f9b6c84d22f12aefcc3196e7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 06:48:14 +0000 Subject: [PATCH 0008/2904] refactor(shared): reuse outbound text chunking core --- src/auto-reply/chunk.ts | 36 ++++------------------------ src/plugin-sdk/text-chunking.test.ts | 16 +++++++++++++ src/plugin-sdk/text-chunking.ts | 32 ++++--------------------- src/shared/text-chunking.ts | 34 ++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 59 deletions(-) create mode 100644 src/plugin-sdk/text-chunking.test.ts create mode 100644 src/shared/text-chunking.ts diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts index e91b9e86833..a40eebb82cb 100644 --- a/src/auto-reply/chunk.ts +++ b/src/auto-reply/chunk.ts @@ -6,6 +6,7 @@ import type { ChannelId } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import { findFenceSpanAt, isSafeFenceBreak, parseFenceSpans } from "../markdown/fences.js"; import { normalizeAccountId } from "../routing/session-key.js"; +import { chunkTextByBreakResolver } from "../shared/text-chunking.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js"; export type TextChunkProvider = ChannelId | typeof INTERNAL_MESSAGE_CHANNEL; @@ -316,41 +317,12 @@ export function chunkText(text: string, limit: number): string[] { if (early) { return early; } - - const chunks: string[] = []; - let remaining = text; - - while (remaining.length > limit) { - const window = remaining.slice(0, limit); - + return chunkTextByBreakResolver(text, limit, (window) => { // 1) Prefer a newline break inside the window (outside parentheses). const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window); - // 2) Otherwise prefer the last whitespace (word boundary) inside the window. - let breakIdx = lastNewline > 0 ? lastNewline : lastWhitespace; - - // 3) Fallback: hard break exactly at the limit. - if (breakIdx <= 0) { - breakIdx = limit; - } - - const rawChunk = remaining.slice(0, breakIdx); - const chunk = rawChunk.trimEnd(); - if (chunk.length > 0) { - chunks.push(chunk); - } - - // If we broke on whitespace/newline, skip that separator; for hard breaks keep it. - const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]); - const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0)); - remaining = remaining.slice(nextStart).trimStart(); - } - - if (remaining.length) { - chunks.push(remaining); - } - - return chunks; + return lastNewline > 0 ? lastNewline : lastWhitespace; + }); } export function chunkMarkdownText(text: string, limit: number): string[] { diff --git a/src/plugin-sdk/text-chunking.test.ts b/src/plugin-sdk/text-chunking.test.ts new file mode 100644 index 00000000000..b96b00cd991 --- /dev/null +++ b/src/plugin-sdk/text-chunking.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { chunkTextForOutbound } from "./text-chunking.js"; + +describe("chunkTextForOutbound", () => { + it("returns empty for empty input", () => { + expect(chunkTextForOutbound("", 10)).toEqual([]); + }); + + it("splits on newline or whitespace boundaries", () => { + expect(chunkTextForOutbound("alpha\nbeta gamma", 8)).toEqual(["alpha", "beta", "gamma"]); + }); + + it("falls back to hard limit when no separator exists", () => { + expect(chunkTextForOutbound("abcdefghij", 4)).toEqual(["abcd", "efgh", "ij"]); + }); +}); diff --git a/src/plugin-sdk/text-chunking.ts b/src/plugin-sdk/text-chunking.ts index 3c86e43f6fd..47c98c10851 100644 --- a/src/plugin-sdk/text-chunking.ts +++ b/src/plugin-sdk/text-chunking.ts @@ -1,31 +1,9 @@ +import { chunkTextByBreakResolver } from "../shared/text-chunking.js"; + export function chunkTextForOutbound(text: string, limit: number): string[] { - if (!text) { - return []; - } - if (limit <= 0 || text.length <= limit) { - return [text]; - } - const chunks: string[] = []; - let remaining = text; - while (remaining.length > limit) { - const window = remaining.slice(0, limit); + return chunkTextByBreakResolver(text, limit, (window) => { const lastNewline = window.lastIndexOf("\n"); const lastSpace = window.lastIndexOf(" "); - let breakIdx = lastNewline > 0 ? lastNewline : lastSpace; - if (breakIdx <= 0) { - breakIdx = limit; - } - const rawChunk = remaining.slice(0, breakIdx); - const chunk = rawChunk.trimEnd(); - if (chunk.length > 0) { - chunks.push(chunk); - } - const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]); - const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0)); - remaining = remaining.slice(nextStart).trimStart(); - } - if (remaining.length) { - chunks.push(remaining); - } - return chunks; + return lastNewline > 0 ? lastNewline : lastSpace; + }); } diff --git a/src/shared/text-chunking.ts b/src/shared/text-chunking.ts new file mode 100644 index 00000000000..9b75368fcd4 --- /dev/null +++ b/src/shared/text-chunking.ts @@ -0,0 +1,34 @@ +export function chunkTextByBreakResolver( + text: string, + limit: number, + resolveBreakIndex: (window: string) => number, +): string[] { + if (!text) { + return []; + } + if (limit <= 0 || text.length <= limit) { + return [text]; + } + const chunks: string[] = []; + let remaining = text; + while (remaining.length > limit) { + const window = remaining.slice(0, limit); + const candidateBreak = resolveBreakIndex(window); + const breakIdx = + Number.isFinite(candidateBreak) && candidateBreak > 0 && candidateBreak <= limit + ? candidateBreak + : limit; + const rawChunk = remaining.slice(0, breakIdx); + const chunk = rawChunk.trimEnd(); + if (chunk.length > 0) { + chunks.push(chunk); + } + const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]); + const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0)); + remaining = remaining.slice(nextStart).trimStart(); + } + if (remaining.length) { + chunks.push(remaining); + } + return chunks; +} From b22deada9e86ed1f048eb7ab363c541ba79b69f7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:00:44 +0000 Subject: [PATCH 0009/2904] refactor(queue): reuse collect-mode item drain flow --- src/agents/subagent-announce-queue.ts | 25 ++++++++++++++----------- src/auto-reply/reply/queue/drain.ts | 26 ++++++++++++++------------ src/utils/queue-helpers.ts | 17 +++++++++++++++++ 3 files changed, 45 insertions(+), 23 deletions(-) diff --git a/src/agents/subagent-announce-queue.ts b/src/agents/subagent-announce-queue.ts index e0dc8fcbfa2..9c18bffa07b 100644 --- a/src/agents/subagent-announce-queue.ts +++ b/src/agents/subagent-announce-queue.ts @@ -10,6 +10,7 @@ import { applyQueueDropPolicy, buildCollectPrompt, clearQueueSummaryState, + drainCollectItemIfNeeded, drainNextQueueItem, hasCrossChannelItems, previewQueueSummaryPrompt, @@ -108,12 +109,6 @@ function scheduleAnnounceDrain(key: string) { while (queue.items.length > 0 || queue.droppedCount > 0) { await waitForQueueDebounce(queue); if (queue.mode === "collect") { - if (forceIndividualCollect) { - if (!(await drainNextQueueItem(queue.items, async (item) => await queue.send(item)))) { - break; - } - continue; - } const isCrossChannel = hasCrossChannelItems(queue.items, (item) => { if (!item.origin) { return {}; @@ -123,11 +118,19 @@ function scheduleAnnounceDrain(key: string) { } return { key: item.originKey }; }); - if (isCrossChannel) { - forceIndividualCollect = true; - if (!(await drainNextQueueItem(queue.items, async (item) => await queue.send(item)))) { - break; - } + const collectDrainResult = await drainCollectItemIfNeeded({ + forceIndividualCollect, + isCrossChannel, + setForceIndividualCollect: (next) => { + forceIndividualCollect = next; + }, + items: queue.items, + run: async (item) => await queue.send(item), + }); + if (collectDrainResult === "empty") { + break; + } + if (collectDrainResult === "drained") { continue; } const items = queue.items.slice(); diff --git a/src/auto-reply/reply/queue/drain.ts b/src/auto-reply/reply/queue/drain.ts index be409b3c742..35cb8de6897 100644 --- a/src/auto-reply/reply/queue/drain.ts +++ b/src/auto-reply/reply/queue/drain.ts @@ -2,6 +2,7 @@ import { defaultRuntime } from "../../../runtime.js"; import { buildCollectPrompt, clearQueueSummaryState, + drainCollectItemIfNeeded, drainNextQueueItem, hasCrossChannelItems, previewQueueSummaryPrompt, @@ -30,13 +31,6 @@ export function scheduleFollowupDrain( // Prevents “collect after shift” collapsing different targets. // // Debug: `pnpm test src/auto-reply/reply/queue.collect-routing.test.ts` - if (forceIndividualCollect) { - if (!(await drainNextQueueItem(queue.items, runFollowup))) { - break; - } - continue; - } - // Check if messages span multiple channels. // If so, process individually to preserve per-message routing. const isCrossChannel = hasCrossChannelItems(queue.items, (item) => { @@ -56,11 +50,19 @@ export function scheduleFollowupDrain( }; }); - if (isCrossChannel) { - forceIndividualCollect = true; - if (!(await drainNextQueueItem(queue.items, runFollowup))) { - break; - } + const collectDrainResult = await drainCollectItemIfNeeded({ + forceIndividualCollect, + isCrossChannel, + setForceIndividualCollect: (next) => { + forceIndividualCollect = next; + }, + items: queue.items, + run: runFollowup, + }); + if (collectDrainResult === "empty") { + break; + } + if (collectDrainResult === "drained") { continue; } diff --git a/src/utils/queue-helpers.ts b/src/utils/queue-helpers.ts index 4ebb627e89a..cb4889134c9 100644 --- a/src/utils/queue-helpers.ts +++ b/src/utils/queue-helpers.ts @@ -142,6 +142,23 @@ export async function drainNextQueueItem( return true; } +export async function drainCollectItemIfNeeded(params: { + forceIndividualCollect: boolean; + isCrossChannel: boolean; + setForceIndividualCollect?: (next: boolean) => void; + items: T[]; + run: (item: T) => Promise; +}): Promise<"skipped" | "drained" | "empty"> { + if (!params.forceIndividualCollect && !params.isCrossChannel) { + return "skipped"; + } + if (params.isCrossChannel) { + params.setForceIndividualCollect?.(true); + } + const drained = await drainNextQueueItem(params.items, params.run); + return drained ? "drained" : "empty"; +} + export function buildQueueSummaryPrompt(params: { state: QueueSummaryState; noun: string; From 742fb9057118417d600dde96adeb2e64d1c487f8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:00:49 +0000 Subject: [PATCH 0010/2904] test(queue): cover collect drain helper states --- src/utils/queue-helpers.test.ts | 56 +++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/utils/queue-helpers.test.ts b/src/utils/queue-helpers.test.ts index 7df1a0d4958..a0d31952315 100644 --- a/src/utils/queue-helpers.test.ts +++ b/src/utils/queue-helpers.test.ts @@ -3,6 +3,7 @@ import { applyQueueRuntimeSettings, buildQueueSummaryPrompt, clearQueueSummaryState, + drainCollectItemIfNeeded, previewQueueSummaryPrompt, } from "./queue-helpers.js"; @@ -111,3 +112,58 @@ describe("queue summary helpers", () => { expect(state.summaryLines).toEqual([]); }); }); + +describe("drainCollectItemIfNeeded", () => { + it("skips when neither force mode nor cross-channel routing is active", async () => { + const seen: number[] = []; + const items = [1]; + + const result = await drainCollectItemIfNeeded({ + forceIndividualCollect: false, + isCrossChannel: false, + items, + run: async (item) => { + seen.push(item); + }, + }); + + expect(result).toBe("skipped"); + expect(seen).toEqual([]); + expect(items).toEqual([1]); + }); + + it("drains one item in force mode", async () => { + const seen: number[] = []; + const items = [1, 2]; + + const result = await drainCollectItemIfNeeded({ + forceIndividualCollect: true, + isCrossChannel: false, + items, + run: async (item) => { + seen.push(item); + }, + }); + + expect(result).toBe("drained"); + expect(seen).toEqual([1]); + expect(items).toEqual([2]); + }); + + it("switches to force mode and returns empty when cross-channel with no queued item", async () => { + let forced = false; + + const result = await drainCollectItemIfNeeded({ + forceIndividualCollect: false, + isCrossChannel: true, + setForceIndividualCollect: (next) => { + forced = next; + }, + items: [], + run: async () => {}, + }); + + expect(result).toBe("empty"); + expect(forced).toBe(true); + }); +}); From 231f2af7df1a52f4be600d2a6fc38306dcc3b6cd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:00:55 +0000 Subject: [PATCH 0011/2904] refactor(config): dedupe redacted snapshot array/object restore paths --- src/config/redact-snapshot.ts | 132 +++++++++++++++++++++++++--------- 1 file changed, 100 insertions(+), 32 deletions(-) diff --git a/src/config/redact-snapshot.ts b/src/config/redact-snapshot.ts index 0059138a41f..243dcc3c295 100644 --- a/src/config/redact-snapshot.ts +++ b/src/config/redact-snapshot.ts @@ -384,6 +384,89 @@ function restoreOriginalValueOrThrow(params: { throw new RedactionError(params.path); } +function mapRedactedArray(params: { + incoming: unknown[]; + original: unknown; + path: string; + mapItem: (item: unknown, index: number, originalArray: unknown[]) => unknown; +}): unknown[] { + const originalArray = Array.isArray(params.original) ? params.original : []; + if (params.incoming.length < originalArray.length) { + log.warn(`Redacted config array key ${params.path} has been truncated`); + } + return params.incoming.map((item, index) => params.mapItem(item, index, originalArray)); +} + +function toObjectRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + return {}; +} + +function restoreArrayItemWithLookup(params: { + item: unknown; + index: number; + originalArray: unknown[]; + lookup: Set; + path: string; + hints: ConfigUiHints; +}): unknown { + if (params.item === REDACTED_SENTINEL) { + return params.originalArray[params.index]; + } + return restoreRedactedValuesWithLookup( + params.item, + params.originalArray[params.index], + params.lookup, + params.path, + params.hints, + ); +} + +function restoreArrayItemWithGuessing(params: { + item: unknown; + index: number; + originalArray: unknown[]; + path: string; + hints?: ConfigUiHints; +}): unknown { + if ( + !isExplicitlyNonSensitivePath(params.hints, [params.path]) && + isSensitivePath(params.path) && + params.item === REDACTED_SENTINEL + ) { + return params.originalArray[params.index]; + } + return restoreRedactedValuesGuessing( + params.item, + params.originalArray[params.index], + params.path, + params.hints, + ); +} + +function restoreGuessingArray( + incoming: unknown[], + original: unknown, + path: string, + hints?: ConfigUiHints, +): unknown[] { + return mapRedactedArray({ + incoming, + original, + path, + mapItem: (item, index, originalArray) => + restoreArrayItemWithGuessing({ + item, + index, + originalArray, + path, + hints, + }), + }); +} + /** * Worker for restoreRedactedValues(). * Used when there are ConfigUiHints available. @@ -413,21 +496,22 @@ function restoreRedactedValuesWithLookup( } return restoreRedactedValuesGuessing(incoming, original, prefix, hints); } - const origArr = Array.isArray(original) ? original : []; - if (incoming.length < origArr.length) { - log.warn(`Redacted config array key ${path} has been truncated`); - } - return incoming.map((item, i) => { - if (item === REDACTED_SENTINEL) { - return origArr[i]; - } - return restoreRedactedValuesWithLookup(item, origArr[i], lookup, path, hints); + return mapRedactedArray({ + incoming, + original, + path, + mapItem: (item, index, originalArray) => + restoreArrayItemWithLookup({ + item, + index, + originalArray, + lookup, + path, + hints, + }), }); } - const orig = - original && typeof original === "object" && !Array.isArray(original) - ? (original as Record) - : {}; + const orig = toObjectRecord(original); const result: Record = {}; for (const [key, value] of Object.entries(incoming as Record)) { result[key] = value; @@ -478,26 +562,10 @@ function restoreRedactedValuesGuessing( // we have no way of knowing which one. In this case, the last // element(s) get(s) chopped off. Not good, so please don't put // sensitive string array in the config... - const origArr = Array.isArray(original) ? original : []; - return incoming.map((item, i) => { - const path = `${prefix}[]`; - if (incoming.length < origArr.length) { - log.warn(`Redacted config array key ${path} has been truncated`); - } - if ( - !isExplicitlyNonSensitivePath(hints, [path]) && - isSensitivePath(path) && - item === REDACTED_SENTINEL - ) { - return origArr[i]; - } - return restoreRedactedValuesGuessing(item, origArr[i], path, hints); - }); + const path = `${prefix}[]`; + return restoreGuessingArray(incoming, original, path, hints); } - const orig = - original && typeof original === "object" && !Array.isArray(original) - ? (original as Record) - : {}; + const orig = toObjectRecord(original); const result: Record = {}; for (const [key, value] of Object.entries(incoming as Record)) { const path = prefix ? `${prefix}.${key}` : key; From c37cf02f297482c3b18cb6bddd7dca25f8452dd8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:02:26 +0000 Subject: [PATCH 0012/2904] test: make shell env path cache tests platform deterministic --- src/infra/infra-runtime.test.ts | 25 +++++++++++------------ src/infra/shell-env.test.ts | 35 ++++++++++++++++++++++----------- src/infra/shell-env.ts | 4 +++- 3 files changed, 39 insertions(+), 25 deletions(-) diff --git a/src/infra/infra-runtime.test.ts b/src/infra/infra-runtime.test.ts index 66a81f7bc06..6676a5d8f8a 100644 --- a/src/infra/infra-runtime.test.ts +++ b/src/infra/infra-runtime.test.ts @@ -224,35 +224,34 @@ describe("infra runtime", () => { afterEach(() => resetShellPathCacheForTests()); it("returns PATH from login shell env", () => { - if (process.platform === "win32") { - return; - } const exec = vi .fn() .mockReturnValue(Buffer.from("PATH=/custom/bin\0HOME=/home/user\0", "utf-8")); - const result = getShellPathFromLoginShell({ env: { SHELL: "/bin/sh" }, exec }); + const result = getShellPathFromLoginShell({ + env: { SHELL: "/bin/sh" }, + exec, + platform: "linux", + }); expect(result).toBe("/custom/bin"); }); it("caches the value", () => { - if (process.platform === "win32") { - return; - } const exec = vi.fn().mockReturnValue(Buffer.from("PATH=/custom/bin\0", "utf-8")); const env = { SHELL: "/bin/sh" } as NodeJS.ProcessEnv; - expect(getShellPathFromLoginShell({ env, exec })).toBe("/custom/bin"); - expect(getShellPathFromLoginShell({ env, exec })).toBe("/custom/bin"); + expect(getShellPathFromLoginShell({ env, exec, platform: "linux" })).toBe("/custom/bin"); + expect(getShellPathFromLoginShell({ env, exec, platform: "linux" })).toBe("/custom/bin"); expect(exec).toHaveBeenCalledTimes(1); }); it("returns null on exec failure", () => { - if (process.platform === "win32") { - return; - } const exec = vi.fn(() => { throw new Error("boom"); }); - const result = getShellPathFromLoginShell({ env: { SHELL: "/bin/sh" }, exec }); + const result = getShellPathFromLoginShell({ + env: { SHELL: "/bin/sh" }, + exec, + platform: "linux", + }); expect(result).toBeNull(); }); }); diff --git a/src/infra/shell-env.test.ts b/src/infra/shell-env.test.ts index 1a3449900be..0869dfec4f9 100644 --- a/src/infra/shell-env.test.ts +++ b/src/infra/shell-env.test.ts @@ -81,19 +81,14 @@ describe("shell env fallback", () => { const first = getShellPathFromLoginShell({ env: {} as NodeJS.ProcessEnv, exec: exec as unknown as Parameters[0]["exec"], + platform: "linux", }); const second = getShellPathFromLoginShell({ env: {} as NodeJS.ProcessEnv, exec: exec as unknown as Parameters[0]["exec"], + platform: "linux", }); - if (process.platform === "win32") { - expect(first).toBeNull(); - expect(second).toBeNull(); - expect(exec).not.toHaveBeenCalled(); - return; - } - expect(first).toBe("/usr/local/bin:/usr/bin"); expect(second).toBe("/usr/local/bin:/usr/bin"); expect(exec).toHaveBeenCalledOnce(); @@ -108,18 +103,36 @@ describe("shell env fallback", () => { const first = getShellPathFromLoginShell({ env: {} as NodeJS.ProcessEnv, exec: exec as unknown as Parameters[0]["exec"], + platform: "linux", }); const second = getShellPathFromLoginShell({ env: {} as NodeJS.ProcessEnv, exec: exec as unknown as Parameters[0]["exec"], + platform: "linux", }); expect(first).toBeNull(); expect(second).toBeNull(); - if (process.platform === "win32") { - expect(exec).not.toHaveBeenCalled(); - return; - } expect(exec).toHaveBeenCalledOnce(); }); + + it("returns null without invoking shell on win32", () => { + resetShellPathCacheForTests(); + const exec = vi.fn(() => Buffer.from("PATH=/usr/local/bin:/usr/bin\0HOME=/tmp\0")); + + const first = getShellPathFromLoginShell({ + env: {} as NodeJS.ProcessEnv, + exec: exec as unknown as Parameters[0]["exec"], + platform: "win32", + }); + const second = getShellPathFromLoginShell({ + env: {} as NodeJS.ProcessEnv, + exec: exec as unknown as Parameters[0]["exec"], + platform: "win32", + }); + + expect(first).toBeNull(); + expect(second).toBeNull(); + expect(exec).not.toHaveBeenCalled(); + }); }); diff --git a/src/infra/shell-env.ts b/src/infra/shell-env.ts index d1fa53fe01c..51839c66ea9 100644 --- a/src/infra/shell-env.ts +++ b/src/infra/shell-env.ts @@ -140,11 +140,13 @@ export function getShellPathFromLoginShell(opts: { env: NodeJS.ProcessEnv; timeoutMs?: number; exec?: typeof execFileSync; + platform?: NodeJS.Platform; }): string | null { if (cachedShellPath !== undefined) { return cachedShellPath; } - if (process.platform === "win32") { + const platform = opts.platform ?? process.platform; + if (platform === "win32") { cachedShellPath = null; return cachedShellPath; } From 192366e0e83e9f3ea63747d1937accd2555e297d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:21:26 +0000 Subject: [PATCH 0013/2904] test: dedupe shell env coverage from infra runtime suite --- src/infra/infra-runtime.test.ts | 37 --------------------------------- 1 file changed, 37 deletions(-) diff --git a/src/infra/infra-runtime.test.ts b/src/infra/infra-runtime.test.ts index 6676a5d8f8a..7b9de0c1b6d 100644 --- a/src/infra/infra-runtime.test.ts +++ b/src/infra/infra-runtime.test.ts @@ -14,7 +14,6 @@ import { setPreRestartDeferralCheck, } from "./restart.js"; import { createTelegramRetryRunner } from "./retry-policy.js"; -import { getShellPathFromLoginShell, resetShellPathCacheForTests } from "./shell-env.js"; import { listTailnetAddresses } from "./tailnet.js"; describe("infra runtime", () => { @@ -220,42 +219,6 @@ describe("infra runtime", () => { }); }); - describe("getShellPathFromLoginShell", () => { - afterEach(() => resetShellPathCacheForTests()); - - it("returns PATH from login shell env", () => { - const exec = vi - .fn() - .mockReturnValue(Buffer.from("PATH=/custom/bin\0HOME=/home/user\0", "utf-8")); - const result = getShellPathFromLoginShell({ - env: { SHELL: "/bin/sh" }, - exec, - platform: "linux", - }); - expect(result).toBe("/custom/bin"); - }); - - it("caches the value", () => { - const exec = vi.fn().mockReturnValue(Buffer.from("PATH=/custom/bin\0", "utf-8")); - const env = { SHELL: "/bin/sh" } as NodeJS.ProcessEnv; - expect(getShellPathFromLoginShell({ env, exec, platform: "linux" })).toBe("/custom/bin"); - expect(getShellPathFromLoginShell({ env, exec, platform: "linux" })).toBe("/custom/bin"); - expect(exec).toHaveBeenCalledTimes(1); - }); - - it("returns null on exec failure", () => { - const exec = vi.fn(() => { - throw new Error("boom"); - }); - const result = getShellPathFromLoginShell({ - env: { SHELL: "/bin/sh" }, - exec, - platform: "linux", - }); - expect(result).toBeNull(); - }); - }); - describe("tailnet address detection", () => { it("detects tailscale IPv4 and IPv6 addresses", () => { vi.spyOn(os, "networkInterfaces").mockReturnValue({ From c085c9e6d04737fbfbc3d1f1c3b73af718f6ec9e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:23:44 +0000 Subject: [PATCH 0014/2904] test(browser): dedupe CDP and download setup helpers --- src/browser/cdp.test.ts | 167 ++++++++---------- ...-core.waits-next-download-saves-it.test.ts | 46 +++-- 2 files changed, 98 insertions(+), 115 deletions(-) diff --git a/src/browser/cdp.test.ts b/src/browser/cdp.test.ts index 24a97d1aa17..07f6d688cc5 100644 --- a/src/browser/cdp.test.ts +++ b/src/browser/cdp.test.ts @@ -1,6 +1,6 @@ import { createServer } from "node:http"; import { afterEach, describe, expect, it } from "vitest"; -import { WebSocketServer } from "ws"; +import { type WebSocket, WebSocketServer } from "ws"; import { rawDataToString } from "../infra/ws.js"; import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js"; @@ -14,6 +14,29 @@ describe("cdp", () => { return (wsServer.address() as { port: number }).port; }; + const startWsServerWithMessages = async ( + onMessage: ( + msg: { id?: number; method?: string; params?: Record }, + socket: WebSocket, + ) => void, + ) => { + const wsPort = await startWsServer(); + if (!wsServer) { + throw new Error("ws server not initialized"); + } + wsServer.on("connection", (socket) => { + socket.on("message", (data) => { + const msg = JSON.parse(rawDataToString(data)) as { + id?: number; + method?: string; + params?: Record; + }; + onMessage(msg, socket); + }); + }); + return wsPort; + }; + afterEach(async () => { await new Promise((resolve) => { if (!httpServer) { @@ -32,28 +55,16 @@ describe("cdp", () => { }); it("creates a target via the browser websocket", async () => { - const wsPort = await startWsServer(); - if (!wsServer) { - throw new Error("ws server not initialized"); - } - - wsServer.on("connection", (socket) => { - socket.on("message", (data) => { - const msg = JSON.parse(rawDataToString(data)) as { - id?: number; - method?: string; - params?: { url?: string }; - }; - if (msg.method !== "Target.createTarget") { - return; - } - socket.send( - JSON.stringify({ - id: msg.id, - result: { targetId: "TARGET_123" }, - }), - ); - }); + const wsPort = await startWsServerWithMessages((msg, socket) => { + if (msg.method !== "Target.createTarget") { + return; + } + socket.send( + JSON.stringify({ + id: msg.id, + result: { targetId: "TARGET_123" }, + }), + ); }); httpServer = createServer((req, res) => { @@ -82,32 +93,20 @@ describe("cdp", () => { }); it("evaluates javascript via CDP", async () => { - const wsPort = await startWsServer(); - if (!wsServer) { - throw new Error("ws server not initialized"); - } - - wsServer.on("connection", (socket) => { - socket.on("message", (data) => { - const msg = JSON.parse(rawDataToString(data)) as { - id?: number; - method?: string; - params?: { expression?: string }; - }; - if (msg.method === "Runtime.enable") { - socket.send(JSON.stringify({ id: msg.id, result: {} })); - return; - } - if (msg.method === "Runtime.evaluate") { - expect(msg.params?.expression).toBe("1+1"); - socket.send( - JSON.stringify({ - id: msg.id, - result: { result: { type: "number", value: 2 } }, - }), - ); - } - }); + const wsPort = await startWsServerWithMessages((msg, socket) => { + if (msg.method === "Runtime.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Runtime.evaluate") { + expect(msg.params?.expression).toBe("1+1"); + socket.send( + JSON.stringify({ + id: msg.id, + result: { result: { type: "number", value: 2 } }, + }), + ); + } }); const res = await evaluateJavaScript({ @@ -120,47 +119,35 @@ describe("cdp", () => { }); it("captures an aria snapshot via CDP", async () => { - const wsPort = await startWsServer(); - if (!wsServer) { - throw new Error("ws server not initialized"); - } - - wsServer.on("connection", (socket) => { - socket.on("message", (data) => { - const msg = JSON.parse(rawDataToString(data)) as { - id?: number; - method?: string; - }; - if (msg.method === "Accessibility.enable") { - socket.send(JSON.stringify({ id: msg.id, result: {} })); - return; - } - if (msg.method === "Accessibility.getFullAXTree") { - socket.send( - JSON.stringify({ - id: msg.id, - result: { - nodes: [ - { - nodeId: "1", - role: { value: "RootWebArea" }, - name: { value: "" }, - childIds: ["2"], - }, - { - nodeId: "2", - role: { value: "button" }, - name: { value: "OK" }, - backendDOMNodeId: 42, - childIds: [], - }, - ], - }, - }), - ); - return; - } - }); + const wsPort = await startWsServerWithMessages((msg, socket) => { + if (msg.method === "Accessibility.enable") { + socket.send(JSON.stringify({ id: msg.id, result: {} })); + return; + } + if (msg.method === "Accessibility.getFullAXTree") { + socket.send( + JSON.stringify({ + id: msg.id, + result: { + nodes: [ + { + nodeId: "1", + role: { value: "RootWebArea" }, + name: { value: "" }, + childIds: ["2"], + }, + { + nodeId: "2", + role: { value: "button" }, + name: { value: "OK" }, + backendDOMNodeId: 42, + childIds: [], + }, + ], + }, + }), + ); + } }); const snap = await snapshotAria({ wsUrl: `ws://127.0.0.1:${wsPort}` }); diff --git a/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts b/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts index 8324acfbe11..d4e8ad26325 100644 --- a/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts +++ b/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts @@ -27,15 +27,8 @@ describe("pw-tools-core", () => { downloadUrl: string; suggestedFilename: string; }) { - let downloadHandler: ((download: unknown) => void) | undefined; - const on = vi.fn((event: string, handler: (download: unknown) => void) => { - if (event === "download") { - downloadHandler = handler; - } - }); - const off = vi.fn(); + const harness = createDownloadEventHarness(); const saveAs = vi.fn(async () => {}); - setPwToolsCoreCurrentPage({ on, off }); const p = mod.waitForDownloadViaPlaywright({ cdpUrl: "http://127.0.0.1:18792", @@ -44,7 +37,7 @@ describe("pw-tools-core", () => { }); await Promise.resolve(); - downloadHandler?.({ + harness.trigger({ url: () => params.downloadUrl, suggestedFilename: () => params.suggestedFilename, saveAs, @@ -55,7 +48,7 @@ describe("pw-tools-core", () => { return { res, outPath }; } - it("waits for the next download and saves it", async () => { + function createDownloadEventHarness() { let downloadHandler: ((download: unknown) => void) | undefined; const on = vi.fn((event: string, handler: (download: unknown) => void) => { if (event === "download") { @@ -63,6 +56,19 @@ describe("pw-tools-core", () => { } }); const off = vi.fn(); + setPwToolsCoreCurrentPage({ on, off }); + return { + trigger: (download: unknown) => { + downloadHandler?.(download); + }, + expectArmed: () => { + expect(downloadHandler).toBeDefined(); + }, + }; + } + + it("waits for the next download and saves it", async () => { + const harness = createDownloadEventHarness(); const saveAs = vi.fn(async () => {}); const download = { @@ -71,8 +77,6 @@ describe("pw-tools-core", () => { saveAs, }; - setPwToolsCoreCurrentPage({ on, off }); - const targetPath = path.resolve("/tmp/file.bin"); const p = mod.waitForDownloadViaPlaywright({ cdpUrl: "http://127.0.0.1:18792", @@ -82,21 +86,15 @@ describe("pw-tools-core", () => { }); await Promise.resolve(); - expect(downloadHandler).toBeDefined(); - downloadHandler?.(download); + harness.expectArmed(); + harness.trigger(download); const res = await p; expect(saveAs).toHaveBeenCalledWith(targetPath); expect(res.path).toBe(targetPath); }); it("clicks a ref and saves the resulting download", async () => { - let downloadHandler: ((download: unknown) => void) | undefined; - const on = vi.fn((event: string, handler: (download: unknown) => void) => { - if (event === "download") { - downloadHandler = handler; - } - }); - const off = vi.fn(); + const harness = createDownloadEventHarness(); const click = vi.fn(async () => {}); setPwToolsCoreCurrentRefLocator({ click }); @@ -108,8 +106,6 @@ describe("pw-tools-core", () => { saveAs, }; - setPwToolsCoreCurrentPage({ on, off }); - const targetPath = path.resolve("/tmp/report.pdf"); const p = mod.downloadViaPlaywright({ cdpUrl: "http://127.0.0.1:18792", @@ -120,10 +116,10 @@ describe("pw-tools-core", () => { }); await Promise.resolve(); - expect(downloadHandler).toBeDefined(); + harness.expectArmed(); expect(click).toHaveBeenCalledWith({ timeout: 1000 }); - downloadHandler?.(download); + harness.trigger(download); const res = await p; expect(saveAs).toHaveBeenCalledWith(targetPath); From 9ac6f46735bff17746cd52eab68d21415566e26d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:23:51 +0000 Subject: [PATCH 0015/2904] test(messaging): dedupe parser/proxy/followup test scaffolding --- .../reply/commands-setunset.test.ts | 93 +++++++++---------- src/auto-reply/reply/followup-runner.test.ts | 70 +++++++------- src/telegram/send.proxy.test.ts | 61 +++++------- 3 files changed, 109 insertions(+), 115 deletions(-) diff --git a/src/auto-reply/reply/commands-setunset.test.ts b/src/auto-reply/reply/commands-setunset.test.ts index fd420fc2485..397c11b1cb7 100644 --- a/src/auto-reply/reply/commands-setunset.test.ts +++ b/src/auto-reply/reply/commands-setunset.test.ts @@ -11,6 +11,28 @@ type ParsedSetUnsetAction = | { action: "unset"; path: string } | { action: "error"; message: string }; +function createActionMappers() { + return { + onSet: (path: string, value: unknown): ParsedSetUnsetAction => ({ action: "set", path, value }), + onUnset: (path: string): ParsedSetUnsetAction => ({ action: "unset", path }), + onError: (message: string): ParsedSetUnsetAction => ({ action: "error", message }), + }; +} + +function createSlashParams(params: { + raw: string; + onKnownAction?: (action: string) => ParsedSetUnsetAction | undefined; +}) { + return { + raw: params.raw, + slash: "/config", + invalidMessage: "Invalid /config syntax.", + usageMessage: "Usage: /config show|set|unset", + onKnownAction: params.onKnownAction ?? (() => undefined), + ...createActionMappers(), + }; +} + describe("parseSetUnsetCommand", () => { it("parses unset values", () => { expect( @@ -35,25 +57,23 @@ describe("parseSetUnsetCommand", () => { describe("parseSetUnsetCommandAction", () => { it("returns null for non set/unset actions", () => { + const mappers = createActionMappers(); const result = parseSetUnsetCommandAction({ slash: "/config", action: "show", args: "", - onSet: (path, value) => ({ action: "set", path, value }), - onUnset: (path) => ({ action: "unset", path }), - onError: (message) => ({ action: "error", message }), + ...mappers, }); expect(result).toBeNull(); }); it("maps parse errors through onError", () => { + const mappers = createActionMappers(); const result = parseSetUnsetCommandAction({ slash: "/config", action: "set", args: "", - onSet: (path, value) => ({ action: "set", path, value }), - onUnset: (path) => ({ action: "unset", path }), - onError: (message) => ({ action: "error", message }), + ...mappers, }); expect(result).toEqual({ action: "error", message: "Usage: /config set path=value" }); }); @@ -61,57 +81,36 @@ describe("parseSetUnsetCommandAction", () => { describe("parseSlashCommandWithSetUnset", () => { it("returns null when the input does not match the slash command", () => { - const result = parseSlashCommandWithSetUnset({ - raw: "/debug show", - slash: "/config", - invalidMessage: "Invalid /config syntax.", - usageMessage: "Usage: /config show|set|unset", - onKnownAction: () => undefined, - onSet: (path, value) => ({ action: "set", path, value }), - onUnset: (path) => ({ action: "unset", path }), - onError: (message) => ({ action: "error", message }), - }); + const result = parseSlashCommandWithSetUnset( + createSlashParams({ raw: "/debug show" }), + ); expect(result).toBeNull(); }); it("prefers set/unset mapping and falls back to known actions", () => { - const setResult = parseSlashCommandWithSetUnset({ - raw: '/config set a.b={"ok":true}', - slash: "/config", - invalidMessage: "Invalid /config syntax.", - usageMessage: "Usage: /config show|set|unset", - onKnownAction: () => undefined, - onSet: (path, value) => ({ action: "set", path, value }), - onUnset: (path) => ({ action: "unset", path }), - onError: (message) => ({ action: "error", message }), - }); + const setResult = parseSlashCommandWithSetUnset( + createSlashParams({ + raw: '/config set a.b={"ok":true}', + }), + ); expect(setResult).toEqual({ action: "set", path: "a.b", value: { ok: true } }); - const showResult = parseSlashCommandWithSetUnset({ - raw: "/config show", - slash: "/config", - invalidMessage: "Invalid /config syntax.", - usageMessage: "Usage: /config show|set|unset", - onKnownAction: (action) => - action === "show" ? { action: "unset", path: "dummy" } : undefined, - onSet: (path, value) => ({ action: "set", path, value }), - onUnset: (path) => ({ action: "unset", path }), - onError: (message) => ({ action: "error", message }), - }); + const showResult = parseSlashCommandWithSetUnset( + createSlashParams({ + raw: "/config show", + onKnownAction: (action) => + action === "show" ? { action: "unset", path: "dummy" } : undefined, + }), + ); expect(showResult).toEqual({ action: "unset", path: "dummy" }); }); it("returns onError for unknown actions", () => { - const unknownAction = parseSlashCommandWithSetUnset({ - raw: "/config whoami", - slash: "/config", - invalidMessage: "Invalid /config syntax.", - usageMessage: "Usage: /config show|set|unset", - onKnownAction: () => undefined, - onSet: (path, value) => ({ action: "set", path, value }), - onUnset: (path) => ({ action: "unset", path }), - onError: (message) => ({ action: "error", message }), - }); + const unknownAction = parseSlashCommandWithSetUnset( + createSlashParams({ + raw: "/config whoami", + }), + ); expect(unknownAction).toEqual({ action: "error", message: "Usage: /config show|set|unset" }); }); }); diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 4d2cf45d261..824e4d2fdf7 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -60,6 +60,26 @@ const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun => }, }) as FollowupRun; +function mockCompactionRun(params: { + willRetry: boolean; + result: { + payloads: Array<{ text: string }>; + meta: Record; + }; +}) { + runEmbeddedPiAgentMock.mockImplementationOnce( + async (args: { + onAgentEvent?: (evt: { stream: string; data: Record }) => void; + }) => { + args.onAgentEvent?.({ + stream: "compaction", + data: { phase: "end", willRetry: params.willRetry }, + }); + return params.result; + }, + ); +} + describe("createFollowupRunner compaction", () => { it("adds verbose auto-compaction notice and tracks count", async () => { const storePath = path.join( @@ -75,17 +95,10 @@ describe("createFollowupRunner compaction", () => { }; const onBlockReply = vi.fn(async () => {}); - runEmbeddedPiAgentMock.mockImplementationOnce( - async (params: { - onAgentEvent?: (evt: { stream: string; data: Record }) => void; - }) => { - params.onAgentEvent?.({ - stream: "compaction", - data: { phase: "end", willRetry: true }, - }); - return { payloads: [{ text: "final" }], meta: {} }; - }, - ); + mockCompactionRun({ + willRetry: true, + result: { payloads: [{ text: "final" }], meta: {} }, + }); const runner = createFollowupRunner({ opts: { onBlockReply }, @@ -149,29 +162,22 @@ describe("createFollowupRunner compaction", () => { await saveSessionStore(storePath, sessionStore); const onBlockReply = vi.fn(async () => {}); - runEmbeddedPiAgentMock.mockImplementationOnce( - async (params: { - onAgentEvent?: (evt: { stream: string; data: Record }) => void; - }) => { - params.onAgentEvent?.({ - stream: "compaction", - data: { phase: "end", willRetry: false }, - }); - return { - payloads: [{ text: "done" }], - meta: { - agentMeta: { - // Accumulated usage across pre+post compaction calls. - usage: { input: 190_000, output: 8_000, total: 198_000 }, - // Last call usage reflects post-compaction context. - lastCallUsage: { input: 11_000, output: 2_000, total: 13_000 }, - model: "claude-opus-4-5", - provider: "anthropic", - }, + mockCompactionRun({ + willRetry: false, + result: { + payloads: [{ text: "done" }], + meta: { + agentMeta: { + // Accumulated usage across pre+post compaction calls. + usage: { input: 190_000, output: 8_000, total: 198_000 }, + // Last call usage reflects post-compaction context. + lastCallUsage: { input: 11_000, output: 2_000, total: 13_000 }, + model: "claude-opus-4-5", + provider: "anthropic", }, - }; + }, }, - ); + }); const runner = createFollowupRunner({ opts: { onBlockReply }, diff --git a/src/telegram/send.proxy.test.ts b/src/telegram/send.proxy.test.ts index 39ef9e2d084..aa20cb72db7 100644 --- a/src/telegram/send.proxy.test.ts +++ b/src/telegram/send.proxy.test.ts @@ -56,6 +56,25 @@ import { deleteMessageTelegram, reactMessageTelegram, sendMessageTelegram } from describe("telegram proxy client", () => { const proxyUrl = "http://proxy.test:8080"; + const prepareProxyFetch = () => { + const proxyFetch = vi.fn(); + const fetchImpl = vi.fn(); + makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch); + resolveTelegramFetch.mockReturnValue(fetchImpl as unknown as typeof fetch); + return { proxyFetch, fetchImpl }; + }; + + const expectProxyClient = (fetchImpl: ReturnType) => { + expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl); + expect(resolveTelegramFetch).toHaveBeenCalledWith(expect.any(Function), { network: undefined }); + expect(botCtorSpy).toHaveBeenCalledWith( + "tok", + expect.objectContaining({ + client: expect.objectContaining({ fetch: fetchImpl }), + }), + ); + }; + beforeEach(() => { botApi.sendMessage.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); botApi.setMessageReaction.mockResolvedValue(undefined); @@ -69,56 +88,26 @@ describe("telegram proxy client", () => { }); it("uses proxy fetch for sendMessage", async () => { - const proxyFetch = vi.fn(); - const fetchImpl = vi.fn(); - makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch); - resolveTelegramFetch.mockReturnValue(fetchImpl as unknown as typeof fetch); + const { fetchImpl } = prepareProxyFetch(); await sendMessageTelegram("123", "hi", { token: "tok", accountId: "foo" }); - expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl); - expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { network: undefined }); - expect(botCtorSpy).toHaveBeenCalledWith( - "tok", - expect.objectContaining({ - client: expect.objectContaining({ fetch: fetchImpl }), - }), - ); + expectProxyClient(fetchImpl); }); it("uses proxy fetch for reactions", async () => { - const proxyFetch = vi.fn(); - const fetchImpl = vi.fn(); - makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch); - resolveTelegramFetch.mockReturnValue(fetchImpl as unknown as typeof fetch); + const { fetchImpl } = prepareProxyFetch(); await reactMessageTelegram("123", "456", "✅", { token: "tok", accountId: "foo" }); - expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl); - expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { network: undefined }); - expect(botCtorSpy).toHaveBeenCalledWith( - "tok", - expect.objectContaining({ - client: expect.objectContaining({ fetch: fetchImpl }), - }), - ); + expectProxyClient(fetchImpl); }); it("uses proxy fetch for deleteMessage", async () => { - const proxyFetch = vi.fn(); - const fetchImpl = vi.fn(); - makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch); - resolveTelegramFetch.mockReturnValue(fetchImpl as unknown as typeof fetch); + const { fetchImpl } = prepareProxyFetch(); await deleteMessageTelegram("123", "456", { token: "tok", accountId: "foo" }); - expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl); - expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { network: undefined }); - expect(botCtorSpy).toHaveBeenCalledWith( - "tok", - expect.objectContaining({ - client: expect.objectContaining({ fetch: fetchImpl }), - }), - ); + expectProxyClient(fetchImpl); }); }); From 0383c79c9c16971149644b7f70adf7328eeebad3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:27:42 +0000 Subject: [PATCH 0016/2904] test(cli): dedupe account-option assertion in message helper tests --- src/cli/program/message/helpers.test.ts | 29 +++++++++++-------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/cli/program/message/helpers.test.ts b/src/cli/program/message/helpers.test.ts index 67b716cd35a..15bb60828b4 100644 --- a/src/cli/program/message/helpers.test.ts +++ b/src/cli/program/message/helpers.test.ts @@ -69,6 +69,17 @@ async function runSendAction(opts: Record = {}) { await expect(runMessageAction("send", { ...baseSendOptions, ...opts })).rejects.toThrow("exit"); } +function expectNoAccountFieldInPassedOptions() { + const passedOpts = ( + messageCommandMock.mock.calls as unknown as Array<[Record]> + )?.[0]?.[0]; + expect(passedOpts).toBeTruthy(); + if (!passedOpts) { + throw new Error("expected message command call"); + } + expect(passedOpts).not.toHaveProperty("account"); +} + describe("runMessageAction", () => { beforeEach(() => { vi.clearAllMocks(); @@ -180,14 +191,7 @@ describe("runMessageAction", () => { expect.anything(), ); // account key should be stripped in favor of accountId - const passedOpts = ( - messageCommandMock.mock.calls as unknown as Array<[Record]> - )?.[0]?.[0]; - expect(passedOpts).toBeTruthy(); - if (!passedOpts) { - throw new Error("expected message command call"); - } - expect(passedOpts).not.toHaveProperty("account"); + expectNoAccountFieldInPassedOptions(); }); it("strips non-string account values instead of passing accountId", async () => { @@ -212,13 +216,6 @@ describe("runMessageAction", () => { expect.anything(), expect.anything(), ); - const passedOpts = ( - messageCommandMock.mock.calls as unknown as Array<[Record]> - )?.[0]?.[0]; - expect(passedOpts).toBeTruthy(); - if (!passedOpts) { - throw new Error("expected message command call"); - } - expect(passedOpts).not.toHaveProperty("account"); + expectNoAccountFieldInPassedOptions(); }); }); From 1d71c21aacef889afa358d0f622c58d21d6875da Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:27:47 +0000 Subject: [PATCH 0017/2904] test(web): dedupe media-failure setup in deliver reply tests --- src/web/auto-reply/deliver-reply.test.ts | 40 ++++++++++++------------ 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/web/auto-reply/deliver-reply.test.ts b/src/web/auto-reply/deliver-reply.test.ts index 32850a95fde..3344016f1a9 100644 --- a/src/web/auto-reply/deliver-reply.test.ts +++ b/src/web/auto-reply/deliver-reply.test.ts @@ -37,6 +37,22 @@ function makeMsg(): WebInboundMsg { } as unknown as WebInboundMsg; } +function mockLoadedImageMedia() { + ( + loadWebMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void } + ).mockResolvedValueOnce({ + buffer: Buffer.from("img"), + contentType: "image/jpeg", + kind: "image", + }); +} + +function mockFirstSendMediaFailure(msg: WebInboundMsg, message: string) { + ( + msg.sendMedia as unknown as { mockRejectedValueOnce: (v: unknown) => void } + ).mockRejectedValueOnce(new Error(message)); +} + const replyLogger = { info: vi.fn(), warn: vi.fn(), @@ -123,16 +139,8 @@ describe("deliverWebReply", () => { it("retries media send on transient failure", async () => { const msg = makeMsg(); - ( - loadWebMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void } - ).mockResolvedValueOnce({ - buffer: Buffer.from("img"), - contentType: "image/jpeg", - kind: "image", - }); - ( - msg.sendMedia as unknown as { mockRejectedValueOnce: (v: unknown) => void } - ).mockRejectedValueOnce(new Error("socket reset")); + mockLoadedImageMedia(); + mockFirstSendMediaFailure(msg, "socket reset"); ( msg.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void } ).mockResolvedValueOnce(undefined); @@ -152,16 +160,8 @@ describe("deliverWebReply", () => { it("falls back to text-only when the first media send fails", async () => { const msg = makeMsg(); - ( - loadWebMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void } - ).mockResolvedValueOnce({ - buffer: Buffer.from("img"), - contentType: "image/jpeg", - kind: "image", - }); - ( - msg.sendMedia as unknown as { mockRejectedValueOnce: (v: unknown) => void } - ).mockRejectedValueOnce(new Error("boom")); + mockLoadedImageMedia(); + mockFirstSendMediaFailure(msg, "boom"); await deliverWebReply({ replyResult: { text: "caption", mediaUrl: "http://example.com/img.jpg" }, From 87d833115079c51a532d70f245c642c8b79b496c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:30:24 +0100 Subject: [PATCH 0018/2904] docs: warn against third-party 1-click marketplace images --- docs/install/index.md | 4 ++++ docs/platforms/digitalocean.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/docs/install/index.md b/docs/install/index.md index f9da04d71aa..285324ed6b7 100644 --- a/docs/install/index.md +++ b/docs/install/index.md @@ -27,6 +27,10 @@ On Windows, we strongly recommend running OpenClaw under [WSL2](https://learn.mi The **installer script** is the recommended way to install OpenClaw. It handles Node detection, installation, and onboarding in one step. + +For VPS/cloud hosts, avoid third-party "1-click" marketplace images when possible. Prefer a clean base OS image (for example Ubuntu LTS), then install OpenClaw yourself with the installer script. + + Downloads the CLI, installs it globally via npm, and launches the onboarding wizard. diff --git a/docs/platforms/digitalocean.md b/docs/platforms/digitalocean.md index 7a92ad68844..17012388b24 100644 --- a/docs/platforms/digitalocean.md +++ b/docs/platforms/digitalocean.md @@ -40,6 +40,10 @@ If you want a $0/month option and don’t mind ARM + provider-specific setup, se ## 1) Create a Droplet + +Use a clean base image (Ubuntu 24.04 LTS). Avoid third-party Marketplace 1-click images unless you have reviewed their startup scripts and firewall defaults. + + 1. Log into [DigitalOcean](https://cloud.digitalocean.com/) 2. Click **Create → Droplets** 3. Choose: From 5556675aae2162457ba38f946d787523d3560efb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:32:50 +0000 Subject: [PATCH 0019/2904] test(gateway): dedupe APNs wake fixture setup in node invoke tests --- .../server-methods/nodes.invoke-wake.test.ts | 71 +++++++------------ 1 file changed, 27 insertions(+), 44 deletions(-) diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts index c3a6bbfe6bc..147e1df86da 100644 --- a/src/gateway/server-methods/nodes.invoke-wake.test.ts +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -95,6 +95,31 @@ async function invokeNode(params: { return respond; } +function mockSuccessfulWakeConfig(nodeId: string) { + mocks.loadApnsRegistration.mockResolvedValue({ + nodeId, + token: "abcd1234abcd1234abcd1234abcd1234", + topic: "ai.openclaw.ios", + environment: "sandbox", + updatedAtMs: 1, + }); + mocks.resolveApnsAuthConfigFromEnv.mockResolvedValue({ + ok: true, + value: { + teamId: "TEAM123", + keyId: "KEY123", + privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", + }, + }); + mocks.sendApnsBackgroundWake.mockResolvedValue({ + ok: true, + status: 200, + tokenSuffix: "1234abcd", + topic: "ai.openclaw.ios", + environment: "sandbox", + }); +} + describe("node.invoke APNs wake path", () => { beforeEach(() => { mocks.loadConfig.mockReset(); @@ -135,28 +160,7 @@ describe("node.invoke APNs wake path", () => { it("wakes and retries invoke after the node reconnects", async () => { vi.useFakeTimers(); - mocks.loadApnsRegistration.mockResolvedValue({ - nodeId: "ios-node-reconnect", - token: "abcd1234abcd1234abcd1234abcd1234", - topic: "ai.openclaw.ios", - environment: "sandbox", - updatedAtMs: 1, - }); - mocks.resolveApnsAuthConfigFromEnv.mockResolvedValue({ - ok: true, - value: { - teamId: "TEAM123", - keyId: "KEY123", - privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", - }, - }); - mocks.sendApnsBackgroundWake.mockResolvedValue({ - ok: true, - status: 200, - tokenSuffix: "1234abcd", - topic: "ai.openclaw.ios", - environment: "sandbox", - }); + mockSuccessfulWakeConfig("ios-node-reconnect"); let connected = false; const session: TestNodeSession = { nodeId: "ios-node-reconnect", commands: ["camera.capture"] }; @@ -200,28 +204,7 @@ describe("node.invoke APNs wake path", () => { it("throttles repeated wake attempts for the same disconnected node", async () => { vi.useFakeTimers(); - mocks.loadApnsRegistration.mockResolvedValue({ - nodeId: "ios-node-throttle", - token: "abcd1234abcd1234abcd1234abcd1234", - topic: "ai.openclaw.ios", - environment: "sandbox", - updatedAtMs: 1, - }); - mocks.resolveApnsAuthConfigFromEnv.mockResolvedValue({ - ok: true, - value: { - teamId: "TEAM123", - keyId: "KEY123", - privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", - }, - }); - mocks.sendApnsBackgroundWake.mockResolvedValue({ - ok: true, - status: 200, - tokenSuffix: "1234abcd", - topic: "ai.openclaw.ios", - environment: "sandbox", - }); + mockSuccessfulWakeConfig("ios-node-throttle"); const nodeRegistry = { get: vi.fn(() => undefined), From 4c68a09f08fbafd5c1921a4fab6c005a4fa2e669 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:32:57 +0000 Subject: [PATCH 0020/2904] test(discord): dedupe gateway proxy runtime fixture --- src/discord/monitor/provider.proxy.test.ts | 28 ++++++++++------------ 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/src/discord/monitor/provider.proxy.test.ts b/src/discord/monitor/provider.proxy.test.ts index 410bf350693..365a82de729 100644 --- a/src/discord/monitor/provider.proxy.test.ts +++ b/src/discord/monitor/provider.proxy.test.ts @@ -70,6 +70,16 @@ vi.mock("ws", () => ({ })); describe("createDiscordGatewayPlugin", () => { + function createRuntime() { + return { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error("exit"); + }), + }; + } + beforeEach(() => { proxyAgentSpy.mockReset(); webSocketSpy.mockReset(); @@ -78,14 +88,7 @@ describe("createDiscordGatewayPlugin", () => { it("uses proxy agent for gateway WebSocket when configured", async () => { const { createDiscordGatewayPlugin } = await import("./gateway-plugin.js"); - - const runtime = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(() => { - throw new Error("exit"); - }), - }; + const runtime = createRuntime(); const plugin = createDiscordGatewayPlugin({ discordConfig: { proxy: "http://proxy.test:8080" }, @@ -109,14 +112,7 @@ describe("createDiscordGatewayPlugin", () => { it("falls back to the default gateway plugin when proxy is invalid", async () => { const { createDiscordGatewayPlugin } = await import("./gateway-plugin.js"); - - const runtime = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(() => { - throw new Error("exit"); - }), - }; + const runtime = createRuntime(); const plugin = createDiscordGatewayPlugin({ discordConfig: { proxy: "bad-proxy" }, From a4da6cfd53fc3bd840a6c3c25d6c8779344ff2e8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:33:03 +0000 Subject: [PATCH 0021/2904] test(update-cli): dedupe restart script test setup helpers --- src/cli/update-cli/restart-helper.test.ts | 120 ++++++---------------- 1 file changed, 34 insertions(+), 86 deletions(-) diff --git a/src/cli/update-cli/restart-helper.test.ts b/src/cli/update-cli/restart-helper.test.ts index ddeb8df34ef..802ced311c3 100644 --- a/src/cli/update-cli/restart-helper.test.ts +++ b/src/cli/update-cli/restart-helper.test.ts @@ -11,6 +11,17 @@ describe("restart-helper", () => { const originalPlatform = process.platform; const originalGetUid = process.getuid; + async function prepareAndReadScript(env: Record) { + const scriptPath = await prepareRestartScript(env); + expect(scriptPath).toBeTruthy(); + const content = await fs.readFile(scriptPath!, "utf-8"); + return { scriptPath: scriptPath!, content }; + } + + async function cleanupScript(scriptPath: string) { + await fs.unlink(scriptPath); + } + beforeEach(() => { vi.resetAllMocks(); }); @@ -23,165 +34,108 @@ describe("restart-helper", () => { describe("prepareRestartScript", () => { it("creates a systemd restart script on Linux", async () => { Object.defineProperty(process, "platform", { value: "linux" }); - const scriptPath = await prepareRestartScript({ + const { scriptPath, content } = await prepareAndReadScript({ OPENCLAW_PROFILE: "default", }); - - expect(scriptPath).toBeTruthy(); - expect(scriptPath!.endsWith(".sh")).toBe(true); - - const content = await fs.readFile(scriptPath!, "utf-8"); + expect(scriptPath.endsWith(".sh")).toBe(true); expect(content).toContain("#!/bin/sh"); expect(content).toContain("systemctl --user restart 'openclaw-gateway.service'"); // Script should self-cleanup expect(content).toContain('rm -f "$0"'); - - if (scriptPath) { - await fs.unlink(scriptPath); - } + await cleanupScript(scriptPath); }); it("uses OPENCLAW_SYSTEMD_UNIT override for systemd scripts", async () => { Object.defineProperty(process, "platform", { value: "linux" }); - const scriptPath = await prepareRestartScript({ + const { scriptPath, content } = await prepareAndReadScript({ OPENCLAW_PROFILE: "default", OPENCLAW_SYSTEMD_UNIT: "custom-gateway", }); - - expect(scriptPath).toBeTruthy(); - const content = await fs.readFile(scriptPath!, "utf-8"); expect(content).toContain("systemctl --user restart 'custom-gateway.service'"); - - if (scriptPath) { - await fs.unlink(scriptPath); - } + await cleanupScript(scriptPath); }); it("creates a launchd restart script on macOS", async () => { Object.defineProperty(process, "platform", { value: "darwin" }); process.getuid = () => 501; - const scriptPath = await prepareRestartScript({ + const { scriptPath, content } = await prepareAndReadScript({ OPENCLAW_PROFILE: "default", }); - - expect(scriptPath).toBeTruthy(); - expect(scriptPath!.endsWith(".sh")).toBe(true); - - const content = await fs.readFile(scriptPath!, "utf-8"); + expect(scriptPath.endsWith(".sh")).toBe(true); expect(content).toContain("#!/bin/sh"); expect(content).toContain("launchctl kickstart -k 'gui/501/ai.openclaw.gateway'"); expect(content).toContain('rm -f "$0"'); - - if (scriptPath) { - await fs.unlink(scriptPath); - } + await cleanupScript(scriptPath); }); it("uses OPENCLAW_LAUNCHD_LABEL override on macOS", async () => { Object.defineProperty(process, "platform", { value: "darwin" }); process.getuid = () => 501; - const scriptPath = await prepareRestartScript({ + const { scriptPath, content } = await prepareAndReadScript({ OPENCLAW_PROFILE: "default", OPENCLAW_LAUNCHD_LABEL: "com.custom.openclaw", }); - - expect(scriptPath).toBeTruthy(); - const content = await fs.readFile(scriptPath!, "utf-8"); expect(content).toContain("launchctl kickstart -k 'gui/501/com.custom.openclaw'"); - - if (scriptPath) { - await fs.unlink(scriptPath); - } + await cleanupScript(scriptPath); }); it("creates a schtasks restart script on Windows", async () => { Object.defineProperty(process, "platform", { value: "win32" }); - const scriptPath = await prepareRestartScript({ + const { scriptPath, content } = await prepareAndReadScript({ OPENCLAW_PROFILE: "default", }); - - expect(scriptPath).toBeTruthy(); - expect(scriptPath!.endsWith(".bat")).toBe(true); - - const content = await fs.readFile(scriptPath!, "utf-8"); + expect(scriptPath.endsWith(".bat")).toBe(true); expect(content).toContain("@echo off"); expect(content).toContain('schtasks /End /TN "OpenClaw Gateway"'); expect(content).toContain('schtasks /Run /TN "OpenClaw Gateway"'); // Batch self-cleanup expect(content).toContain('del "%~f0"'); - - if (scriptPath) { - await fs.unlink(scriptPath); - } + await cleanupScript(scriptPath); }); it("uses OPENCLAW_WINDOWS_TASK_NAME override on Windows", async () => { Object.defineProperty(process, "platform", { value: "win32" }); - const scriptPath = await prepareRestartScript({ + const { scriptPath, content } = await prepareAndReadScript({ OPENCLAW_PROFILE: "default", OPENCLAW_WINDOWS_TASK_NAME: "OpenClaw Gateway (custom)", }); - - expect(scriptPath).toBeTruthy(); - const content = await fs.readFile(scriptPath!, "utf-8"); expect(content).toContain('schtasks /End /TN "OpenClaw Gateway (custom)"'); expect(content).toContain('schtasks /Run /TN "OpenClaw Gateway (custom)"'); - - if (scriptPath) { - await fs.unlink(scriptPath); - } + await cleanupScript(scriptPath); }); it("uses custom profile in service names", async () => { Object.defineProperty(process, "platform", { value: "linux" }); - const scriptPath = await prepareRestartScript({ + const { scriptPath, content } = await prepareAndReadScript({ OPENCLAW_PROFILE: "production", }); - - expect(scriptPath).toBeTruthy(); - const content = await fs.readFile(scriptPath!, "utf-8"); expect(content).toContain("openclaw-gateway-production.service"); - - if (scriptPath) { - await fs.unlink(scriptPath); - } + await cleanupScript(scriptPath); }); it("uses custom profile in macOS launchd label", async () => { Object.defineProperty(process, "platform", { value: "darwin" }); process.getuid = () => 502; - const scriptPath = await prepareRestartScript({ + const { scriptPath, content } = await prepareAndReadScript({ OPENCLAW_PROFILE: "staging", }); - - expect(scriptPath).toBeTruthy(); - const content = await fs.readFile(scriptPath!, "utf-8"); expect(content).toContain("gui/502/ai.openclaw.staging"); - - if (scriptPath) { - await fs.unlink(scriptPath); - } + await cleanupScript(scriptPath); }); it("uses custom profile in Windows task name", async () => { Object.defineProperty(process, "platform", { value: "win32" }); - const scriptPath = await prepareRestartScript({ + const { scriptPath, content } = await prepareAndReadScript({ OPENCLAW_PROFILE: "production", }); - - expect(scriptPath).toBeTruthy(); - const content = await fs.readFile(scriptPath!, "utf-8"); expect(content).toContain('schtasks /End /TN "OpenClaw Gateway (production)"'); - - if (scriptPath) { - await fs.unlink(scriptPath); - } + await cleanupScript(scriptPath); }); it("returns null for unsupported platforms", async () => { @@ -206,19 +160,13 @@ describe("restart-helper", () => { it("escapes single quotes in profile names for shell scripts", async () => { Object.defineProperty(process, "platform", { value: "linux" }); - const scriptPath = await prepareRestartScript({ + const { scriptPath, content } = await prepareAndReadScript({ OPENCLAW_PROFILE: "it's-a-test", }); - - expect(scriptPath).toBeTruthy(); - const content = await fs.readFile(scriptPath!, "utf-8"); // Single quotes should be escaped with '\'' pattern expect(content).not.toContain("it's"); expect(content).toContain("it'\\''s"); - - if (scriptPath) { - await fs.unlink(scriptPath); - } + await cleanupScript(scriptPath); }); it("rejects unsafe batch profile names on Windows", async () => { From a2e846f649b39bb02b493ec6d15690f83441f148 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:33:25 +0000 Subject: [PATCH 0022/2904] test: drop duplicate skills-cli integration coverage --- src/cli/skills-cli.test.ts | 90 +------------------------------------- 1 file changed, 1 insertion(+), 89 deletions(-) diff --git a/src/cli/skills-cli.test.ts b/src/cli/skills-cli.test.ts index 7d243183d61..37323e7f21d 100644 --- a/src/cli/skills-cli.test.ts +++ b/src/cli/skills-cli.test.ts @@ -1,10 +1,5 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { SkillStatusEntry, SkillStatusReport } from "../agents/skills-status.js"; -import type { SkillEntry } from "../agents/skills.js"; -import { captureEnv } from "../test-utils/env.js"; import { createEmptyInstallChecks } from "./requirements-test-fixtures.js"; import { formatSkillInfo, formatSkillsCheck, formatSkillsList } from "./skills-cli.format.js"; @@ -221,87 +216,4 @@ describe("skills-cli", () => { assert(parsed); }); }); - - describe("integration: loads real skills from bundled directory", () => { - let tempWorkspaceDir = ""; - let tempBundledDir = ""; - let envSnapshot: ReturnType; - let buildWorkspaceSkillStatus: typeof import("../agents/skills-status.js").buildWorkspaceSkillStatus; - - beforeAll(async () => { - envSnapshot = captureEnv(["OPENCLAW_BUNDLED_SKILLS_DIR"]); - tempWorkspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-skills-test-")); - tempBundledDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-skills-test-")); - process.env.OPENCLAW_BUNDLED_SKILLS_DIR = tempBundledDir; - ({ buildWorkspaceSkillStatus } = await import("../agents/skills-status.js")); - }); - - afterAll(() => { - if (tempWorkspaceDir) { - fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); - } - if (tempBundledDir) { - fs.rmSync(tempBundledDir, { recursive: true, force: true }); - } - envSnapshot.restore(); - }); - - const createEntries = (): SkillEntry[] => { - const baseDir = path.join(tempWorkspaceDir, "peekaboo"); - return [ - { - skill: { - name: "peekaboo", - description: "Capture UI screenshots", - source: "openclaw-bundled", - filePath: path.join(baseDir, "SKILL.md"), - baseDir, - } as SkillEntry["skill"], - frontmatter: {}, - metadata: { emoji: "📸" }, - }, - ]; - }; - - it("loads bundled skills and formats them", async () => { - const entries = createEntries(); - const report = buildWorkspaceSkillStatus(tempWorkspaceDir, { - managedSkillsDir: "/nonexistent", - entries, - }); - - // Should have loaded some skills - expect(report.skills.length).toBeGreaterThan(0); - - // Format should work without errors - const listOutput = formatSkillsList(report, {}); - expect(listOutput).toContain("Skills"); - - const checkOutput = formatSkillsCheck(report, {}); - expect(checkOutput).toContain("Total:"); - - // JSON output should be valid - const jsonOutput = formatSkillsList(report, { json: true }); - const parsed = JSON.parse(jsonOutput); - expect(parsed.skills).toBeInstanceOf(Array); - }); - - it("formats info for a real bundled skill (peekaboo)", async () => { - const entries = createEntries(); - const report = buildWorkspaceSkillStatus(tempWorkspaceDir, { - managedSkillsDir: "/nonexistent", - entries, - }); - - // peekaboo is a bundled skill that should always exist - const peekaboo = report.skills.find((s) => s.name === "peekaboo"); - if (!peekaboo) { - throw new Error("peekaboo fixture skill missing"); - } - - const output = formatSkillInfo(report, "peekaboo", {}); - expect(output).toContain("peekaboo"); - expect(output).toContain("Details:"); - }); - }); }); From c5698caca31d5ae752e1887353e9d70ce3c441a2 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 19 Feb 2026 02:35:50 -0500 Subject: [PATCH 0023/2904] Security: default gateway auth bootstrap and explicit mode none (#20686) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: be1b73182cdca9c2331e2113bd1a08b977181974 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + docs/gateway/configuration-reference.md | 3 +- docs/help/faq.md | 4 +- src/browser/control-auth.auto-token.test.ts | 19 ++ src/browser/control-auth.test.ts | 21 ++ src/browser/control-auth.ts | 34 +-- .../gateway-cli/run.option-collisions.test.ts | 18 ++ src/cli/gateway-cli/run.ts | 71 +++--- src/config/types.gateway.ts | 4 +- src/config/zod-schema.ts | 7 +- src/gateway/auth.test.ts | 61 ++++- src/gateway/auth.ts | 49 +++- src/gateway/server-runtime-config.test.ts | 37 +++ src/gateway/server-runtime-config.ts | 15 +- src/gateway/server.auth.e2e.test.ts | 30 +++ src/gateway/server.impl.ts | 22 +- src/gateway/startup-auth.test.ts | 212 ++++++++++++++++++ src/gateway/startup-auth.ts | 147 ++++++++++++ 18 files changed, 678 insertions(+), 77 deletions(-) create mode 100644 src/gateway/startup-auth.test.ts create mode 100644 src/gateway/startup-auth.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bd130a45ee..0de6eac53fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Auth: default unresolved gateway auth to token mode with startup auto-generation/persistence of `gateway.auth.token`, while allowing explicit `gateway.auth.mode: "none"` for intentional open loopback setups. (#20686) thanks @gumadeiras. - Gateway/Hooks: run BOOT.md startup checks per configured agent scope, including per-agent session-key resolution, startup-hook regression coverage, and non-success boot outcome logging for diagnosability. (#20569) thanks @mcaxtr. - Telegram: unify message-like inbound handling so `message` and `channel_post` share the same dedupe/access/media pipeline and remain behaviorally consistent. (#20591) Thanks @obviyus. - Heartbeat/Cron: skip interval heartbeats when `HEARTBEAT.md` is missing or empty and no tagged cron events are queued, while preserving cron-event fallback for queued tagged reminders. (#20461) thanks @vikpos. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 5f551a2de50..e800690fd8b 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1976,7 +1976,7 @@ See [Plugins](/tools/plugin). port: 18789, bind: "loopback", auth: { - mode: "token", // token | password | trusted-proxy + mode: "token", // none | token | password | trusted-proxy token: "your-token", // password: "your-password", // or OPENCLAW_GATEWAY_PASSWORD // trustedProxy: { userHeader: "x-forwarded-user" }, // for mode=trusted-proxy; see /gateway/trusted-proxy-auth @@ -2022,6 +2022,7 @@ See [Plugins](/tools/plugin). - `port`: single multiplexed port for WS + HTTP. Precedence: `--port` > `OPENCLAW_GATEWAY_PORT` > `gateway.port` > `18789`. - `bind`: `auto`, `loopback` (default), `lan` (`0.0.0.0`), `tailnet` (Tailscale IP only), or `custom`. - **Auth**: required by default. Non-loopback binds require a shared token/password. Onboarding wizard generates a token by default. +- `auth.mode: "none"`: explicit no-auth mode. Use only for trusted local loopback setups; this is intentionally not offered by onboarding prompts. - `auth.mode: "trusted-proxy"`: delegate auth to an identity-aware reverse proxy and trust identity headers from `gateway.trustedProxies` (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)). - `auth.allowTailscale`: when `true`, Tailscale Serve identity headers satisfy auth (verified via `tailscale whois`). Defaults to `true` when `tailscale.mode = "serve"`. - `auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`. diff --git a/docs/help/faq.md b/docs/help/faq.md index 9dbfbca7ceb..053e7bbb4a9 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -1385,9 +1385,9 @@ Notes: ### Why do I need a token on localhost now -The wizard generates a gateway token by default (even on loopback) so **local WS clients must authenticate**. This blocks other local processes from calling the Gateway. Paste the token into the Control UI settings (or your client config) to connect. +OpenClaw enforces token auth by default, including loopback. If no token is configured, gateway startup auto-generates one and saves it to `gateway.auth.token`, so **local WS clients must authenticate**. This blocks other local processes from calling the Gateway. -If you **really** want open loopback, remove `gateway.auth` from your config. Doctor can generate a token for you any time: `openclaw doctor --generate-gateway-token`. +If you **really** want open loopback, set `gateway.auth.mode: "none"` explicitly in your config. Doctor can generate a token for you any time: `openclaw doctor --generate-gateway-token`. ### Do I have to restart after changing config diff --git a/src/browser/control-auth.auto-token.test.ts b/src/browser/control-auth.auto-token.test.ts index 0c2ffee811f..41107b2cbf0 100644 --- a/src/browser/control-auth.auto-token.test.ts +++ b/src/browser/control-auth.auto-token.test.ts @@ -98,6 +98,25 @@ describe("ensureBrowserControlAuth", () => { expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); + it("respects explicit none mode", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "none", + }, + }, + browser: { + enabled: true, + }, + }; + + const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv }); + + expect(result).toEqual({ auth: {} }); + expect(mocks.loadConfig).not.toHaveBeenCalled(); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + it("reuses auth from latest config snapshot", async () => { const cfg: OpenClawConfig = { browser: { diff --git a/src/browser/control-auth.test.ts b/src/browser/control-auth.test.ts index 817503fb38e..b88816adb5e 100644 --- a/src/browser/control-auth.test.ts +++ b/src/browser/control-auth.test.ts @@ -49,6 +49,27 @@ describe("ensureBrowserControlAuth", () => { }); }); + describe("none mode", () => { + it("should not auto-generate token when auth mode is none", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "none", + }, + }, + }; + + const result = await ensureBrowserControlAuth({ + cfg, + env: { OPENCLAW_BROWSER_AUTO_AUTH: "1" }, + }); + + expect(result.generatedToken).toBeUndefined(); + expect(result.auth.token).toBeUndefined(); + expect(result.auth.password).toBeUndefined(); + }); + }); + describe("token mode", () => { it("should return existing token if configured", async () => { const cfg: OpenClawConfig = { diff --git a/src/browser/control-auth.ts b/src/browser/control-auth.ts index 0fa25ab86f4..abbafc8d02c 100644 --- a/src/browser/control-auth.ts +++ b/src/browser/control-auth.ts @@ -1,7 +1,7 @@ -import crypto from "node:crypto"; import type { OpenClawConfig } from "../config/config.js"; -import { loadConfig, writeConfigFile } from "../config/config.js"; +import { loadConfig } from "../config/config.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; +import { ensureGatewayStartupAuth } from "../gateway/startup-auth.js"; export type BrowserControlAuth = { token?: string; @@ -58,6 +58,10 @@ export async function ensureBrowserControlAuth(params: { return { auth }; } + if (params.cfg.gateway?.auth?.mode === "none") { + return { auth }; + } + if (params.cfg.gateway?.auth?.mode === "trusted-proxy") { return { auth }; } @@ -71,25 +75,21 @@ export async function ensureBrowserControlAuth(params: { if (latestCfg.gateway?.auth?.mode === "password") { return { auth: latestAuth }; } + if (latestCfg.gateway?.auth?.mode === "none") { + return { auth: latestAuth }; + } if (latestCfg.gateway?.auth?.mode === "trusted-proxy") { return { auth: latestAuth }; } - const generatedToken = crypto.randomBytes(24).toString("hex"); - const nextCfg: OpenClawConfig = { - ...latestCfg, - gateway: { - ...latestCfg.gateway, - auth: { - ...latestCfg.gateway?.auth, - mode: "token", - token: generatedToken, - }, - }, - }; - await writeConfigFile(nextCfg); + const ensured = await ensureGatewayStartupAuth({ + cfg: latestCfg, + env, + persist: true, + }); + const ensuredAuth = resolveBrowserControlAuth(ensured.cfg, env); return { - auth: { token: generatedToken }, - generatedToken, + auth: ensuredAuth, + generatedToken: ensured.generatedToken, }; } diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index 93deae6b5d7..132962609e1 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -132,4 +132,22 @@ describe("gateway run option collisions", () => { }), ); }); + + it("starts gateway when token mode has no configured token (startup bootstrap path)", async () => { + const { addGatewayRunCommand } = await import("./run.js"); + const program = new Command(); + const gateway = addGatewayRunCommand(program.command("gateway")); + addGatewayRunCommand(gateway.command("run")); + + await program.parseAsync(["gateway", "run", "--allow-unconfigured"], { + from: "user", + }); + + expect(startGatewayServer).toHaveBeenCalledWith( + 18789, + expect.objectContaining({ + bind: "loopback", + }), + ); + }); }); diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 7e99e30d363..74c8394b5e4 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import type { Command } from "commander"; -import type { GatewayAuthMode } from "../../config/config.js"; +import type { GatewayAuthMode, GatewayTailscaleMode } from "../../config/config.js"; import { CONFIG_PATH, loadConfig, @@ -193,7 +193,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) { return; } const tailscaleRaw = toOptionString(opts.tailscale); - const tailscaleMode = + const tailscaleMode: GatewayTailscaleMode | null = tailscaleRaw === "off" || tailscaleRaw === "serve" || tailscaleRaw === "funnel" ? tailscaleRaw : null; @@ -239,14 +239,17 @@ async function runGatewayCommand(opts: GatewayRunOpts) { } const miskeys = extractGatewayMiskeys(snapshot?.parsed); - const authConfig = { - ...cfg.gateway?.auth, - ...(authMode ? { mode: authMode } : {}), - ...(passwordRaw ? { password: passwordRaw } : {}), - ...(tokenRaw ? { token: tokenRaw } : {}), - }; + const authOverride = + authMode || passwordRaw || tokenRaw || authModeRaw + ? { + ...(authMode ? { mode: authMode } : {}), + ...(tokenRaw ? { token: tokenRaw } : {}), + ...(passwordRaw ? { password: passwordRaw } : {}), + } + : undefined; const resolvedAuth = resolveGatewayAuth({ - authConfig, + authConfig: cfg.gateway?.auth, + authOverride, env: process.env, tailscaleMode: tailscaleMode ?? cfg.gateway?.tailscale?.mode ?? "off", }); @@ -257,6 +260,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) { const hasPassword = typeof passwordValue === "string" && passwordValue.trim().length > 0; const hasSharedSecret = (resolvedAuthMode === "token" && hasToken) || (resolvedAuthMode === "password" && hasPassword); + const canBootstrapToken = resolvedAuthMode === "token" && !hasToken; const authHints: string[] = []; if (miskeys.hasGatewayToken) { authHints.push('Found "gateway.token" in config. Use "gateway.auth.token" instead.'); @@ -266,19 +270,6 @@ async function runGatewayCommand(opts: GatewayRunOpts) { '"gateway.remote.token" is for remote CLI calls; it does not enable local gateway auth.', ); } - if (resolvedAuthMode === "token" && !hasToken && !resolvedAuth.allowTailscale) { - defaultRuntime.error( - [ - "Gateway auth is set to token, but no token is configured.", - "Set gateway.auth.token (or OPENCLAW_GATEWAY_TOKEN), or pass --token.", - ...authHints, - ] - .filter(Boolean) - .join("\n"), - ); - defaultRuntime.exit(1); - return; - } if (resolvedAuthMode === "password" && !hasPassword) { defaultRuntime.error( [ @@ -292,7 +283,17 @@ async function runGatewayCommand(opts: GatewayRunOpts) { defaultRuntime.exit(1); return; } - if (bind !== "loopback" && !hasSharedSecret && resolvedAuthMode !== "trusted-proxy") { + if (resolvedAuthMode === "none") { + gatewayLog.warn( + "Gateway auth mode=none explicitly configured; all gateway connections are unauthenticated.", + ); + } + if ( + bind !== "loopback" && + !hasSharedSecret && + !canBootstrapToken && + resolvedAuthMode !== "trusted-proxy" + ) { defaultRuntime.error( [ `Refusing to bind gateway to ${bind} without auth.`, @@ -305,6 +306,13 @@ async function runGatewayCommand(opts: GatewayRunOpts) { defaultRuntime.exit(1); return; } + const tailscaleOverride = + tailscaleMode || opts.tailscaleResetOnExit + ? { + ...(tailscaleMode ? { mode: tailscaleMode } : {}), + ...(opts.tailscaleResetOnExit ? { resetOnExit: true } : {}), + } + : undefined; try { await runGatewayLoop({ @@ -312,21 +320,8 @@ async function runGatewayCommand(opts: GatewayRunOpts) { start: async () => await startGatewayServer(port, { bind, - auth: - authMode || passwordRaw || tokenRaw || authModeRaw - ? { - mode: authMode ?? undefined, - token: tokenRaw, - password: passwordRaw, - } - : undefined, - tailscale: - tailscaleMode || opts.tailscaleResetOnExit - ? { - mode: tailscaleMode ?? undefined, - resetOnExit: Boolean(opts.tailscaleResetOnExit), - } - : undefined, + auth: authOverride, + tailscale: tailscaleOverride, }), }); } catch (err) { diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 141f5a0b3b2..5015286e887 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -76,7 +76,7 @@ export type GatewayControlUiConfig = { dangerouslyDisableDeviceAuth?: boolean; }; -export type GatewayAuthMode = "token" | "password" | "trusted-proxy"; +export type GatewayAuthMode = "none" | "token" | "password" | "trusted-proxy"; /** * Configuration for trusted reverse proxy authentication. @@ -104,7 +104,7 @@ export type GatewayTrustedProxyConfig = { }; export type GatewayAuthConfig = { - /** Authentication mode for Gateway connections. Defaults to token when set. */ + /** Authentication mode for Gateway connections. Defaults to token when unset. */ mode?: GatewayAuthMode; /** Shared token for token mode (stored locally for CLI auth). */ token?: string; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index ca1a781fa36..b47418302c2 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -410,7 +410,12 @@ export const OpenClawSchema = z auth: z .object({ mode: z - .union([z.literal("token"), z.literal("password"), z.literal("trusted-proxy")]) + .union([ + z.literal("none"), + z.literal("token"), + z.literal("password"), + z.literal("trusted-proxy"), + ]) .optional(), token: z.string().optional().register(sensitive), password: z.string().optional().register(sensitive), diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index f0982ab253a..acc761ea881 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -34,6 +34,7 @@ describe("gateway auth", () => { }), ).toMatchObject({ mode: "password", + modeSource: "password", token: "env-token", password: "env-password", }); @@ -49,12 +50,42 @@ describe("gateway auth", () => { } as NodeJS.ProcessEnv, }), ).toMatchObject({ - mode: "none", + mode: "token", + modeSource: "default", token: undefined, password: undefined, }); }); + it("resolves explicit auth mode none from config", () => { + expect( + resolveGatewayAuth({ + authConfig: { mode: "none" }, + env: {} as NodeJS.ProcessEnv, + }), + ).toMatchObject({ + mode: "none", + modeSource: "config", + token: undefined, + password: undefined, + }); + }); + + it("marks mode source as override when runtime mode override is provided", () => { + expect( + resolveGatewayAuth({ + authConfig: { mode: "password", password: "config-password" }, + authOverride: { mode: "token" }, + env: {} as NodeJS.ProcessEnv, + }), + ).toMatchObject({ + mode: "token", + modeSource: "override", + token: undefined, + password: "config-password", + }); + }); + it("does not throw when req is missing socket", async () => { const res = await authorizeGatewayConnect({ auth: { mode: "token", token: "secret", allowTailscale: false }, @@ -90,6 +121,34 @@ describe("gateway auth", () => { expect(res.reason).toBe("token_missing_config"); }); + it("allows explicit auth mode none", async () => { + const res = await authorizeGatewayConnect({ + auth: { mode: "none", allowTailscale: false }, + connectAuth: null, + }); + expect(res.ok).toBe(true); + expect(res.method).toBe("none"); + }); + + it("keeps none mode authoritative even when token is present", async () => { + const auth = resolveGatewayAuth({ + authConfig: { mode: "none", token: "configured-token" }, + env: {} as NodeJS.ProcessEnv, + }); + expect(auth).toMatchObject({ + mode: "none", + modeSource: "config", + token: "configured-token", + }); + + const res = await authorizeGatewayConnect({ + auth, + connectAuth: null, + }); + expect(res.ok).toBe(true); + expect(res.method).toBe("none"); + }); + it("reports missing and mismatched password reasons", async () => { const missing = await authorizeGatewayConnect({ auth: { mode: "password", password: "secret", allowTailscale: false }, diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 3212885c6ac..f3a7f2d9056 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -20,9 +20,16 @@ import { } from "./net.js"; export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy"; +export type ResolvedGatewayAuthModeSource = + | "override" + | "config" + | "password" + | "token" + | "default"; export type ResolvedGatewayAuth = { mode: ResolvedGatewayAuthMode; + modeSource?: ResolvedGatewayAuthModeSource; token?: string; password?: string; allowTailscale: boolean; @@ -178,24 +185,55 @@ async function resolveVerifiedTailscaleUser(params: { export function resolveGatewayAuth(params: { authConfig?: GatewayAuthConfig | null; + authOverride?: GatewayAuthConfig | null; env?: NodeJS.ProcessEnv; tailscaleMode?: GatewayTailscaleMode; }): ResolvedGatewayAuth { - const authConfig = params.authConfig ?? {}; + const baseAuthConfig = params.authConfig ?? {}; + const authOverride = params.authOverride ?? undefined; + const authConfig: GatewayAuthConfig = { ...baseAuthConfig }; + if (authOverride) { + if (authOverride.mode !== undefined) { + authConfig.mode = authOverride.mode; + } + if (authOverride.token !== undefined) { + authConfig.token = authOverride.token; + } + if (authOverride.password !== undefined) { + authConfig.password = authOverride.password; + } + if (authOverride.allowTailscale !== undefined) { + authConfig.allowTailscale = authOverride.allowTailscale; + } + if (authOverride.rateLimit !== undefined) { + authConfig.rateLimit = authOverride.rateLimit; + } + if (authOverride.trustedProxy !== undefined) { + authConfig.trustedProxy = authOverride.trustedProxy; + } + } const env = params.env ?? process.env; const token = authConfig.token ?? env.OPENCLAW_GATEWAY_TOKEN ?? undefined; const password = authConfig.password ?? env.OPENCLAW_GATEWAY_PASSWORD ?? undefined; const trustedProxy = authConfig.trustedProxy; let mode: ResolvedGatewayAuth["mode"]; - if (authConfig.mode) { + let modeSource: ResolvedGatewayAuth["modeSource"]; + if (authOverride?.mode !== undefined) { + mode = authOverride.mode; + modeSource = "override"; + } else if (authConfig.mode) { mode = authConfig.mode; + modeSource = "config"; } else if (password) { mode = "password"; + modeSource = "password"; } else if (token) { mode = "token"; + modeSource = "token"; } else { - mode = "none"; + mode = "token"; + modeSource = "default"; } const allowTailscale = @@ -204,6 +242,7 @@ export function resolveGatewayAuth(params: { return { mode, + modeSource, token, password, allowTailscale, @@ -317,6 +356,10 @@ export async function authorizeGatewayConnect(params: { return { ok: false, reason: result.reason }; } + if (auth.mode === "none") { + return { ok: true, method: "none" }; + } + const limiter = params.rateLimiter; const ip = params.clientIp ?? resolveRequestClientIp(req, trustedProxies) ?? req?.socket?.remoteAddress; diff --git a/src/gateway/server-runtime-config.test.ts b/src/gateway/server-runtime-config.test.ts index 2f85796886b..119c9cad9a7 100644 --- a/src/gateway/server-runtime-config.test.ts +++ b/src/gateway/server-runtime-config.test.ts @@ -115,5 +115,42 @@ describe("resolveGatewayRuntimeConfig", () => { expect(result.authMode).toBe("token"); expect(result.bindHost).toBe("0.0.0.0"); }); + + it("should allow loopback binding with explicit none mode", async () => { + const cfg = { + gateway: { + bind: "loopback" as const, + auth: { + mode: "none" as const, + }, + }, + }; + + const result = await resolveGatewayRuntimeConfig({ + cfg, + port: 18789, + }); + + expect(result.authMode).toBe("none"); + expect(result.bindHost).toBe("127.0.0.1"); + }); + + it("should reject lan binding with explicit none mode", async () => { + const cfg = { + gateway: { + bind: "lan" as const, + auth: { + mode: "none" as const, + }, + }, + }; + + await expect( + resolveGatewayRuntimeConfig({ + cfg, + port: 18789, + }), + ).rejects.toThrow("refusing to bind gateway"); + }); }); }); diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts index 8763341f00a..614b8c0b542 100644 --- a/src/gateway/server-runtime-config.ts +++ b/src/gateway/server-runtime-config.ts @@ -12,6 +12,7 @@ import { import { normalizeControlUiBasePath } from "./control-ui-shared.js"; import { resolveHooksConfig } from "./hooks.js"; import { isLoopbackHost, resolveGatewayBindHost } from "./net.js"; +import { mergeGatewayTailscaleConfig } from "./startup-auth.js"; export type GatewayRuntimeConfig = { bindHost: string; @@ -57,21 +58,13 @@ export async function resolveGatewayRuntimeConfig(params: { typeof controlUiRootRaw === "string" && controlUiRootRaw.trim().length > 0 ? controlUiRootRaw.trim() : undefined; - const authBase = params.cfg.gateway?.auth ?? {}; - const authOverrides = params.auth ?? {}; - const authConfig = { - ...authBase, - ...authOverrides, - }; const tailscaleBase = params.cfg.gateway?.tailscale ?? {}; const tailscaleOverrides = params.tailscale ?? {}; - const tailscaleConfig = { - ...tailscaleBase, - ...tailscaleOverrides, - }; + const tailscaleConfig = mergeGatewayTailscaleConfig(tailscaleBase, tailscaleOverrides); const tailscaleMode = tailscaleConfig.mode ?? "off"; const resolvedAuth = resolveGatewayAuth({ - authConfig, + authConfig: params.cfg.gateway?.auth, + authOverride: params.auth, env: process.env, tailscaleMode, }); diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index 8a6f050575c..bbfbbf29c3b 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -616,6 +616,36 @@ describe("gateway server auth/connect", () => { }); }); + describe("explicit none auth", () => { + let server: Awaited>; + let port: number; + let prevToken: string | undefined; + + beforeAll(async () => { + prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; + delete process.env.OPENCLAW_GATEWAY_TOKEN; + testState.gatewayAuth = { mode: "none" }; + port = await getFreePort(); + server = await startGatewayServer(port); + }); + + afterAll(async () => { + await server.close(); + if (prevToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; + } + }); + + test("allows loopback connect without shared secret when mode is none", async () => { + const ws = await openWs(port); + const res = await connectReq(ws, { skipDefaultAuth: true }); + expect(res.ok).toBe(true); + ws.close(); + }); + }); + describe("tailscale auth", () => { let server: Awaited>; let port: number; diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 2cfa561e993..a4add4d9488 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -84,6 +84,7 @@ import { refreshGatewayHealthSnapshot, } from "./server/health-state.js"; import { loadGatewayTlsRuntime } from "./server/tls.js"; +import { ensureGatewayStartupAuth } from "./startup-auth.js"; export { __resetModelCatalogCacheForTest } from "./server-model-catalog.js"; @@ -227,7 +228,26 @@ export async function startGatewayServer( } } - const cfgAtStart = loadConfig(); + let cfgAtStart = loadConfig(); + const authBootstrap = await ensureGatewayStartupAuth({ + cfg: cfgAtStart, + env: process.env, + authOverride: opts.auth, + tailscaleOverride: opts.tailscale, + persist: true, + }); + cfgAtStart = authBootstrap.cfg; + if (authBootstrap.generatedToken) { + if (authBootstrap.persistedGeneratedToken) { + log.info( + "Gateway auth token was missing. Generated a new token and saved it to config (gateway.auth.token).", + ); + } else { + log.warn( + "Gateway auth token was missing. Generated a runtime token for this startup without changing config; restart will generate a different token. Persist one with `openclaw config set gateway.auth.mode token` and `openclaw config set gateway.auth.token `.", + ); + } + } const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart); if (diagnosticsEnabled) { startDiagnosticHeartbeat(); diff --git a/src/gateway/startup-auth.test.ts b/src/gateway/startup-auth.test.ts new file mode 100644 index 00000000000..4cd10946550 --- /dev/null +++ b/src/gateway/startup-auth.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; + +const mocks = vi.hoisted(() => ({ + writeConfigFile: vi.fn(async (_cfg: OpenClawConfig) => {}), +})); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + writeConfigFile: mocks.writeConfigFile, + }; +}); + +import { ensureGatewayStartupAuth } from "./startup-auth.js"; + +describe("ensureGatewayStartupAuth", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mocks.writeConfigFile.mockReset(); + }); + + it("generates and persists a token when startup auth is missing", async () => { + const result = await ensureGatewayStartupAuth({ + cfg: {}, + env: {} as NodeJS.ProcessEnv, + persist: true, + }); + + expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/); + expect(result.persistedGeneratedToken).toBe(true); + expect(result.auth.mode).toBe("token"); + expect(result.auth.token).toBe(result.generatedToken); + expect(mocks.writeConfigFile).toHaveBeenCalledTimes(1); + const persisted = mocks.writeConfigFile.mock.calls[0]?.[0]; + expect(persisted?.gateway?.auth?.mode).toBe("token"); + expect(persisted?.gateway?.auth?.token).toBe(result.generatedToken); + }); + + it("does not generate when token already exists", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "token", + token: "configured-token", + }, + }, + }; + const result = await ensureGatewayStartupAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + persist: true, + }); + + expect(result.generatedToken).toBeUndefined(); + expect(result.persistedGeneratedToken).toBe(false); + expect(result.auth.mode).toBe("token"); + expect(result.auth.token).toBe("configured-token"); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("does not generate in password mode", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "password", + }, + }, + }; + const result = await ensureGatewayStartupAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + persist: true, + }); + + expect(result.generatedToken).toBeUndefined(); + expect(result.persistedGeneratedToken).toBe(false); + expect(result.auth.mode).toBe("password"); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("does not generate in trusted-proxy mode", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "trusted-proxy", + trustedProxy: { userHeader: "x-forwarded-user" }, + }, + }, + }; + const result = await ensureGatewayStartupAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + persist: true, + }); + + expect(result.generatedToken).toBeUndefined(); + expect(result.persistedGeneratedToken).toBe(false); + expect(result.auth.mode).toBe("trusted-proxy"); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("does not generate in explicit none mode", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "none", + }, + }, + }; + const result = await ensureGatewayStartupAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + persist: true, + }); + + expect(result.generatedToken).toBeUndefined(); + expect(result.persistedGeneratedToken).toBe(false); + expect(result.auth.mode).toBe("none"); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("treats undefined token override as no override", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "token", + token: "from-config", + }, + }, + }; + const result = await ensureGatewayStartupAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + authOverride: { mode: "token", token: undefined }, + persist: true, + }); + + expect(result.generatedToken).toBeUndefined(); + expect(result.persistedGeneratedToken).toBe(false); + expect(result.auth.mode).toBe("token"); + expect(result.auth.token).toBe("from-config"); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("keeps generated token ephemeral when runtime override flips explicit non-token mode", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "password", + }, + }, + }; + const result = await ensureGatewayStartupAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + authOverride: { mode: "token" }, + persist: true, + }); + + expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/); + expect(result.persistedGeneratedToken).toBe(false); + expect(result.auth.mode).toBe("token"); + expect(result.auth.token).toBe(result.generatedToken); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("keeps generated token ephemeral when runtime override flips explicit none mode", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "none", + }, + }, + }; + const result = await ensureGatewayStartupAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + authOverride: { mode: "token" }, + persist: true, + }); + + expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/); + expect(result.persistedGeneratedToken).toBe(false); + expect(result.auth.mode).toBe("token"); + expect(result.auth.token).toBe(result.generatedToken); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + + it("keeps generated token ephemeral when runtime override flips implicit password mode", async () => { + const cfg: OpenClawConfig = { + gateway: { + auth: { + password: "configured-password", + }, + }, + }; + const result = await ensureGatewayStartupAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + authOverride: { mode: "token" }, + persist: true, + }); + + expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/); + expect(result.persistedGeneratedToken).toBe(false); + expect(result.auth.mode).toBe("token"); + expect(result.auth.token).toBe(result.generatedToken); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gateway/startup-auth.ts b/src/gateway/startup-auth.ts new file mode 100644 index 00000000000..ec1ef7dd56e --- /dev/null +++ b/src/gateway/startup-auth.ts @@ -0,0 +1,147 @@ +import crypto from "node:crypto"; +import type { + GatewayAuthConfig, + GatewayTailscaleConfig, + OpenClawConfig, +} from "../config/config.js"; +import { writeConfigFile } from "../config/config.js"; +import { resolveGatewayAuth, type ResolvedGatewayAuth } from "./auth.js"; + +export function mergeGatewayAuthConfig( + base?: GatewayAuthConfig, + override?: GatewayAuthConfig, +): GatewayAuthConfig { + const merged: GatewayAuthConfig = { ...base }; + if (!override) { + return merged; + } + if (override.mode !== undefined) { + merged.mode = override.mode; + } + if (override.token !== undefined) { + merged.token = override.token; + } + if (override.password !== undefined) { + merged.password = override.password; + } + if (override.allowTailscale !== undefined) { + merged.allowTailscale = override.allowTailscale; + } + if (override.rateLimit !== undefined) { + merged.rateLimit = override.rateLimit; + } + if (override.trustedProxy !== undefined) { + merged.trustedProxy = override.trustedProxy; + } + return merged; +} + +export function mergeGatewayTailscaleConfig( + base?: GatewayTailscaleConfig, + override?: GatewayTailscaleConfig, +): GatewayTailscaleConfig { + const merged: GatewayTailscaleConfig = { ...base }; + if (!override) { + return merged; + } + if (override.mode !== undefined) { + merged.mode = override.mode; + } + if (override.resetOnExit !== undefined) { + merged.resetOnExit = override.resetOnExit; + } + return merged; +} + +function resolveGatewayAuthFromConfig(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + authOverride?: GatewayAuthConfig; + tailscaleOverride?: GatewayTailscaleConfig; +}) { + const tailscaleConfig = mergeGatewayTailscaleConfig( + params.cfg.gateway?.tailscale, + params.tailscaleOverride, + ); + return resolveGatewayAuth({ + authConfig: params.cfg.gateway?.auth, + authOverride: params.authOverride, + env: params.env, + tailscaleMode: tailscaleConfig.mode ?? "off", + }); +} + +function shouldPersistGeneratedToken(params: { + persistRequested: boolean; + resolvedAuth: ResolvedGatewayAuth; +}): boolean { + if (!params.persistRequested) { + return false; + } + + // Keep CLI/runtime mode overrides ephemeral: startup should not silently + // mutate durable auth policy when mode was chosen by an override flag. + if (params.resolvedAuth.modeSource === "override") { + return false; + } + + return true; +} + +export async function ensureGatewayStartupAuth(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + authOverride?: GatewayAuthConfig; + tailscaleOverride?: GatewayTailscaleConfig; + persist?: boolean; +}): Promise<{ + cfg: OpenClawConfig; + auth: ReturnType; + generatedToken?: string; + persistedGeneratedToken: boolean; +}> { + const env = params.env ?? process.env; + const persistRequested = params.persist === true; + const resolved = resolveGatewayAuthFromConfig({ + cfg: params.cfg, + env, + authOverride: params.authOverride, + tailscaleOverride: params.tailscaleOverride, + }); + if (resolved.mode !== "token" || (resolved.token?.trim().length ?? 0) > 0) { + return { cfg: params.cfg, auth: resolved, persistedGeneratedToken: false }; + } + + const generatedToken = crypto.randomBytes(24).toString("hex"); + const nextCfg: OpenClawConfig = { + ...params.cfg, + gateway: { + ...params.cfg.gateway, + auth: { + ...params.cfg.gateway?.auth, + mode: "token", + token: generatedToken, + }, + }, + }; + const persist = shouldPersistGeneratedToken({ + persistRequested, + resolvedAuth: resolved, + }); + if (persist) { + await writeConfigFile(nextCfg); + } + + const nextAuth = resolveGatewayAuthFromConfig({ + cfg: nextCfg, + env, + authOverride: params.authOverride, + tailscaleOverride: params.tailscaleOverride, + }); + return { + cfg: nextCfg, + auth: nextAuth, + generatedToken, + persistedGeneratedToken: persist, + }; +} From bd4fdfc35649bea85471fa2cbd877bf97df80382 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:36:48 +0000 Subject: [PATCH 0024/2904] test(reply): dedupe compaction session fixture setup --- src/auto-reply/reply/reply-state.test.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/auto-reply/reply/reply-state.test.ts b/src/auto-reply/reply/reply-state.test.ts index 182506b4e48..fee6b74fe70 100644 --- a/src/auto-reply/reply/reply-state.test.ts +++ b/src/auto-reply/reply/reply-state.test.ts @@ -35,6 +35,15 @@ async function seedSessionStore(params: { ); } +async function createCompactionSessionFixture(entry: SessionEntry) { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); + const storePath = path.join(tmp, "sessions.json"); + const sessionKey = "main"; + const sessionStore: Record = { [sessionKey]: entry }; + await seedSessionStore({ storePath, sessionKey, entry }); + return { storePath, sessionKey, sessionStore }; +} + describe("history helpers", () => { it("returns current message when history is empty", () => { const result = buildHistoryContext({ @@ -323,9 +332,6 @@ describe("incrementCompactionCount", () => { }); it("updates totalTokens when tokensAfter is provided", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; const entry = { sessionId: "s1", updatedAt: Date.now(), @@ -334,8 +340,7 @@ describe("incrementCompactionCount", () => { inputTokens: 170_000, outputTokens: 10_000, } as SessionEntry; - const sessionStore: Record = { [sessionKey]: entry }; - await seedSessionStore({ storePath, sessionKey, entry }); + const { storePath, sessionKey, sessionStore } = await createCompactionSessionFixture(entry); await incrementCompactionCount({ sessionEntry: entry, @@ -354,17 +359,13 @@ describe("incrementCompactionCount", () => { }); it("does not update totalTokens when tokensAfter is not provided", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); - const storePath = path.join(tmp, "sessions.json"); - const sessionKey = "main"; const entry = { sessionId: "s1", updatedAt: Date.now(), compactionCount: 0, totalTokens: 180_000, } as SessionEntry; - const sessionStore: Record = { [sessionKey]: entry }; - await seedSessionStore({ storePath, sessionKey, entry }); + const { storePath, sessionKey, sessionStore } = await createCompactionSessionFixture(entry); await incrementCompactionCount({ sessionEntry: entry, From 781b1c1e095bc35e57eb665db9d9bf4890142735 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:36:55 +0000 Subject: [PATCH 0025/2904] test(memory): dedupe voyage embedding provider test setup --- src/memory/embeddings-voyage.test.ts | 52 +++++++++++++--------------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/src/memory/embeddings-voyage.test.ts b/src/memory/embeddings-voyage.test.ts index e47710689e6..08e59474ae6 100644 --- a/src/memory/embeddings-voyage.test.ts +++ b/src/memory/embeddings-voyage.test.ts @@ -19,6 +19,28 @@ const createFetchMock = () => { return withFetchPreconnect(fetchMock); }; +function mockVoyageApiKey() { + vi.mocked(authModule.resolveApiKeyForProvider).mockResolvedValue({ + apiKey: "voyage-key-123", + mode: "api-key", + source: "test", + }); +} + +async function createDefaultVoyageProvider( + model: string, + fetchMock: ReturnType, +) { + vi.stubGlobal("fetch", fetchMock); + mockVoyageApiKey(); + return createVoyageEmbeddingProvider({ + config: {} as never, + provider: "voyage", + model, + fallback: "none", + }); +} + describe("voyage embedding provider", () => { afterEach(() => { vi.resetAllMocks(); @@ -27,20 +49,7 @@ describe("voyage embedding provider", () => { it("configures client with correct defaults and headers", async () => { const fetchMock = createFetchMock(); - vi.stubGlobal("fetch", fetchMock); - - vi.mocked(authModule.resolveApiKeyForProvider).mockResolvedValue({ - apiKey: "voyage-key-123", - mode: "api-key", - source: "test", - }); - - const result = await createVoyageEmbeddingProvider({ - config: {} as never, - provider: "voyage", - model: "voyage-4-large", - fallback: "none", - }); + const result = await createDefaultVoyageProvider("voyage-4-large", fetchMock); await result.provider.embedQuery("test query"); @@ -105,20 +114,7 @@ describe("voyage embedding provider", () => { ), ), ); - vi.stubGlobal("fetch", fetchMock); - - vi.mocked(authModule.resolveApiKeyForProvider).mockResolvedValue({ - apiKey: "voyage-key-123", - mode: "api-key", - source: "test", - }); - - const result = await createVoyageEmbeddingProvider({ - config: {} as never, - provider: "voyage", - model: "voyage-4-large", - fallback: "none", - }); + const result = await createDefaultVoyageProvider("voyage-4-large", fetchMock); await result.provider.embedBatch(["doc1", "doc2"]); From 7e54b6c96feb1a5c30884f2b32037b8dadd0e532 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:39:34 +0100 Subject: [PATCH 0026/2904] fix(browser): unify extension relay auth on gateway token --- CHANGELOG.md | 1 + assets/chrome-extension/README.md | 1 + assets/chrome-extension/background.js | 17 ++++++- assets/chrome-extension/options.html | 10 ++-- assets/chrome-extension/options.js | 50 ++++++++++++++------ docs/tools/chrome-extension.md | 13 ++++-- src/browser/extension-relay.test.ts | 66 +++++++++++++++++++-------- src/browser/extension-relay.ts | 58 +++++++++++------------ 8 files changed, 146 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0de6eac53fa..b2e641c9fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Gateway/Auth: default unresolved gateway auth to token mode with startup auto-generation/persistence of `gateway.auth.token`, while allowing explicit `gateway.auth.mode: "none"` for intentional open loopback setups. (#20686) thanks @gumadeiras. +- Browser/Relay: require gateway-token auth on both `/extension` and `/cdp`, and align Chrome extension setup to use a single `gateway.auth.token` input for relay authentication. Thanks @tdjackey for reporting. - Gateway/Hooks: run BOOT.md startup checks per configured agent scope, including per-agent session-key resolution, startup-hook regression coverage, and non-success boot outcome logging for diagnosability. (#20569) thanks @mcaxtr. - Telegram: unify message-like inbound handling so `message` and `channel_post` share the same dedupe/access/media pipeline and remain behaviorally consistent. (#20591) Thanks @obviyus. - Heartbeat/Cron: skip interval heartbeats when `HEARTBEAT.md` is missing or empty and no tagged cron events are queued, while preserving cron-event fallback for queued tagged reminders. (#20461) thanks @vikpos. diff --git a/assets/chrome-extension/README.md b/assets/chrome-extension/README.md index 2a2a11a3be5..4ee072c1f2b 100644 --- a/assets/chrome-extension/README.md +++ b/assets/chrome-extension/README.md @@ -20,3 +20,4 @@ Purpose: attach OpenClaw to an existing Chrome tab so the Gateway can automate i ## Options - `Relay port`: defaults to `18792`. +- `Gateway token`: required. Set this to `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`). diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js index 31ba401bddc..7a1754e06c9 100644 --- a/assets/chrome-extension/background.js +++ b/assets/chrome-extension/background.js @@ -42,6 +42,12 @@ async function getRelayPort() { return n } +async function getGatewayToken() { + const stored = await chrome.storage.local.get(['gatewayToken']) + const token = String(stored.gatewayToken || '').trim() + return token || '' +} + function setBadge(tabId, kind) { const cfg = BADGE[kind] void chrome.action.setBadgeText({ tabId, text: cfg.text }) @@ -55,8 +61,11 @@ async function ensureRelayConnection() { relayConnectPromise = (async () => { const port = await getRelayPort() + const gatewayToken = await getGatewayToken() const httpBase = `http://127.0.0.1:${port}` - const wsUrl = `ws://127.0.0.1:${port}/extension` + const wsUrl = gatewayToken + ? `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(gatewayToken)}` + : `ws://127.0.0.1:${port}/extension` // Fast preflight: is the relay server up? try { @@ -65,6 +74,12 @@ async function ensureRelayConnection() { throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`) } + if (!gatewayToken) { + throw new Error( + 'Missing gatewayToken in extension settings (chrome.storage.local.gatewayToken)', + ) + } + const ws = new WebSocket(wsUrl) relayWs = ws diff --git a/assets/chrome-extension/options.html b/assets/chrome-extension/options.html index 14704d65cf0..17fc6a79eed 100644 --- a/assets/chrome-extension/options.html +++ b/assets/chrome-extension/options.html @@ -176,15 +176,19 @@
-

Relay port

+

Relay connection

+
+ +
+
- Default: 18792. Extension connects to: http://127.0.0.1:<port>/. - Only change this if your OpenClaw profile uses a different cdpUrl port. + Default port: 18792. Extension connects to: http://127.0.0.1:<port>/. + Gateway token must match gateway.auth.token (or OPENCLAW_GATEWAY_TOKEN).
diff --git a/assets/chrome-extension/options.js b/assets/chrome-extension/options.js index 5b558ddccf2..e4252ccae4c 100644 --- a/assets/chrome-extension/options.js +++ b/assets/chrome-extension/options.js @@ -13,6 +13,12 @@ function updateRelayUrl(port) { el.textContent = `http://127.0.0.1:${port}/` } +function relayHeaders(token) { + const t = String(token || '').trim() + if (!t) return {} + return { 'x-openclaw-relay-token': t } +} + function setStatus(kind, message) { const status = document.getElementById('status') if (!status) return @@ -20,18 +26,31 @@ function setStatus(kind, message) { status.textContent = message || '' } -async function checkRelayReachable(port) { - const url = `http://127.0.0.1:${port}/` +async function checkRelayReachable(port, token) { + const url = `http://127.0.0.1:${port}/json/version` + const trimmedToken = String(token || '').trim() + if (!trimmedToken) { + setStatus('error', 'Gateway token required. Save your gateway token to connect.') + return + } const ctrl = new AbortController() - const t = setTimeout(() => ctrl.abort(), 900) + const t = setTimeout(() => ctrl.abort(), 1200) try { - const res = await fetch(url, { method: 'HEAD', signal: ctrl.signal }) + const res = await fetch(url, { + method: 'GET', + headers: relayHeaders(trimmedToken), + signal: ctrl.signal, + }) + if (res.status === 401) { + setStatus('error', 'Gateway token rejected. Check token and save again.') + return + } if (!res.ok) throw new Error(`HTTP ${res.status}`) - setStatus('ok', `Relay reachable at ${url}`) + setStatus('ok', `Relay reachable and authenticated at http://127.0.0.1:${port}/`) } catch { setStatus( 'error', - `Relay not reachable at ${url}. Start OpenClaw’s browser relay on this machine, then click the toolbar button again.`, + `Relay not reachable/authenticated at http://127.0.0.1:${port}/. Start OpenClaw browser relay and verify token.`, ) } finally { clearTimeout(t) @@ -39,20 +58,25 @@ async function checkRelayReachable(port) { } async function load() { - const stored = await chrome.storage.local.get(['relayPort']) + const stored = await chrome.storage.local.get(['relayPort', 'gatewayToken']) const port = clampPort(stored.relayPort) + const token = String(stored.gatewayToken || '').trim() document.getElementById('port').value = String(port) + document.getElementById('token').value = token updateRelayUrl(port) - await checkRelayReachable(port) + await checkRelayReachable(port, token) } async function save() { - const input = document.getElementById('port') - const port = clampPort(input.value) - await chrome.storage.local.set({ relayPort: port }) - input.value = String(port) + const portInput = document.getElementById('port') + const tokenInput = document.getElementById('token') + const port = clampPort(portInput.value) + const token = String(tokenInput.value || '').trim() + await chrome.storage.local.set({ relayPort: port, gatewayToken: token }) + portInput.value = String(port) + tokenInput.value = token updateRelayUrl(port) - await checkRelayReachable(port) + await checkRelayReachable(port, token) } document.getElementById('save').addEventListener('click', () => void save()) diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md index 4d49c835ed7..6049dfb36a7 100644 --- a/docs/tools/chrome-extension.md +++ b/docs/tools/chrome-extension.md @@ -53,10 +53,15 @@ After upgrading OpenClaw: - Re-run `openclaw browser extension install` to refresh the installed files under your OpenClaw state directory. - Chrome → `chrome://extensions` → click “Reload” on the extension. -## Use it (no extra config) +## Use it (set gateway token once) OpenClaw ships with a built-in browser profile named `chrome` that targets the extension relay on the default port. +Before first attach, open extension Options and set: + +- `Port` (default `18792`) +- `Gateway token` (must match `gateway.auth.token` / `OPENCLAW_GATEWAY_TOKEN`) + Use it: - CLI: `openclaw browser --browser-profile chrome tabs` @@ -89,12 +94,12 @@ openclaw browser create-profile \ - `ON`: attached; OpenClaw can drive that tab. - `…`: connecting to the local relay. -- `!`: relay not reachable (most common: browser relay server isn’t running on this machine). +- `!`: relay not reachable/authenticated (most common: relay server not running, or gateway token missing/wrong). If you see `!`: - Make sure the Gateway is running locally (default setup), or run a node host on this machine if the Gateway runs elsewhere. -- Open the extension Options page; it shows whether the relay is reachable. +- Open the extension Options page; it validates relay reachability + gateway-token auth. ## Remote Gateway (use a node host) @@ -169,7 +174,7 @@ Recommendations: - Prefer a dedicated Chrome profile (separate from your personal browsing) for extension relay usage. - Keep the Gateway and any node hosts tailnet-only; rely on Gateway auth + node pairing. - Avoid exposing relay ports over LAN (`0.0.0.0`) and avoid Funnel (public). -- The relay blocks non-extension origins and requires an internal auth token for CDP clients. +- The relay blocks non-extension origins and requires gateway-token auth for both `/cdp` and `/extension`. Related: diff --git a/src/browser/extension-relay.test.ts b/src/browser/extension-relay.test.ts index 021778393e6..54e8fb428e6 100644 --- a/src/browser/extension-relay.test.ts +++ b/src/browser/extension-relay.test.ts @@ -1,5 +1,5 @@ import { createServer } from "node:http"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import WebSocket from "ws"; import { ensureChromeExtensionRelayServer, @@ -122,13 +122,25 @@ async function waitForListMatch( } describe("chrome extension relay server", () => { + const TEST_GATEWAY_TOKEN = "test-gateway-token"; let cdpUrl = ""; + let previousGatewayToken: string | undefined; + + beforeEach(() => { + previousGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN; + process.env.OPENCLAW_GATEWAY_TOKEN = TEST_GATEWAY_TOKEN; + }); afterEach(async () => { if (cdpUrl) { await stopChromeExtensionRelayServer({ cdpUrl }).catch(() => {}); cdpUrl = ""; } + if (previousGatewayToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = previousGatewayToken; + } }); it("advertises CDP WS only when extension is connected", async () => { @@ -143,7 +155,9 @@ describe("chrome extension relay server", () => { }; expect(v1.webSocketDebuggerUrl).toBeUndefined(); - const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`); + const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, { + headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), + }); await waitForOpen(ext); const v2 = (await fetch(`${cdpUrl}/json/version`, { @@ -156,21 +170,11 @@ describe("chrome extension relay server", () => { ext.close(); }); - it("derives relay auth headers from gateway token for loopback URLs", async () => { + it("uses gateway token for relay auth headers on loopback URLs", async () => { const port = await getFreePort(); - const prev = process.env.OPENCLAW_GATEWAY_TOKEN; - process.env.OPENCLAW_GATEWAY_TOKEN = "test-gateway-token"; - try { - const headers = getChromeExtensionRelayAuthHeaders(`http://127.0.0.1:${port}`); - expect(Object.keys(headers)).toContain("x-openclaw-relay-token"); - expect((headers["x-openclaw-relay-token"] ?? "").length).toBeGreaterThan(20); - } finally { - if (prev === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = prev; - } - } + const headers = getChromeExtensionRelayAuthHeaders(`http://127.0.0.1:${port}`); + expect(Object.keys(headers)).toContain("x-openclaw-relay-token"); + expect(headers["x-openclaw-relay-token"]).toBe(TEST_GATEWAY_TOKEN); }); it("rejects CDP access without relay auth token", async () => { @@ -186,12 +190,36 @@ describe("chrome extension relay server", () => { expect(err.message).toContain("401"); }); - it("tracks attached page targets and exposes them via CDP + /json/list", async () => { + it("rejects extension websocket access without relay auth token", async () => { const port = await getFreePort(); cdpUrl = `http://127.0.0.1:${port}`; await ensureChromeExtensionRelayServer({ cdpUrl }); const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`); + const err = await waitForError(ext); + expect(err.message).toContain("401"); + }); + + it("accepts extension websocket access with gateway token query param", async () => { + const port = await getFreePort(); + cdpUrl = `http://127.0.0.1:${port}`; + await ensureChromeExtensionRelayServer({ cdpUrl }); + + const ext = new WebSocket( + `ws://127.0.0.1:${port}/extension?token=${encodeURIComponent(TEST_GATEWAY_TOKEN)}`, + ); + await waitForOpen(ext); + ext.close(); + }); + + it("tracks attached page targets and exposes them via CDP + /json/list", async () => { + const port = await getFreePort(); + cdpUrl = `http://127.0.0.1:${port}`; + await ensureChromeExtensionRelayServer({ cdpUrl }); + + const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, { + headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), + }); await waitForOpen(ext); // Simulate a tab attach coming from the extension. @@ -307,7 +335,9 @@ describe("chrome extension relay server", () => { cdpUrl = `http://127.0.0.1:${port}`; await ensureChromeExtensionRelayServer({ cdpUrl }); - const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`); + const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`, { + headers: relayAuthHeaders(`ws://127.0.0.1:${port}/extension`), + }); await waitForOpen(ext); const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`, { diff --git a/src/browser/extension-relay.ts b/src/browser/extension-relay.ts index 53a38e3ac73..e65500a5d44 100644 --- a/src/browser/extension-relay.ts +++ b/src/browser/extension-relay.ts @@ -1,8 +1,7 @@ -import { createHash, randomBytes } from "node:crypto"; import type { IncomingMessage } from "node:http"; -import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import type { Duplex } from "node:stream"; +import { createServer } from "node:http"; import WebSocket, { WebSocketServer } from "ws"; import { loadConfig } from "../config/config.js"; import { isLoopbackAddress, isLoopbackHost } from "../gateway/net.js"; @@ -94,6 +93,18 @@ function getHeader(req: IncomingMessage, name: string): string | undefined { return headerValue(req.headers[name.toLowerCase()]); } +function getRelayAuthTokenFromRequest(req: IncomingMessage, url?: URL): string | undefined { + const headerToken = getHeader(req, RELAY_AUTH_HEADER)?.trim(); + if (headerToken) { + return headerToken; + } + const queryToken = url?.searchParams.get("token")?.trim(); + if (queryToken) { + return queryToken; + } + return undefined; +} + export type ChromeExtensionRelayServer = { host: string; port: number; @@ -144,7 +155,6 @@ function rejectUpgrade(socket: Duplex, status: number, bodyText: string) { } const serversByPort = new Map(); -const relayAuthByPort = new Map(); function resolveGatewayAuthToken(): string | null { const envToken = @@ -164,19 +174,14 @@ function resolveGatewayAuthToken(): string | null { return null; } -function deriveDeterministicRelayAuthToken(port: number): string | null { +function resolveRelayAuthToken(): string { const gatewayToken = resolveGatewayAuthToken(); - if (!gatewayToken) { - return null; + if (gatewayToken) { + return gatewayToken; } - return createHash("sha256") - .update(`openclaw-relay:${port}:`) - .update(gatewayToken) - .digest("base64url"); -} - -function resolveRelayAuthToken(port: number): string { - return deriveDeterministicRelayAuthToken(port) ?? randomBytes(32).toString("base64url"); + throw new Error( + "extension relay requires gateway auth token (set gateway.auth.token or OPENCLAW_GATEWAY_TOKEN)", + ); } function isAddrInUseError(err: unknown): boolean { @@ -212,16 +217,7 @@ function relayAuthTokenForUrl(url: string): string | null { if (!isLoopbackHost(parsed.hostname)) { return null; } - const port = - parsed.port?.trim() !== "" - ? Number(parsed.port) - : parsed.protocol === "https:" || parsed.protocol === "wss:" - ? 443 - : 80; - if (!Number.isFinite(port)) { - return null; - } - return relayAuthByPort.get(port) ?? deriveDeterministicRelayAuthToken(port); + return resolveGatewayAuthToken(); } catch { return null; } @@ -248,7 +244,7 @@ export async function ensureChromeExtensionRelayServer(opts: { return existing; } - const relayAuthToken = resolveRelayAuthToken(info.port); + const relayAuthToken = resolveRelayAuthToken(); let extensionWs: WebSocket | null = null; const cdpClients = new Set(); @@ -529,6 +525,11 @@ export async function ensureChromeExtensionRelayServer(opts: { } if (pathname === "/extension") { + const token = getRelayAuthTokenFromRequest(req, url); + if (!token || token !== relayAuthToken) { + rejectUpgrade(socket, 401, "Unauthorized"); + return; + } if (extensionWs) { rejectUpgrade(socket, 409, "Extension already connected"); return; @@ -540,7 +541,7 @@ export async function ensureChromeExtensionRelayServer(opts: { } if (pathname === "/cdp") { - const token = getHeader(req, RELAY_AUTH_HEADER); + const token = getRelayAuthTokenFromRequest(req, url); if (!token || token !== relayAuthToken) { rejectUpgrade(socket, 401, "Unauthorized"); return; @@ -779,10 +780,8 @@ export async function ensureChromeExtensionRelayServer(opts: { extensionConnected: () => false, stop: async () => { serversByPort.delete(info.port); - relayAuthByPort.delete(info.port); }, }; - relayAuthByPort.set(info.port, relayAuthToken); serversByPort.set(info.port, existingRelay); return existingRelay; } @@ -802,7 +801,6 @@ export async function ensureChromeExtensionRelayServer(opts: { extensionConnected: () => Boolean(extensionWs), stop: async () => { serversByPort.delete(port); - relayAuthByPort.delete(port); try { extensionWs?.close(1001, "server stopping"); } catch { @@ -823,7 +821,6 @@ export async function ensureChromeExtensionRelayServer(opts: { }, }; - relayAuthByPort.set(port, relayAuthToken); serversByPort.set(port, relay); return relay; } @@ -835,6 +832,5 @@ export async function stopChromeExtensionRelayServer(opts: { cdpUrl: string }): return false; } await existing.stop(); - relayAuthByPort.delete(info.port); return true; } From ff1189c6d68b6ace1f3cd6ea45b5abbf3a727dee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:41:38 +0000 Subject: [PATCH 0027/2904] test: remove duplicate inbound-meta coverage from reply-flow --- src/auto-reply/reply/reply-flow.test.ts | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts index 6df2037ae27..9883d3da058 100644 --- a/src/auto-reply/reply/reply-flow.test.ts +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -2,10 +2,9 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js"; import type { OpenClawConfig } from "../../config/config.js"; import { defaultRuntime } from "../../runtime.js"; -import type { MsgContext, TemplateContext } from "../templating.js"; +import type { MsgContext } from "../templating.js"; import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js"; import { finalizeInboundContext } from "./inbound-context.js"; -import { buildInboundUserContextPrefix } from "./inbound-meta.js"; import { normalizeInboundTextNewlines } from "./inbound-text.js"; import { parseLineDirectives, hasLineDirectives } from "./line-directives.js"; import type { FollowupRun, QueueSettings } from "./queue.js"; @@ -13,27 +12,6 @@ import { enqueueFollowupRun, scheduleFollowupDrain } from "./queue.js"; import { createReplyDispatcher } from "./reply-dispatcher.js"; import { createReplyToModeFilter, resolveReplyToMode } from "./reply-threading.js"; -describe("buildInboundUserContextPrefix", () => { - it("omits conversation label block for direct chats", () => { - const text = buildInboundUserContextPrefix({ - ChatType: "direct", - ConversationLabel: "openclaw-tui", - } as TemplateContext); - - expect(text).toBe(""); - }); - - it("keeps conversation label for group chats", () => { - const text = buildInboundUserContextPrefix({ - ChatType: "group", - ConversationLabel: "ops-room", - } as TemplateContext); - - expect(text).toContain("Conversation info (untrusted metadata):"); - expect(text).toContain('"conversation_label": "ops-room"'); - }); -}); - describe("normalizeInboundTextNewlines", () => { it("converts CRLF to LF", () => { expect(normalizeInboundTextNewlines("hello\r\nworld")).toBe("hello\nworld"); From 1c04f5fcbbedb049b9ea3cc119a7b8f188df21be Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:44:06 +0000 Subject: [PATCH 0028/2904] style: format extension relay imports --- src/browser/extension-relay.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/extension-relay.ts b/src/browser/extension-relay.ts index e65500a5d44..6b799cc0fa8 100644 --- a/src/browser/extension-relay.ts +++ b/src/browser/extension-relay.ts @@ -1,7 +1,7 @@ import type { IncomingMessage } from "node:http"; +import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import type { Duplex } from "node:stream"; -import { createServer } from "node:http"; import WebSocket, { WebSocketServer } from "ws"; import { loadConfig } from "../config/config.js"; import { isLoopbackAddress, isLoopbackHost } from "../gateway/net.js"; From 3cb0c96740cb93b843781b87693faf0674b6138c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:44:25 +0000 Subject: [PATCH 0029/2904] test(image-tool): dedupe repeated image tool fixture assertions --- src/agents/tools/image-tool.e2e.test.ts | 71 +++++++------------------ 1 file changed, 19 insertions(+), 52 deletions(-) diff --git a/src/agents/tools/image-tool.e2e.test.ts b/src/agents/tools/image-tool.e2e.test.ts index 400e2b7a822..b4bee9bb31e 100644 --- a/src/agents/tools/image-tool.e2e.test.ts +++ b/src/agents/tools/image-tool.e2e.test.ts @@ -92,6 +92,14 @@ async function expectImageToolExecOk( }); } +function requireImageTool(tool: T | null | undefined): T { + expect(tool).not.toBeNull(); + if (!tool) { + throw new Error("expected image tool"); + } + return tool; +} + function findSchemaUnionKeywords(schema: unknown, path = "root"): string[] { if (!schema || typeof schema !== "object") { return []; @@ -249,11 +257,7 @@ describe("image tool implicit imageModel config", () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-")); try { const cfg = createMinimaxImageConfig(); - const tool = createImageTool({ config: cfg, agentDir }); - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("expected image tool"); - } + const tool = requireImageTool(createImageTool({ config: cfg, agentDir })); const violations = findSchemaUnionKeywords(tool.parameters, "image.parameters"); expect(violations).toEqual([]); @@ -279,11 +283,7 @@ describe("image tool implicit imageModel config", () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-")); try { const cfg = createMinimaxImageConfig(); - const tool = createImageTool({ config: cfg, agentDir }); - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("expected image tool"); - } + const tool = requireImageTool(createImageTool({ config: cfg, agentDir })); expect(JSON.parse(JSON.stringify(tool.parameters))).toEqual({ type: "object", @@ -312,11 +312,7 @@ describe("image tool implicit imageModel config", () => { try { const cfg = createMinimaxImageConfig(); - const withoutWorkspace = createImageTool({ config: cfg, agentDir }); - expect(withoutWorkspace).not.toBeNull(); - if (!withoutWorkspace) { - throw new Error("expected image tool"); - } + const withoutWorkspace = requireImageTool(createImageTool({ config: cfg, agentDir })); await expect( withoutWorkspace.execute("t0", { prompt: "Describe the image.", @@ -324,11 +320,9 @@ describe("image tool implicit imageModel config", () => { }), ).rejects.toThrow(/Local media path is not under an allowed directory/i); - const withWorkspace = createImageTool({ config: cfg, agentDir, workspaceDir }); - expect(withWorkspace).not.toBeNull(); - if (!withWorkspace) { - throw new Error("expected image tool"); - } + const withWorkspace = requireImageTool( + createImageTool({ config: cfg, agentDir, workspaceDir }), + ); await expectImageToolExecOk(withWorkspace, imagePath); @@ -347,11 +341,7 @@ describe("image tool implicit imageModel config", () => { const cfg = createMinimaxImageConfig(); const tools = createOpenClawCodingTools({ config: cfg, agentDir }); - const tool = tools.find((candidate) => candidate.name === "image"); - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("expected image tool"); - } + const tool = requireImageTool(tools.find((candidate) => candidate.name === "image")); await expectImageToolExecOk(tool, imagePath); @@ -375,11 +365,7 @@ describe("image tool implicit imageModel config", () => { const cfg: OpenClawConfig = { agents: { defaults: { model: { primary: "minimax/MiniMax-M2.1" } } }, }; - const tool = createImageTool({ config: cfg, agentDir, sandbox }); - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("expected image tool"); - } + const tool = requireImageTool(createImageTool({ config: cfg, agentDir, sandbox })); await expect(tool.execute("t1", { image: "https://example.com/a.png" })).rejects.toThrow( /Sandboxed image tool does not allow remote URLs/i, @@ -405,18 +391,7 @@ describe("image tool implicit imageModel config", () => { Buffer.from(pngB64, "base64"), ); - const fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - statusText: "OK", - headers: new Headers(), - json: async () => ({ - content: "ok", - base_resp: { status_code: 0, status_msg: "" }, - }), - }); - global.fetch = withFetchPreconnect(fetch); - vi.stubEnv("MINIMAX_API_KEY", "minimax-test"); + const fetch = stubMinimaxOkFetch(); const cfg: OpenClawConfig = { agents: { @@ -427,11 +402,7 @@ describe("image tool implicit imageModel config", () => { }, }; const sandbox = { root: sandboxRoot, bridge: createHostSandboxFsBridge(sandboxRoot) }; - const tool = createImageTool({ config: cfg, agentDir, sandbox }); - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("expected image tool"); - } + const tool = requireImageTool(createImageTool({ config: cfg, agentDir, sandbox })); const res = await tool.execute("t1", { prompt: "Describe the image.", @@ -495,11 +466,7 @@ describe("image tool MiniMax VLM routing", () => { const cfg: OpenClawConfig = { agents: { defaults: { model: { primary: "minimax/MiniMax-M2.1" } } }, }; - const tool = createImageTool({ config: cfg, agentDir }); - expect(tool).not.toBeNull(); - if (!tool) { - throw new Error("expected image tool"); - } + const tool = requireImageTool(createImageTool({ config: cfg, agentDir })); return { fetch, tool }; } From d7b2efc2e7836754f1c9885bc557de1fee2cec45 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:44:30 +0000 Subject: [PATCH 0030/2904] test(agents): dedupe ping-pong loop test scaffolding --- src/agents/tool-loop-detection.test.ts | 118 ++++++++++++++----------- 1 file changed, 65 insertions(+), 53 deletions(-) diff --git a/src/agents/tool-loop-detection.test.ts b/src/agents/tool-loop-detection.test.ts index eac09dc996b..19cf950efc3 100644 --- a/src/agents/tool-loop-detection.test.ts +++ b/src/agents/tool-loop-detection.test.ts @@ -45,6 +45,50 @@ function recordSuccessfulCall( }); } +function recordSuccessfulPingPongCalls(params: { + state: SessionState; + readParams: { path: string }; + listParams: { dir: string }; + count: number; + textAtIndex: (toolName: "read" | "list", index: number) => string; +}) { + for (let i = 0; i < params.count; i += 1) { + if (i % 2 === 0) { + recordSuccessfulCall( + params.state, + "read", + params.readParams, + { content: [{ type: "text", text: params.textAtIndex("read", i) }], details: { ok: true } }, + i, + ); + } else { + recordSuccessfulCall( + params.state, + "list", + params.listParams, + { content: [{ type: "text", text: params.textAtIndex("list", i) }], details: { ok: true } }, + i, + ); + } + } +} + +function expectPingPongLoop( + loopResult: ReturnType, + expected: { level: "warning" | "critical"; count: number; expectCriticalText?: boolean }, +) { + expect(loopResult.stuck).toBe(true); + if (!loopResult.stuck) { + return; + } + expect(loopResult.level).toBe(expected.level); + expect(loopResult.detector).toBe("ping_pong"); + expect(loopResult.count).toBe(expected.count); + if (expected.expectCriticalText) { + expect(loopResult.message).toContain("CRITICAL"); + } +} + describe("tool-loop-detection", () => { describe("hashToolCall", () => { it("creates consistent hash for same tool and params", () => { @@ -356,11 +400,8 @@ describe("tool-loop-detection", () => { } const loopResult = detectToolCallLoop(state, "list", listParams, enabledLoopDetectionConfig); - expect(loopResult.stuck).toBe(true); + expectPingPongLoop(loopResult, { level: "warning", count: WARNING_THRESHOLD }); if (loopResult.stuck) { - expect(loopResult.level).toBe("warning"); - expect(loopResult.detector).toBe("ping_pong"); - expect(loopResult.count).toBe(WARNING_THRESHOLD); expect(loopResult.message).toContain("ping-pong loop"); } }); @@ -370,33 +411,21 @@ describe("tool-loop-detection", () => { const readParams = { path: "/a.txt" }; const listParams = { dir: "/workspace" }; - for (let i = 0; i < CRITICAL_THRESHOLD - 1; i += 1) { - if (i % 2 === 0) { - recordSuccessfulCall( - state, - "read", - readParams, - { content: [{ type: "text", text: "read stable" }], details: { ok: true } }, - i, - ); - } else { - recordSuccessfulCall( - state, - "list", - listParams, - { content: [{ type: "text", text: "list stable" }], details: { ok: true } }, - i, - ); - } - } + recordSuccessfulPingPongCalls({ + state, + readParams, + listParams, + count: CRITICAL_THRESHOLD - 1, + textAtIndex: (toolName) => (toolName === "read" ? "read stable" : "list stable"), + }); const loopResult = detectToolCallLoop(state, "list", listParams, enabledLoopDetectionConfig); - expect(loopResult.stuck).toBe(true); + expectPingPongLoop(loopResult, { + level: "critical", + count: CRITICAL_THRESHOLD, + expectCriticalText: true, + }); if (loopResult.stuck) { - expect(loopResult.level).toBe("critical"); - expect(loopResult.detector).toBe("ping_pong"); - expect(loopResult.count).toBe(CRITICAL_THRESHOLD); - expect(loopResult.message).toContain("CRITICAL"); expect(loopResult.message).toContain("ping-pong loop"); } }); @@ -406,33 +435,16 @@ describe("tool-loop-detection", () => { const readParams = { path: "/a.txt" }; const listParams = { dir: "/workspace" }; - for (let i = 0; i < CRITICAL_THRESHOLD - 1; i += 1) { - if (i % 2 === 0) { - recordSuccessfulCall( - state, - "read", - readParams, - { content: [{ type: "text", text: `read ${i}` }], details: { ok: true } }, - i, - ); - } else { - recordSuccessfulCall( - state, - "list", - listParams, - { content: [{ type: "text", text: `list ${i}` }], details: { ok: true } }, - i, - ); - } - } + recordSuccessfulPingPongCalls({ + state, + readParams, + listParams, + count: CRITICAL_THRESHOLD - 1, + textAtIndex: (toolName, index) => `${toolName} ${index}`, + }); const loopResult = detectToolCallLoop(state, "list", listParams, enabledLoopDetectionConfig); - expect(loopResult.stuck).toBe(true); - if (loopResult.stuck) { - expect(loopResult.level).toBe("warning"); - expect(loopResult.detector).toBe("ping_pong"); - expect(loopResult.count).toBe(CRITICAL_THRESHOLD); - } + expectPingPongLoop(loopResult, { level: "warning", count: CRITICAL_THRESHOLD }); }); it("does not flag ping-pong when alternation is broken", () => { From ccd68d8166df4212c06a578e39451e4412e60c4e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:44:34 +0000 Subject: [PATCH 0031/2904] test(subagents): dedupe sessions_spawn model expectation paths --- ...subagents.sessions-spawn.model.e2e.test.ts | 70 +++++-------------- 1 file changed, 18 insertions(+), 52 deletions(-) diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts index c0e67858fd4..94c317fdde8 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts @@ -62,14 +62,18 @@ function mockPatchAndSingleAgentRun(params: { calls: GatewayCall[]; runId: strin } async function expectSpawnUsesConfiguredModel(params: { - config: SessionsSpawnConfigOverride; + config?: SessionsSpawnConfigOverride; runId: string; callId: string; expectedModel: string; }) { resetSubagentRegistryForTests(); callGatewayMock.mockReset(); - setSessionsSpawnConfigOverride(params.config); + if (params.config) { + setSessionsSpawnConfigOverride(params.config); + } else { + resetSessionsSpawnConfigOverride(); + } const calls: GatewayCall[] = []; mockPatchAndSingleAgentRun({ calls, runId: params.runId }); @@ -198,60 +202,22 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { }); it("sessions_spawn applies default subagent model from defaults config", async () => { - resetSubagentRegistryForTests(); - callGatewayMock.mockReset(); - setSessionsSpawnConfigOverride({ - session: { mainKey: "main", scope: "per-sender" }, - agents: { defaults: { subagents: { model: "minimax/MiniMax-M2.1" } } }, - }); - const calls: GatewayCall[] = []; - mockPatchAndSingleAgentRun({ calls, runId: "run-default-model" }); - - const tool = await getSessionsSpawnTool({ - agentSessionKey: "agent:main:main", - agentChannel: "discord", - }); - - const result = await tool.execute("call-default-model", { - task: "do thing", - }); - expect(result.details).toMatchObject({ - status: "accepted", - modelApplied: true, - }); - - const patchCall = calls.find( - (call) => call.method === "sessions.patch" && (call.params as { model?: string })?.model, - ); - expect(patchCall?.params).toMatchObject({ - model: "minimax/MiniMax-M2.1", + await expectSpawnUsesConfiguredModel({ + config: { + session: { mainKey: "main", scope: "per-sender" }, + agents: { defaults: { subagents: { model: "minimax/MiniMax-M2.1" } } }, + }, + runId: "run-default-model", + callId: "call-default-model", + expectedModel: "minimax/MiniMax-M2.1", }); }); it("sessions_spawn falls back to runtime default model when no model config is set", async () => { - resetSubagentRegistryForTests(); - callGatewayMock.mockReset(); - const calls: GatewayCall[] = []; - mockPatchAndSingleAgentRun({ calls, runId: "run-runtime-default-model" }); - - const tool = await getSessionsSpawnTool({ - agentSessionKey: "agent:main:main", - agentChannel: "discord", - }); - - const result = await tool.execute("call-runtime-default-model", { - task: "do thing", - }); - expect(result.details).toMatchObject({ - status: "accepted", - modelApplied: true, - }); - - const patchCall = calls.find( - (call) => call.method === "sessions.patch" && (call.params as { model?: string })?.model, - ); - expect(patchCall?.params).toMatchObject({ - model: `${DEFAULT_PROVIDER}/${DEFAULT_MODEL}`, + await expectSpawnUsesConfiguredModel({ + runId: "run-runtime-default-model", + callId: "call-runtime-default-model", + expectedModel: `${DEFAULT_PROVIDER}/${DEFAULT_MODEL}`, }); }); From 57ea6feb038231604a521ed3757d93d580d4a2eb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:44:40 +0000 Subject: [PATCH 0032/2904] test(gateway): dedupe startup auth override token checks --- src/gateway/startup-auth.test.ts | 57 ++++++++++---------------------- 1 file changed, 18 insertions(+), 39 deletions(-) diff --git a/src/gateway/startup-auth.test.ts b/src/gateway/startup-auth.test.ts index 4cd10946550..cbde1431ecb 100644 --- a/src/gateway/startup-auth.test.ts +++ b/src/gateway/startup-auth.test.ts @@ -16,6 +16,21 @@ vi.mock("../config/config.js", async (importOriginal) => { import { ensureGatewayStartupAuth } from "./startup-auth.js"; describe("ensureGatewayStartupAuth", () => { + async function expectEphemeralGeneratedTokenWhenOverridden(cfg: OpenClawConfig) { + const result = await ensureGatewayStartupAuth({ + cfg, + env: {} as NodeJS.ProcessEnv, + authOverride: { mode: "token" }, + persist: true, + }); + + expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/); + expect(result.persistedGeneratedToken).toBe(false); + expect(result.auth.mode).toBe("token"); + expect(result.auth.token).toBe(result.generatedToken); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + } + beforeEach(() => { vi.restoreAllMocks(); mocks.writeConfigFile.mockReset(); @@ -145,68 +160,32 @@ describe("ensureGatewayStartupAuth", () => { }); it("keeps generated token ephemeral when runtime override flips explicit non-token mode", async () => { - const cfg: OpenClawConfig = { + await expectEphemeralGeneratedTokenWhenOverridden({ gateway: { auth: { mode: "password", }, }, - }; - const result = await ensureGatewayStartupAuth({ - cfg, - env: {} as NodeJS.ProcessEnv, - authOverride: { mode: "token" }, - persist: true, }); - - expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/); - expect(result.persistedGeneratedToken).toBe(false); - expect(result.auth.mode).toBe("token"); - expect(result.auth.token).toBe(result.generatedToken); - expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); it("keeps generated token ephemeral when runtime override flips explicit none mode", async () => { - const cfg: OpenClawConfig = { + await expectEphemeralGeneratedTokenWhenOverridden({ gateway: { auth: { mode: "none", }, }, - }; - const result = await ensureGatewayStartupAuth({ - cfg, - env: {} as NodeJS.ProcessEnv, - authOverride: { mode: "token" }, - persist: true, }); - - expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/); - expect(result.persistedGeneratedToken).toBe(false); - expect(result.auth.mode).toBe("token"); - expect(result.auth.token).toBe(result.generatedToken); - expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); it("keeps generated token ephemeral when runtime override flips implicit password mode", async () => { - const cfg: OpenClawConfig = { + await expectEphemeralGeneratedTokenWhenOverridden({ gateway: { auth: { password: "configured-password", }, }, - }; - const result = await ensureGatewayStartupAuth({ - cfg, - env: {} as NodeJS.ProcessEnv, - authOverride: { mode: "token" }, - persist: true, }); - - expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/); - expect(result.persistedGeneratedToken).toBe(false); - expect(result.auth.mode).toBe("token"); - expect(result.auth.token).toBe(result.generatedToken); - expect(mocks.writeConfigFile).not.toHaveBeenCalled(); }); }); From 8d7df30ee050882d9ec4ee87092f47109e3588f5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:47:10 +0000 Subject: [PATCH 0033/2904] test: remove duplicate target-resolution cases from outbound suite --- src/infra/outbound/outbound.test.ts | 107 +--------------------------- 1 file changed, 1 insertion(+), 106 deletions(-) diff --git a/src/infra/outbound/outbound.test.ts b/src/infra/outbound/outbound.test.ts index 8095da3c6b2..7b788363542 100644 --- a/src/infra/outbound/outbound.test.ts +++ b/src/infra/outbound/outbound.test.ts @@ -36,7 +36,7 @@ import { normalizeOutboundPayloads, normalizeOutboundPayloadsForJson, } from "./payloads.js"; -import { resolveOutboundTarget, resolveSessionDeliveryTarget } from "./targets.js"; +import { resolveOutboundTarget } from "./targets.js"; describe("delivery-queue", () => { let tmpDir: string; @@ -981,108 +981,3 @@ describe("resolveOutboundTarget", () => { } }); }); - -describe("resolveSessionDeliveryTarget", () => { - it("derives implicit delivery from the last route", () => { - const resolved = resolveSessionDeliveryTarget({ - entry: { - sessionId: "sess-1", - updatedAt: 1, - lastChannel: " whatsapp ", - lastTo: " +1555 ", - lastAccountId: " acct-1 ", - }, - requestedChannel: "last", - }); - - expect(resolved).toEqual({ - channel: "whatsapp", - to: "+1555", - accountId: "acct-1", - threadId: undefined, - threadIdExplicit: false, - mode: "implicit", - lastChannel: "whatsapp", - lastTo: "+1555", - lastAccountId: "acct-1", - lastThreadId: undefined, - }); - }); - - it("prefers explicit targets without reusing lastTo", () => { - const resolved = resolveSessionDeliveryTarget({ - entry: { - sessionId: "sess-2", - updatedAt: 1, - lastChannel: "whatsapp", - lastTo: "+1555", - }, - requestedChannel: "telegram", - }); - - expect(resolved).toEqual({ - channel: "telegram", - to: undefined, - accountId: undefined, - threadId: undefined, - threadIdExplicit: false, - mode: "implicit", - lastChannel: "whatsapp", - lastTo: "+1555", - lastAccountId: undefined, - lastThreadId: undefined, - }); - }); - - it("allows mismatched lastTo when configured", () => { - const resolved = resolveSessionDeliveryTarget({ - entry: { - sessionId: "sess-3", - updatedAt: 1, - lastChannel: "whatsapp", - lastTo: "+1555", - }, - requestedChannel: "telegram", - allowMismatchedLastTo: true, - }); - - expect(resolved).toEqual({ - channel: "telegram", - to: "+1555", - accountId: undefined, - threadId: undefined, - threadIdExplicit: false, - mode: "implicit", - lastChannel: "whatsapp", - lastTo: "+1555", - lastAccountId: undefined, - lastThreadId: undefined, - }); - }); - - it("falls back to a provided channel when requested is unsupported", () => { - const resolved = resolveSessionDeliveryTarget({ - entry: { - sessionId: "sess-4", - updatedAt: 1, - lastChannel: "whatsapp", - lastTo: "+1555", - }, - requestedChannel: "webchat", - fallbackChannel: "slack", - }); - - expect(resolved).toEqual({ - channel: "slack", - to: undefined, - accountId: undefined, - threadId: undefined, - threadIdExplicit: false, - mode: "implicit", - lastChannel: "whatsapp", - lastTo: "+1555", - lastAccountId: undefined, - lastThreadId: undefined, - }); - }); -}); From 9bd2261c0f10768ad91977c7591777e2b3ff737a Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Thu, 19 Feb 2026 15:48:46 +0800 Subject: [PATCH 0034/2904] fix(ios): auto-generate local signing overrides (#20716) --- scripts/ios-configure-signing.sh | 61 ++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/scripts/ios-configure-signing.sh b/scripts/ios-configure-signing.sh index ef891632c1b..99219725fe7 100755 --- a/scripts/ios-configure-signing.sh +++ b/scripts/ios-configure-signing.sh @@ -6,6 +6,26 @@ IOS_DIR="${ROOT_DIR}/apps/ios" TEAM_ID_SCRIPT="${ROOT_DIR}/scripts/ios-team-id.sh" LOCAL_SIGNING_FILE="${IOS_DIR}/.local-signing.xcconfig" +sanitize_identifier_segment() { + local raw="${1:-}" + raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" + raw="$(printf '%s' "$raw" | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-+/-/g')" + if [[ -z "$raw" ]]; then + raw="local" + fi + printf '%s\n' "$raw" +} + +normalize_bundle_id() { + local raw="${1:-}" + raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')" + raw="$(printf '%s' "$raw" | sed -E 's/[^a-z0-9.-]+/-/g; s/\.+/./g; s/^-+//; s/[.-]+$//')" + if [[ -z "$raw" ]]; then + raw="ai.openclaw.ios.test.local" + fi + printf '%s\n' "$raw" +} + if [[ ! -x "${TEAM_ID_SCRIPT}" ]]; then echo "ERROR: Missing team detection helper: ${TEAM_ID_SCRIPT}" >&2 exit 1 @@ -24,20 +44,57 @@ else exit 0 fi +if [[ -n "${OPENCLAW_IOS_BUNDLE_SUFFIX:-}" ]]; then + identity_source="${OPENCLAW_IOS_BUNDLE_SUFFIX}" +else + identity_source="${USER:-}" + if [[ -z "${identity_source}" ]]; then + identity_source="$(id -un 2>/dev/null || true)" + fi + team_segment="$(sanitize_identifier_segment "${team_id}")" + identity_source="${identity_source}-${team_segment}" +fi +bundle_suffix="$(sanitize_identifier_segment "${identity_source}")" + +bundle_base="${OPENCLAW_IOS_APP_BUNDLE_ID:-${OPENCLAW_IOS_BUNDLE_ID_BASE:-}}" +if [[ -z "${bundle_base}" ]]; then + bundle_base="ai.openclaw.ios.test.${bundle_suffix}" +fi +bundle_base="$(normalize_bundle_id "${bundle_base}")" + +share_bundle_id="${OPENCLAW_IOS_SHARE_BUNDLE_ID:-${bundle_base}.share}" +watch_app_bundle_id="${OPENCLAW_IOS_WATCH_APP_BUNDLE_ID:-${bundle_base}.watchkitapp}" +watch_extension_bundle_id="${OPENCLAW_IOS_WATCH_EXTENSION_BUNDLE_ID:-${watch_app_bundle_id}.extension}" + +code_sign_style="${OPENCLAW_IOS_CODE_SIGN_STYLE:-Automatic}" +app_profile="${OPENCLAW_IOS_APP_PROFILE:-}" +share_profile="${OPENCLAW_IOS_SHARE_PROFILE:-}" + tmp_file="$(mktemp "${TMPDIR:-/tmp}/openclaw-ios-signing.XXXXXX")" cat >"${tmp_file}" < Date: Thu, 19 Feb 2026 07:49:43 +0000 Subject: [PATCH 0035/2904] test(shell-env): dedupe repeated login-shell path lookups --- src/infra/shell-env.test.ts | 41 ++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/src/infra/shell-env.test.ts b/src/infra/shell-env.test.ts index 0869dfec4f9..4fcb41b538a 100644 --- a/src/infra/shell-env.test.ts +++ b/src/infra/shell-env.test.ts @@ -8,6 +8,23 @@ import { } from "./shell-env.js"; describe("shell env fallback", () => { + function getShellPathTwice(params: { + exec: Parameters[0]["exec"]; + platform: NodeJS.Platform; + }) { + const first = getShellPathFromLoginShell({ + env: {} as NodeJS.ProcessEnv, + exec: params.exec, + platform: params.platform, + }); + const second = getShellPathFromLoginShell({ + env: {} as NodeJS.ProcessEnv, + exec: params.exec, + platform: params.platform, + }); + return { first, second }; + } + it("is disabled by default", () => { expect(shouldEnableShellEnvFallback({} as NodeJS.ProcessEnv)).toBe(false); expect(shouldEnableShellEnvFallback({ OPENCLAW_LOAD_SHELL_ENV: "0" })).toBe(false); @@ -78,13 +95,7 @@ describe("shell env fallback", () => { resetShellPathCacheForTests(); const exec = vi.fn(() => Buffer.from("PATH=/usr/local/bin:/usr/bin\0HOME=/tmp\0")); - const first = getShellPathFromLoginShell({ - env: {} as NodeJS.ProcessEnv, - exec: exec as unknown as Parameters[0]["exec"], - platform: "linux", - }); - const second = getShellPathFromLoginShell({ - env: {} as NodeJS.ProcessEnv, + const { first, second } = getShellPathTwice({ exec: exec as unknown as Parameters[0]["exec"], platform: "linux", }); @@ -100,13 +111,7 @@ describe("shell env fallback", () => { throw new Error("exec failed"); }); - const first = getShellPathFromLoginShell({ - env: {} as NodeJS.ProcessEnv, - exec: exec as unknown as Parameters[0]["exec"], - platform: "linux", - }); - const second = getShellPathFromLoginShell({ - env: {} as NodeJS.ProcessEnv, + const { first, second } = getShellPathTwice({ exec: exec as unknown as Parameters[0]["exec"], platform: "linux", }); @@ -120,13 +125,7 @@ describe("shell env fallback", () => { resetShellPathCacheForTests(); const exec = vi.fn(() => Buffer.from("PATH=/usr/local/bin:/usr/bin\0HOME=/tmp\0")); - const first = getShellPathFromLoginShell({ - env: {} as NodeJS.ProcessEnv, - exec: exec as unknown as Parameters[0]["exec"], - platform: "win32", - }); - const second = getShellPathFromLoginShell({ - env: {} as NodeJS.ProcessEnv, + const { first, second } = getShellPathTwice({ exec: exec as unknown as Parameters[0]["exec"], platform: "win32", }); From bbb07bdc1906e5d2aeed57fce427fc778843353f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:49:48 +0000 Subject: [PATCH 0036/2904] test(media): dedupe active-model fallback resolver setup --- src/media-understanding/resolve.test.ts | 29 +++++++++++++++++-------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/media-understanding/resolve.test.ts b/src/media-understanding/resolve.test.ts index 3f7b21c52cc..90dba89cbf8 100644 --- a/src/media-understanding/resolve.test.ts +++ b/src/media-understanding/resolve.test.ts @@ -72,6 +72,23 @@ describe("resolveModelEntries", () => { }); describe("resolveEntriesWithActiveFallback", () => { + type ResolveWithFallbackInput = Parameters[0]; + const defaultActiveModel = { provider: "groq", model: "whisper-large-v3" } as const; + + function resolveWithActiveFallback(params: { + cfg: ResolveWithFallbackInput["cfg"]; + capability: ResolveWithFallbackInput["capability"]; + config: ResolveWithFallbackInput["config"]; + }) { + return resolveEntriesWithActiveFallback({ + cfg: params.cfg, + capability: params.capability, + config: params.config, + providerRegistry, + activeModel: defaultActiveModel, + }); + } + it("uses active model when enabled and no models are configured", () => { const cfg: OpenClawConfig = { tools: { @@ -81,12 +98,10 @@ describe("resolveEntriesWithActiveFallback", () => { }, }; - const entries = resolveEntriesWithActiveFallback({ + const entries = resolveWithActiveFallback({ cfg, capability: "audio", config: cfg.tools?.media?.audio, - providerRegistry, - activeModel: { provider: "groq", model: "whisper-large-v3" }, }); expect(entries).toHaveLength(1); expect(entries[0]?.provider).toBe("groq"); @@ -101,12 +116,10 @@ describe("resolveEntriesWithActiveFallback", () => { }, }; - const entries = resolveEntriesWithActiveFallback({ + const entries = resolveWithActiveFallback({ cfg, capability: "audio", config: cfg.tools?.media?.audio, - providerRegistry, - activeModel: { provider: "groq", model: "whisper-large-v3" }, }); expect(entries).toHaveLength(1); expect(entries[0]?.provider).toBe("openai"); @@ -121,12 +134,10 @@ describe("resolveEntriesWithActiveFallback", () => { }, }; - const entries = resolveEntriesWithActiveFallback({ + const entries = resolveWithActiveFallback({ cfg, capability: "video", config: cfg.tools?.media?.video, - providerRegistry, - activeModel: { provider: "groq", model: "whisper-large-v3" }, }); expect(entries).toHaveLength(0); }); From 18d4ad6aabd27cad7a781da5e0ea7ce2545d0678 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:50:19 +0000 Subject: [PATCH 0037/2904] test: trim duplicate cross-context policy cases --- src/infra/outbound/outbound.test.ts | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/src/infra/outbound/outbound.test.ts b/src/infra/outbound/outbound.test.ts index 7b788363542..799f0fe7121 100644 --- a/src/infra/outbound/outbound.test.ts +++ b/src/infra/outbound/outbound.test.ts @@ -621,18 +621,6 @@ const discordConfig = { } as OpenClawConfig; describe("outbound policy", () => { - it("blocks cross-provider sends by default", () => { - expect(() => - enforceCrossContextPolicy({ - cfg: slackConfig, - channel: "telegram", - action: "send", - args: { to: "telegram:@ops" }, - toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, - }), - ).toThrow(/Cross-context messaging denied/); - }); - it("allows cross-provider sends when enabled", () => { const cfg = { ...slackConfig, @@ -652,23 +640,6 @@ describe("outbound policy", () => { ).not.toThrow(); }); - it("blocks same-provider cross-context when disabled", () => { - const cfg = { - ...slackConfig, - tools: { message: { crossContext: { allowWithinProvider: false } } }, - } as OpenClawConfig; - - expect(() => - enforceCrossContextPolicy({ - cfg, - channel: "slack", - action: "send", - args: { to: "C99999999" }, - toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, - }), - ).toThrow(/Cross-context messaging denied/); - }); - it("uses components when available and preferred", async () => { const decoration = await buildCrossContextDecoration({ cfg: discordConfig, From a82a41236e7cb5d7dd3bd203aa880f2cda4c2ae9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:52:27 +0000 Subject: [PATCH 0038/2904] test(web): dedupe creds-update trigger helper in session tests --- src/web/session.test.ts | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/web/session.test.ts b/src/web/session.test.ts index b1e9b1b716f..0bf8fefc040 100644 --- a/src/web/session.test.ts +++ b/src/web/session.test.ts @@ -9,6 +9,18 @@ const { createWaSocket, formatError, logWebSelfId, waitForWaConnection } = await import("./session.js"); const useMultiFileAuthStateMock = vi.mocked(baileys.useMultiFileAuthState); +async function flushCredsUpdate() { + await new Promise((resolve) => setImmediate(resolve)); +} + +async function emitCredsUpdateAndReadSaveCreds() { + const sock = getLastSocket(); + const saveCreds = (await useMultiFileAuthStateMock.mock.results[0]?.value)?.saveCreds; + sock.ev.emit("creds.update", {}); + await flushCredsUpdate(); + return saveCreds; +} + function mockCredsJsonSpies(readContents: string) { const credsSuffix = path.join(".openclaw", "credentials", "whatsapp", "default", "creds.json"); const copySpy = vi.spyOn(fsSync, "copyFileSync").mockImplementation(() => {}); @@ -69,7 +81,7 @@ describe("web session", () => { const saveCreds = (await useMultiFileAuthStateMock.mock.results[0]?.value)?.saveCreds; // trigger creds.update listener sock.ev.emit("creds.update", {}); - await new Promise((resolve) => setImmediate(resolve)); + await flushCredsUpdate(); expect(saveCreds).toHaveBeenCalled(); }); @@ -145,11 +157,7 @@ describe("web session", () => { const creds = mockCredsJsonSpies("{"); await createWaSocket(false, false); - const sock = getLastSocket(); - const saveCreds = (await useMultiFileAuthStateMock.mock.results[0]?.value)?.saveCreds; - - sock.ev.emit("creds.update", {}); - await new Promise((resolve) => setImmediate(resolve)); + const saveCreds = await emitCredsUpdateAndReadSaveCreds(); expect(creds.copySpy).not.toHaveBeenCalled(); expect(saveCreds).toHaveBeenCalled(); @@ -182,14 +190,14 @@ describe("web session", () => { sock.ev.emit("creds.update", {}); sock.ev.emit("creds.update", {}); - await new Promise((resolve) => setImmediate(resolve)); + await flushCredsUpdate(); expect(inFlight).toBe(1); (release as (() => void) | null)?.(); // let both queued saves complete - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); + await flushCredsUpdate(); + await flushCredsUpdate(); expect(saveCreds).toHaveBeenCalledTimes(2); expect(maxInFlight).toBe(1); @@ -207,11 +215,7 @@ describe("web session", () => { ); await createWaSocket(false, false); - const sock = getLastSocket(); - const saveCreds = (await useMultiFileAuthStateMock.mock.results[0]?.value)?.saveCreds; - - sock.ev.emit("creds.update", {}); - await new Promise((resolve) => setImmediate(resolve)); + const saveCreds = await emitCredsUpdateAndReadSaveCreds(); expect(creds.copySpy).toHaveBeenCalledTimes(1); const args = creds.copySpy.mock.calls[0] ?? []; From 9a490fbbeb383dc3af15144c4589091fcacbf94f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:57:24 +0000 Subject: [PATCH 0039/2904] test: drop duplicate followup compaction token assertion --- src/auto-reply/reply/followup-runner.test.ts | 55 -------------------- 1 file changed, 55 deletions(-) diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 824e4d2fdf7..a5add85416b 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -145,61 +145,6 @@ describe("createFollowupRunner compaction", () => { expect(firstCall?.[0]?.text).toContain("Auto-compaction complete"); expect(sessionStore.main.compactionCount).toBe(1); }); - - it("updates totalTokens after auto-compaction using lastCallUsage", async () => { - const storePath = path.join( - await fs.mkdtemp(path.join(tmpdir(), "openclaw-followup-compaction-")), - "sessions.json", - ); - const sessionKey = "main"; - const sessionEntry: SessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - totalTokens: 180_000, - compactionCount: 0, - }; - const sessionStore: Record = { [sessionKey]: sessionEntry }; - await saveSessionStore(storePath, sessionStore); - const onBlockReply = vi.fn(async () => {}); - - mockCompactionRun({ - willRetry: false, - result: { - payloads: [{ text: "done" }], - meta: { - agentMeta: { - // Accumulated usage across pre+post compaction calls. - usage: { input: 190_000, output: 8_000, total: 198_000 }, - // Last call usage reflects post-compaction context. - lastCallUsage: { input: 11_000, output: 2_000, total: 13_000 }, - model: "claude-opus-4-5", - provider: "anthropic", - }, - }, - }, - }); - - const runner = createFollowupRunner({ - opts: { onBlockReply }, - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry, - sessionStore, - sessionKey, - storePath, - defaultModel: "anthropic/claude-opus-4-5", - agentCfgContextTokens: 200_000, - }); - - await runner(baseQueuedRun()); - - const store = loadSessionStore(storePath, { skipCache: true }); - expect(store[sessionKey]?.compactionCount).toBe(1); - expect(store[sessionKey]?.totalTokens).toBe(11_000); - // We only keep the total estimate after compaction. - expect(store[sessionKey]?.inputTokens).toBeUndefined(); - expect(store[sessionKey]?.outputTokens).toBeUndefined(); - }); }); describe("createFollowupRunner messaging tool dedupe", () => { From 5f2bcfc4d2e5cde29fbe0da2ce600f2b8355a8c2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:58:48 +0000 Subject: [PATCH 0040/2904] ci: skip bun bootstrap in check and docs-check jobs --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e8a797ce74..7d4e0712187 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -244,6 +244,8 @@ jobs: - name: Setup Node environment uses: ./.github/actions/setup-node-env + with: + install-bun: "false" - name: Check types and lint and oxfmt run: pnpm check @@ -261,6 +263,8 @@ jobs: - name: Setup Node environment uses: ./.github/actions/setup-node-env + with: + install-bun: "false" - name: Check docs run: pnpm check:docs From b97b8908b9ce1928044035fcfbc04095a1002021 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 07:59:47 +0000 Subject: [PATCH 0041/2904] test: remove duplicate telegram .co link formatting case --- src/telegram/format.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/telegram/format.test.ts b/src/telegram/format.test.ts index 6b0e1944f70..dd872374440 100644 --- a/src/telegram/format.test.ts +++ b/src/telegram/format.test.ts @@ -101,12 +101,6 @@ describe("markdownToTelegramHtml", () => { expect(res).toContain("(backup.sh)."); }); - it("keeps .co domains as links", () => { - const res = markdownToTelegramHtml("Visit t.co and openclaw.co"); - expect(res).toContain('t.co'); - expect(res).toContain('openclaw.co'); - }); - it("renders spoiler tags", () => { const res = markdownToTelegramHtml("the answer is ||42||"); expect(res).toBe("the answer is 42"); From 2cbf15eb6609e768ab65bd0194abbda2a1c49eb8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:04:13 +0000 Subject: [PATCH 0042/2904] ci: pin bun setup version to avoid API rate-limit flakes --- .github/actions/setup-node-env/action.yml | 2 +- .github/workflows/ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/setup-node-env/action.yml b/.github/actions/setup-node-env/action.yml index 5fa4f6728bc..d21bc987e25 100644 --- a/.github/actions/setup-node-env/action.yml +++ b/.github/actions/setup-node-env/action.yml @@ -52,7 +52,7 @@ runs: if: inputs.install-bun == 'true' uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: "1.3.9" - name: Runtime versions shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d4e0712187..7d38391db2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -373,7 +373,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: "1.3.9" - name: Runtime versions run: | From 221d50bc1802d814c794b2469d06d7c87a531211 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 19 Feb 2026 10:45:06 +0530 Subject: [PATCH 0043/2904] fix: preserve assistant partial stream during reasoning --- ...pi-embedded-subscribe.handlers.messages.ts | 24 +++++++++ ...ion.subscribeembeddedpisession.e2e.test.ts | 50 +++++++++++++++++++ .../reply/agent-runner-execution.ts | 28 ++++------- .../reply/agent-runner.runreplyagent.test.ts | 21 ++++++++ 4 files changed, 105 insertions(+), 18 deletions(-) diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index 5ce166117bb..9daf724803d 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -82,6 +82,30 @@ export function handleMessageUpdate( : undefined; const evtType = typeof assistantRecord?.type === "string" ? assistantRecord.type : ""; + if (evtType === "thinking_start" || evtType === "thinking_delta" || evtType === "thinking_end") { + const thinkingDelta = typeof assistantRecord?.delta === "string" ? assistantRecord.delta : ""; + const thinkingContent = + typeof assistantRecord?.content === "string" ? assistantRecord.content : ""; + appendRawStream({ + ts: Date.now(), + event: "assistant_thinking_stream", + runId: ctx.params.runId, + sessionId: (ctx.params.session as { id?: string }).id, + evtType, + delta: thinkingDelta, + content: thinkingContent, + }); + if (ctx.state.streamReasoning) { + // Prefer full partial-message thinking when available; fall back to event payloads. + const partialThinking = extractAssistantThinking(msg); + ctx.emitReasoningStream(partialThinking || thinkingContent || thinkingDelta); + } + if (evtType === "thinking_end") { + void ctx.params.onReasoningEnd?.(); + } + return; + } + if (evtType !== "text_delta" && evtType !== "text_start" && evtType !== "text_end") { return; } diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts index 27f6014e643..0bee38b1330 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts @@ -201,6 +201,56 @@ describe("subscribeEmbeddedPiSession", () => { }, ); + it("streams native thinking_delta events and signals reasoning end", () => { + let handler: ((evt: unknown) => void) | undefined; + const session: StubSession = { + subscribe: (fn) => { + handler = fn; + return () => {}; + }, + }; + + const onReasoningStream = vi.fn(); + const onReasoningEnd = vi.fn(); + + subscribeEmbeddedPiSession({ + session: session as unknown as Parameters[0]["session"], + runId: "run", + reasoningMode: "stream", + onReasoningStream, + onReasoningEnd, + }); + + handler?.({ + type: "message_update", + message: { + role: "assistant", + content: [{ type: "thinking", thinking: "Checking files" }], + }, + assistantMessageEvent: { + type: "thinking_delta", + delta: "Checking files", + }, + }); + + handler?.({ + type: "message_update", + message: { + role: "assistant", + content: [{ type: "thinking", thinking: "Checking files done" }], + }, + assistantMessageEvent: { + type: "thinking_end", + }, + }); + + const streamTexts = onReasoningStream.mock.calls + .map((call) => call[0]?.text) + .filter((value): value is string => typeof value === "string"); + expect(streamTexts.at(-1)).toBe("Reasoning:\n_Checking files done_"); + expect(onReasoningEnd).toHaveBeenCalledTimes(1); + }); + it("emits delta chunks in agent events for streaming assistant text", () => { const { emit, onAgentEvent } = createAgentEventHarness(); diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index e48155aa374..92c97d829ec 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -104,13 +104,7 @@ export async function runAgentTurnWithFallback(params: { while (true) { try { - const allowPartialStream = !( - params.followupRun.run.reasoningLevel === "stream" && params.opts?.onReasoningStream - ); const normalizeStreamingText = (payload: ReplyPayload): { text?: string; skip: boolean } => { - if (!allowPartialStream) { - return { skip: true }; - } let text = payload.text; if (!params.isHeartbeat && text?.includes("HEARTBEAT_OK")) { const stripped = stripHeartbeatToken(text, { @@ -290,18 +284,16 @@ export async function runAgentTurnWithFallback(params: { abortSignal: params.opts?.abortSignal, blockReplyBreak: params.resolvedBlockStreamingBreak, blockReplyChunking: params.blockReplyChunking, - onPartialReply: allowPartialStream - ? async (payload) => { - const textForTyping = await handlePartialForTyping(payload); - if (!params.opts?.onPartialReply || textForTyping === undefined) { - return; - } - await params.opts.onPartialReply({ - text: textForTyping, - mediaUrls: payload.mediaUrls, - }); - } - : undefined, + onPartialReply: async (payload) => { + const textForTyping = await handlePartialForTyping(payload); + if (!params.opts?.onPartialReply || textForTyping === undefined) { + return; + } + await params.opts.onPartialReply({ + text: textForTyping, + mediaUrls: payload.mediaUrls, + }); + }, onAssistantMessageStart: async () => { await params.typingSignals.signalMessageStart(); await params.opts?.onAssistantMessageStart?.(); diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts index 7ad7e165dc7..0263c8a15fb 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.test.ts @@ -91,6 +91,7 @@ function createMinimalRun(params?: { storePath?: string; typingMode?: TypingMode; blockStreamingEnabled?: boolean; + runOverrides?: Partial; }) { const typing = createMockTypingController(); const opts = params?.opts; @@ -124,6 +125,7 @@ function createMinimalRun(params?: { }, timeoutMs: 1_000, blockReplyBreak: "message_end", + ...params?.runOverrides, }, } as unknown as FollowupRun; @@ -411,6 +413,25 @@ describe("runReplyAgent typing (heartbeat)", () => { expect(typing.startTypingOnText).not.toHaveBeenCalled(); }); + it("keeps assistant partial streaming enabled when reasoning mode is stream", async () => { + const onPartialReply = vi.fn(); + const onReasoningStream = vi.fn(); + state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { + await params.onReasoningStream?.({ text: "Reasoning:\n_step_" }); + await params.onPartialReply?.({ text: "answer chunk" }); + return { payloads: [{ text: "final" }], meta: {} }; + }); + + const { run } = createMinimalRun({ + opts: { onPartialReply, onReasoningStream }, + runOverrides: { reasoningLevel: "stream" }, + }); + await run(); + + expect(onReasoningStream).toHaveBeenCalled(); + expect(onPartialReply).toHaveBeenCalledWith({ text: "answer chunk", mediaUrls: undefined }); + }); + it("suppresses typing in never mode", async () => { state.runEmbeddedPiAgentMock.mockImplementationOnce(async (params: AgentRunParams) => { await params.onPartialReply?.({ text: "hi" }); From 0ff506140db2bae3a38c04ca48c9e9b29e2edf78 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Wed, 18 Feb 2026 23:33:55 -0800 Subject: [PATCH 0044/2904] fix: clear matched tool errors and dedupe reasoning end --- ...pi-embedded-subscribe.handlers.messages.ts | 22 +++++++- .../pi-embedded-subscribe.handlers.types.ts | 1 + ...ion.subscribeembeddedpisession.e2e.test.ts | 53 +++++++++++++++++++ src/agents/pi-embedded-subscribe.ts | 2 + src/agents/tool-mutation.test.ts | 6 ++- src/agents/tool-mutation.ts | 7 ++- 6 files changed, 87 insertions(+), 4 deletions(-) diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index 9daf724803d..9aa445a1ab6 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -30,6 +30,14 @@ const stripTrailingDirective = (text: string): string => { return text.slice(0, openIndex); }; +function emitReasoningEnd(ctx: EmbeddedPiSubscribeContext) { + if (!ctx.state.reasoningStreamOpen) { + return; + } + ctx.state.reasoningStreamOpen = false; + void ctx.params.onReasoningEnd?.(); +} + export function resolveSilentReplyFallbackText(params: { text: string; messagingToolSentTexts: string[]; @@ -83,6 +91,9 @@ export function handleMessageUpdate( const evtType = typeof assistantRecord?.type === "string" ? assistantRecord.type : ""; if (evtType === "thinking_start" || evtType === "thinking_delta" || evtType === "thinking_end") { + if (evtType === "thinking_start" || evtType === "thinking_delta") { + ctx.state.reasoningStreamOpen = true; + } const thinkingDelta = typeof assistantRecord?.delta === "string" ? assistantRecord.delta : ""; const thinkingContent = typeof assistantRecord?.content === "string" ? assistantRecord.content : ""; @@ -101,7 +112,10 @@ export function handleMessageUpdate( ctx.emitReasoningStream(partialThinking || thinkingContent || thinkingDelta); } if (evtType === "thinking_end") { - void ctx.params.onReasoningEnd?.(); + if (!ctx.state.reasoningStreamOpen) { + ctx.state.reasoningStreamOpen = true; + } + emitReasoningEnd(ctx); } return; } @@ -166,9 +180,12 @@ export function handleMessageUpdate( if (next) { const wasThinking = ctx.state.partialBlockState.thinking; const visibleDelta = chunk ? ctx.stripBlockTags(chunk, ctx.state.partialBlockState) : ""; + if (!wasThinking && ctx.state.partialBlockState.thinking) { + ctx.state.reasoningStreamOpen = true; + } // Detect when thinking block ends ( tag processed) if (wasThinking && !ctx.state.partialBlockState.thinking) { - void ctx.params.onReasoningEnd?.(); + emitReasoningEnd(ctx); } const parsedDelta = visibleDelta ? ctx.consumePartialReplyDirectives(visibleDelta) : null; const parsedFull = parseReplyDirectives(stripTrailingDirective(next)); @@ -414,4 +431,5 @@ export function handleMessageEnd( ctx.state.blockState.inlineCode = createInlineCodeState(); ctx.state.lastStreamedAssistant = undefined; ctx.state.lastStreamedAssistantCleaned = undefined; + ctx.state.reasoningStreamOpen = false; } diff --git a/src/agents/pi-embedded-subscribe.handlers.types.ts b/src/agents/pi-embedded-subscribe.handlers.types.ts index 435325601d9..d5c725528c8 100644 --- a/src/agents/pi-embedded-subscribe.handlers.types.ts +++ b/src/agents/pi-embedded-subscribe.handlers.types.ts @@ -52,6 +52,7 @@ export type EmbeddedPiSubscribeState = { emittedAssistantUpdate: boolean; lastStreamedReasoning?: string; lastBlockReplyText?: string; + reasoningStreamOpen: boolean; assistantMessageIndex: number; lastAssistantTextMessageIndex: number; lastAssistantTextNormalized?: string; diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts index 0bee38b1330..b91857c2d76 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.e2e.test.ts @@ -251,6 +251,59 @@ describe("subscribeEmbeddedPiSession", () => { expect(onReasoningEnd).toHaveBeenCalledTimes(1); }); + it("emits reasoning end once when native and tagged reasoning end overlap", () => { + let handler: ((evt: unknown) => void) | undefined; + const session: StubSession = { + subscribe: (fn) => { + handler = fn; + return () => {}; + }, + }; + + const onReasoningEnd = vi.fn(); + + subscribeEmbeddedPiSession({ + session: session as unknown as Parameters[0]["session"], + runId: "run", + reasoningMode: "stream", + onReasoningStream: vi.fn(), + onReasoningEnd, + }); + + handler?.({ type: "message_start", message: { role: "assistant" } }); + + handler?.({ + type: "message_update", + message: { role: "assistant" }, + assistantMessageEvent: { + type: "text_delta", + delta: "Checking", + }, + }); + + handler?.({ + type: "message_update", + message: { + role: "assistant", + content: [{ type: "thinking", thinking: "Checking" }], + }, + assistantMessageEvent: { + type: "thinking_end", + }, + }); + + handler?.({ + type: "message_update", + message: { role: "assistant" }, + assistantMessageEvent: { + type: "text_delta", + delta: " files\nFinal answer", + }, + }); + + expect(onReasoningEnd).toHaveBeenCalledTimes(1); + }); + it("emits delta chunks in agent events for streaming assistant text", () => { const { emit, onAgentEvent } = createAgentEventHarness(); diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index 594cc438622..8e7a7fec295 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -55,6 +55,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar emittedAssistantUpdate: false, lastStreamedReasoning: undefined, lastBlockReplyText: undefined, + reasoningStreamOpen: false, assistantMessageIndex: 0, lastAssistantTextMessageIndex: -1, lastAssistantTextNormalized: undefined, @@ -117,6 +118,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar state.lastBlockReplyText = undefined; state.lastStreamedReasoning = undefined; state.lastReasoningSent = undefined; + state.reasoningStreamOpen = false; state.suppressBlockChunks = false; state.assistantMessageIndex += 1; state.lastAssistantTextMessageIndex = -1; diff --git a/src/agents/tool-mutation.test.ts b/src/agents/tool-mutation.test.ts index 3eb417a71b2..ab618f8da5a 100644 --- a/src/agents/tool-mutation.test.ts +++ b/src/agents/tool-mutation.test.ts @@ -27,7 +27,11 @@ describe("tool mutation helpers", () => { expect(writeFingerprint).toContain("tool=write"); expect(writeFingerprint).toContain("path=/tmp/demo.txt"); expect(writeFingerprint).toContain("id=42"); - expect(writeFingerprint).toContain("meta=write /tmp/demo.txt"); + expect(writeFingerprint).not.toContain("meta=write /tmp/demo.txt"); + + const metaOnlyFingerprint = buildToolActionFingerprint("exec", { command: "ls -la" }, "ls -la"); + expect(metaOnlyFingerprint).toContain("tool=exec"); + expect(metaOnlyFingerprint).toContain("meta=ls -la"); const readFingerprint = buildToolActionFingerprint("read", { path: "/tmp/demo.txt" }); expect(readFingerprint).toBeUndefined(); diff --git a/src/agents/tool-mutation.ts b/src/agents/tool-mutation.ts index 22b0e7af9d8..a88bbadfd21 100644 --- a/src/agents/tool-mutation.ts +++ b/src/agents/tool-mutation.ts @@ -151,6 +151,7 @@ export function buildToolActionFingerprint( if (action) { parts.push(`action=${action}`); } + let hasStableTarget = false; for (const key of [ "path", "filePath", @@ -167,10 +168,14 @@ export function buildToolActionFingerprint( const value = normalizeFingerprintValue(record?.[key]); if (value) { parts.push(`${key.toLowerCase()}=${value}`); + hasStableTarget = true; } } const normalizedMeta = meta?.trim().replace(/\s+/g, " ").toLowerCase(); - if (normalizedMeta) { + // Meta text often carries volatile details (for example "N chars"). + // Prefer stable arg-derived keys for matching; only fall back to meta + // when no stable target key is available. + if (normalizedMeta && !hasStableTarget) { parts.push(`meta=${normalizedMeta}`); } return parts.join("|"); From d3dab089d70f4c98b914792fb43ca7d3ee1e4f4d Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Thu, 19 Feb 2026 00:02:48 -0800 Subject: [PATCH 0045/2904] fix: preserve reasoning stream partial contract (#20635) (thanks @obviyus) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2e641c9fe1..5aca8e7a5b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/Streaming: keep assistant partial streaming active during reasoning streams, handle native `thinking_*` stream events consistently, dedupe mixed reasoning-end signals, and clear stale mutating tool errors after same-target retry success. (#20635) Thanks @obviyus. - Gateway/Auth: default unresolved gateway auth to token mode with startup auto-generation/persistence of `gateway.auth.token`, while allowing explicit `gateway.auth.mode: "none"` for intentional open loopback setups. (#20686) thanks @gumadeiras. - Browser/Relay: require gateway-token auth on both `/extension` and `/cdp`, and align Chrome extension setup to use a single `gateway.auth.token` input for relay authentication. Thanks @tdjackey for reporting. - Gateway/Hooks: run BOOT.md startup checks per configured agent scope, including per-agent session-key resolution, startup-hook regression coverage, and non-success boot outcome logging for diagnosability. (#20569) thanks @mcaxtr. From b78fa57401bb5b9429bafa408faf577144277a50 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:11:42 +0000 Subject: [PATCH 0046/2904] test: remove duplicate telegram de-linkify case --- src/telegram/format.wrap-md.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/telegram/format.wrap-md.test.ts b/src/telegram/format.wrap-md.test.ts index 9fc3e386f32..5c82f1ee5a7 100644 --- a/src/telegram/format.wrap-md.test.ts +++ b/src/telegram/format.wrap-md.test.ts @@ -293,12 +293,6 @@ describe("edge cases", () => { expect(result).toContain(" script.py"); }); - it("handles auto-linked anchor with backreference match", () => { - // The regex uses \1 backreference - href must equal label - const input = 'README.md'; - expect(wrapFileReferencesInHtml(input)).toBe("README.md"); - }); - it("preserves anchor when href and label differ (no backreference match)", () => { // Different href and label - should NOT de-linkify const input = 'README.md'; From ad4c784f2029846a2107d294b3a1755610d74142 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:15:32 +0000 Subject: [PATCH 0047/2904] test: collapse duplicate gateway token-generation cases --- src/commands/configure.gateway-auth.e2e.test.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/commands/configure.gateway-auth.e2e.test.ts b/src/commands/configure.gateway-auth.e2e.test.ts index d050a0e90d9..5751954501c 100644 --- a/src/commands/configure.gateway-auth.e2e.test.ts +++ b/src/commands/configure.gateway-auth.e2e.test.ts @@ -65,23 +65,11 @@ describe("buildGatewayAuthConfig", () => { expect(result).toEqual({ mode: "password", password: "undefined" }); }); - it("generates random token when token param is undefined", () => { + it("generates random token for missing, empty, and coerced-literal token inputs", () => { expectGeneratedTokenFromInput(undefined); - }); - - it("generates random token when token param is empty string", () => { expectGeneratedTokenFromInput(""); - }); - - it("generates random token when token param is whitespace only", () => { expectGeneratedTokenFromInput(" "); - }); - - it('generates random token when token param is the literal string "undefined"', () => { expectGeneratedTokenFromInput("undefined"); - }); - - it('generates random token when token param is the literal string "null"', () => { expectGeneratedTokenFromInput("null", "null"); }); From 9c2640a8106c0ef749c08df38ab77382cb96f5d6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 09:19:27 +0100 Subject: [PATCH 0048/2904] docs: clarify WhatsApp group allowlist and reply mention behavior --- docs/channels/whatsapp.md | 7 +++++++ docs/gateway/security/index.md | 2 ++ 2 files changed, 9 insertions(+) diff --git a/docs/channels/whatsapp.md b/docs/channels/whatsapp.md index 88251bd8454..a6fb427bdc2 100644 --- a/docs/channels/whatsapp.md +++ b/docs/channels/whatsapp.md @@ -169,6 +169,7 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch Sender allowlist fallback: - if `groupAllowFrom` is unset, runtime falls back to `allowFrom` when available + - sender allowlists are evaluated before mention/reply activation Note: if no `channels.whatsapp` block exists at all, runtime group-policy fallback is effectively `open`. @@ -183,6 +184,11 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch - configured mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`) - implicit reply-to-bot detection (reply sender matches bot identity) + Security note: + + - quote/reply only satisfies mention gating; it does **not** grant sender authorization + - with `groupPolicy: "allowlist"`, non-allowlisted senders are still blocked even if they reply to an allowlisted user's message + Session-level activation command: - `/activation mention` @@ -407,6 +413,7 @@ Behavior notes: - `groupAllowFrom` / `allowFrom` - `groups` allowlist entries - mention gating (`requireMention` + mention patterns) + - duplicate keys in `openclaw.json` (JSON5): later entries override earlier ones, so keep a single `groupPolicy` per scope
diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 9b21b80ba4f..6a0ba212aba 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -301,6 +301,8 @@ OpenClaw has two separate “who can trigger me?” layers: - `channels.whatsapp.groups`, `channels.telegram.groups`, `channels.imessage.groups`: per-group defaults like `requireMention`; when set, it also acts as a group allowlist (include `"*"` to keep allow-all behavior). - `groupPolicy="allowlist"` + `groupAllowFrom`: restrict who can trigger the bot _inside_ a group session (WhatsApp/Telegram/Signal/iMessage/Microsoft Teams). - `channels.discord.guilds` / `channels.slack.channels`: per-surface allowlists + mention defaults. + - Group checks run in this order: `groupPolicy`/group allowlists first, mention/reply activation second. + - Replying to a bot message (implicit mention) does **not** bypass sender allowlists like `groupAllowFrom`. - **Security note:** treat `dmPolicy="open"` and `groupPolicy="open"` as last-resort settings. They should be barely used; prefer pairing + allowlists unless you fully trust every member of the room. Details: [Configuration](/gateway/configuration) and [Groups](/channels/groups) From 4e5cffe4c99e57793f7c5cacdb58c03142314a2a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:24:50 +0000 Subject: [PATCH 0049/2904] test: fix flaky run-node spawn side-effects --- src/infra/run-node.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 72713220c1e..fab1d7e771a 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -1,3 +1,4 @@ +import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -26,9 +27,9 @@ describe("run-node script", () => { const nodeCalls: string[][] = []; const spawn = (cmd: string, args: string[]) => { if (cmd === "pnpm") { - void fs.writeFile(argsPath, args.join(" "), "utf-8"); + fsSync.writeFileSync(argsPath, args.join(" "), "utf-8"); if (!args.includes("--no-clean")) { - void fs.rm(path.join(tmp, "dist", "control-ui"), { recursive: true, force: true }); + fsSync.rmSync(path.join(tmp, "dist", "control-ui"), { recursive: true, force: true }); } } if (cmd === process.execPath) { From ab924eb5225b9081f3f4eab24099bb74e7a4de4a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:12:40 +0000 Subject: [PATCH 0050/2904] test(infra): dedupe outbound recovery test scaffolding --- src/infra/outbound/outbound.test.ts | 103 +++++++++------------------- 1 file changed, 34 insertions(+), 69 deletions(-) diff --git a/src/infra/outbound/outbound.test.ts b/src/infra/outbound/outbound.test.ts index 799f0fe7121..be9fe4caf76 100644 --- a/src/infra/outbound/outbound.test.ts +++ b/src/infra/outbound/outbound.test.ts @@ -10,6 +10,7 @@ import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { ackDelivery, computeBackoffMs, + type DeliverFn, enqueueDelivery, failDelivery, loadPendingDeliveries, @@ -177,22 +178,38 @@ describe("delivery-queue", () => { describe("recoverPendingDeliveries", () => { const noopDelay = async () => {}; const baseCfg = {}; - - it("recovers entries from a simulated crash", async () => { - // Manually create two queue entries as if gateway crashed before delivery. + const createLog = () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }); + const enqueueCrashRecoveryEntries = async () => { await enqueueDelivery({ channel: "whatsapp", to: "+1", payloads: [{ text: "a" }] }, tmpDir); await enqueueDelivery({ channel: "telegram", to: "2", payloads: [{ text: "b" }] }, tmpDir); - - const deliver = vi.fn().mockResolvedValue([]); - const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - + }; + const runRecovery = async ({ + deliver, + log = createLog(), + delay = noopDelay, + maxRecoveryMs, + }: { + deliver: ReturnType; + log?: ReturnType; + delay?: (ms: number) => Promise; + maxRecoveryMs?: number; + }) => { const result = await recoverPendingDeliveries({ - deliver, + deliver: deliver as DeliverFn, log, cfg: baseCfg, stateDir: tmpDir, - delay: noopDelay, + delay, + ...(maxRecoveryMs === undefined ? {} : { maxRecoveryMs }), }); + return { result, log }; + }; + + it("recovers entries from a simulated crash", async () => { + // Manually create queue entries as if gateway crashed before delivery. + await enqueueCrashRecoveryEntries(); + const deliver = vi.fn().mockResolvedValue([]); + const { result } = await runRecovery({ deliver }); expect(deliver).toHaveBeenCalledTimes(2); expect(result.recovered).toBe(2); @@ -216,15 +233,7 @@ describe("delivery-queue", () => { fs.writeFileSync(filePath, JSON.stringify(entry), "utf-8"); const deliver = vi.fn(); - const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - - const result = await recoverPendingDeliveries({ - deliver, - log, - cfg: baseCfg, - stateDir: tmpDir, - delay: noopDelay, - }); + const { result } = await runRecovery({ deliver }); expect(deliver).not.toHaveBeenCalled(); expect(result.skipped).toBe(1); @@ -238,15 +247,7 @@ describe("delivery-queue", () => { await enqueueDelivery({ channel: "slack", to: "#ch", payloads: [{ text: "x" }] }, tmpDir); const deliver = vi.fn().mockRejectedValue(new Error("network down")); - const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - - const result = await recoverPendingDeliveries({ - deliver, - log, - cfg: baseCfg, - stateDir: tmpDir, - delay: noopDelay, - }); + const { result } = await runRecovery({ deliver }); expect(result.failed).toBe(1); expect(result.recovered).toBe(0); @@ -262,15 +263,7 @@ describe("delivery-queue", () => { await enqueueDelivery({ channel: "whatsapp", to: "+1", payloads: [{ text: "a" }] }, tmpDir); const deliver = vi.fn().mockResolvedValue([]); - const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - - await recoverPendingDeliveries({ - deliver, - log, - cfg: baseCfg, - stateDir: tmpDir, - delay: noopDelay, - }); + await runRecovery({ deliver }); expect(deliver).toHaveBeenCalledWith(expect.objectContaining({ skipQueue: true })); }); @@ -294,15 +287,7 @@ describe("delivery-queue", () => { ); const deliver = vi.fn().mockResolvedValue([]); - const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - - await recoverPendingDeliveries({ - deliver, - log, - cfg: baseCfg, - stateDir: tmpDir, - delay: noopDelay, - }); + await runRecovery({ deliver }); expect(deliver).toHaveBeenCalledWith( expect.objectContaining({ @@ -319,19 +304,12 @@ describe("delivery-queue", () => { }); it("respects maxRecoveryMs time budget", async () => { - await enqueueDelivery({ channel: "whatsapp", to: "+1", payloads: [{ text: "a" }] }, tmpDir); - await enqueueDelivery({ channel: "telegram", to: "2", payloads: [{ text: "b" }] }, tmpDir); + await enqueueCrashRecoveryEntries(); await enqueueDelivery({ channel: "slack", to: "#c", payloads: [{ text: "c" }] }, tmpDir); const deliver = vi.fn().mockResolvedValue([]); - const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - - const result = await recoverPendingDeliveries({ + const { result, log } = await runRecovery({ deliver, - log, - cfg: baseCfg, - stateDir: tmpDir, - delay: noopDelay, maxRecoveryMs: 0, // Immediate timeout -- no entries should be processed. }); @@ -360,13 +338,8 @@ describe("delivery-queue", () => { const deliver = vi.fn().mockResolvedValue([]); const delay = vi.fn(async () => {}); - const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - - const result = await recoverPendingDeliveries({ + const { result, log } = await runRecovery({ deliver, - log, - cfg: baseCfg, - stateDir: tmpDir, delay, maxRecoveryMs: 1000, }); @@ -383,15 +356,7 @@ describe("delivery-queue", () => { it("returns zeros when queue is empty", async () => { const deliver = vi.fn(); - const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - - const result = await recoverPendingDeliveries({ - deliver, - log, - cfg: baseCfg, - stateDir: tmpDir, - delay: noopDelay, - }); + const { result } = await runRecovery({ deliver }); expect(result).toEqual({ recovered: 0, failed: 0, skipped: 0 }); expect(deliver).not.toHaveBeenCalled(); From 644d0379698f2e57700fa514410469dcbdd90ac3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:14:16 +0000 Subject: [PATCH 0051/2904] test(config): dedupe OPENCLAW_HOME path assertions --- src/config/paths.test.ts | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/config/paths.test.ts b/src/config/paths.test.ts index 22b278d8171..9d2ed808407 100644 --- a/src/config/paths.test.ts +++ b/src/config/paths.test.ts @@ -37,6 +37,18 @@ describe("oauth paths", () => { }); describe("state + config path candidates", () => { + function expectOpenClawHomeDefaults(env: NodeJS.ProcessEnv): void { + const configuredHome = env.OPENCLAW_HOME; + if (!configuredHome) { + throw new Error("OPENCLAW_HOME must be set for this assertion helper"); + } + const resolvedHome = path.resolve(configuredHome); + expect(resolveStateDir(env)).toBe(path.join(resolvedHome, ".openclaw")); + + const candidates = resolveDefaultConfigCandidates(env); + expect(candidates[0]).toBe(path.join(resolvedHome, ".openclaw", "openclaw.json")); + } + it("uses OPENCLAW_STATE_DIR when set", () => { const env = { OPENCLAW_STATE_DIR: "/new/state", @@ -49,12 +61,7 @@ describe("state + config path candidates", () => { const env = { OPENCLAW_HOME: "/srv/openclaw-home", } as NodeJS.ProcessEnv; - - const resolvedHome = path.resolve("/srv/openclaw-home"); - expect(resolveStateDir(env)).toBe(path.join(resolvedHome, ".openclaw")); - - const candidates = resolveDefaultConfigCandidates(env); - expect(candidates[0]).toBe(path.join(resolvedHome, ".openclaw", "openclaw.json")); + expectOpenClawHomeDefaults(env); }); it("prefers OPENCLAW_HOME over HOME for default state/config locations", () => { @@ -62,12 +69,7 @@ describe("state + config path candidates", () => { OPENCLAW_HOME: "/srv/openclaw-home", HOME: "/home/other", } as NodeJS.ProcessEnv; - - const resolvedHome = path.resolve("/srv/openclaw-home"); - expect(resolveStateDir(env)).toBe(path.join(resolvedHome, ".openclaw")); - - const candidates = resolveDefaultConfigCandidates(env); - expect(candidates[0]).toBe(path.join(resolvedHome, ".openclaw", "openclaw.json")); + expectOpenClawHomeDefaults(env); }); it("orders default config candidates in a stable order", () => { From 8bb1747ad97d42313ecaf0f32fe687d31e041fe8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:14:21 +0000 Subject: [PATCH 0052/2904] test(gateway): dedupe assistant chat event assertions --- src/gateway/server-chat.agent-events.test.ts | 64 +++++++++++--------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts index 56eb2464a73..143bdd003d2 100644 --- a/src/gateway/server-chat.agent-events.test.ts +++ b/src/gateway/server-chat.agent-events.test.ts @@ -43,21 +43,38 @@ describe("agent event handler", () => { }; } - it("emits chat delta for assistant text-only events", () => { - const { broadcast, nodeSendToSession, chatRunState, handler, nowSpy } = createHarness({ - now: 1_000, + function emitRun1AssistantText( + harness: ReturnType, + text: string, + ): ReturnType { + harness.chatRunState.registry.add("run-1", { + sessionKey: "session-1", + clientRunId: "client-1", }); - chatRunState.registry.add("run-1", { sessionKey: "session-1", clientRunId: "client-1" }); - - handler({ + harness.handler({ runId: "run-1", seq: 1, stream: "assistant", ts: Date.now(), - data: { text: "Hello world" }, + data: { text }, }); + return harness; + } - const chatCalls = broadcast.mock.calls.filter(([event]) => event === "chat"); + function chatBroadcastCalls(broadcast: ReturnType) { + return broadcast.mock.calls.filter(([event]) => event === "chat"); + } + + function sessionChatCalls(nodeSendToSession: ReturnType) { + return nodeSendToSession.mock.calls.filter(([, event]) => event === "chat"); + } + + it("emits chat delta for assistant text-only events", () => { + const { broadcast, nodeSendToSession, nowSpy } = emitRun1AssistantText( + createHarness({ now: 1_000 }), + "Hello world", + ); + const chatCalls = chatBroadcastCalls(broadcast); expect(chatCalls).toHaveLength(1); const payload = chatCalls[0]?.[1] as { state?: string; @@ -65,29 +82,17 @@ describe("agent event handler", () => { }; expect(payload.state).toBe("delta"); expect(payload.message?.content?.[0]?.text).toBe("Hello world"); - const sessionChatCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "chat"); - expect(sessionChatCalls).toHaveLength(1); + expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1); nowSpy?.mockRestore(); }); it("does not emit chat delta for NO_REPLY streaming text", () => { - const { broadcast, nodeSendToSession, chatRunState, handler, nowSpy } = createHarness({ - now: 1_000, - }); - chatRunState.registry.add("run-1", { sessionKey: "session-1", clientRunId: "client-1" }); - - handler({ - runId: "run-1", - seq: 1, - stream: "assistant", - ts: Date.now(), - data: { text: " NO_REPLY " }, - }); - - const chatCalls = broadcast.mock.calls.filter(([event]) => event === "chat"); - expect(chatCalls).toHaveLength(0); - const sessionChatCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "chat"); - expect(sessionChatCalls).toHaveLength(0); + const { broadcast, nodeSendToSession, nowSpy } = emitRun1AssistantText( + createHarness({ now: 1_000 }), + " NO_REPLY ", + ); + expect(chatBroadcastCalls(broadcast)).toHaveLength(0); + expect(sessionChatCalls(nodeSendToSession)).toHaveLength(0); nowSpy?.mockRestore(); }); @@ -112,13 +117,12 @@ describe("agent event handler", () => { data: { phase: "end" }, }); - const chatCalls = broadcast.mock.calls.filter(([event]) => event === "chat"); + const chatCalls = chatBroadcastCalls(broadcast); expect(chatCalls).toHaveLength(1); const payload = chatCalls[0]?.[1] as { state?: string; message?: unknown }; expect(payload.state).toBe("final"); expect(payload.message).toBeUndefined(); - const sessionChatCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "chat"); - expect(sessionChatCalls).toHaveLength(1); + expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1); nowSpy?.mockRestore(); }); From d8b720cc5f2852066514f260a766d05e4f8bbf6d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:15:20 +0000 Subject: [PATCH 0053/2904] test(config): dedupe model provider fixture setup --- src/config/model-alias-defaults.test.ts | 71 ++++++++++--------------- 1 file changed, 27 insertions(+), 44 deletions(-) diff --git a/src/config/model-alias-defaults.test.ts b/src/config/model-alias-defaults.test.ts index 62afb0a6bc7..015feeac36c 100644 --- a/src/config/model-alias-defaults.test.ts +++ b/src/config/model-alias-defaults.test.ts @@ -4,6 +4,31 @@ import { applyModelDefaults } from "./defaults.js"; import type { OpenClawConfig } from "./types.js"; describe("applyModelDefaults", () => { + function buildProxyProviderConfig(overrides?: { contextWindow?: number; maxTokens?: number }) { + return { + models: { + providers: { + myproxy: { + baseUrl: "https://proxy.example/v1", + apiKey: "sk-test", + api: "openai-completions", + models: [ + { + id: "gpt-5.2", + name: "GPT-5.2", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: overrides?.contextWindow ?? 200_000, + maxTokens: overrides?.maxTokens ?? 8192, + }, + ], + }, + }, + }, + } satisfies OpenClawConfig; + } + it("adds default aliases when models are present", () => { const cfg = { agents: { @@ -58,28 +83,7 @@ describe("applyModelDefaults", () => { }); it("fills missing model provider defaults", () => { - const cfg = { - models: { - providers: { - myproxy: { - baseUrl: "https://proxy.example/v1", - apiKey: "sk-test", - api: "openai-completions", - models: [ - { - id: "gpt-5.2", - name: "GPT-5.2", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200_000, - maxTokens: 8192, - }, - ], - }, - }, - }, - } satisfies OpenClawConfig; + const cfg = buildProxyProviderConfig(); const next = applyModelDefaults(cfg); const model = next.models?.providers?.myproxy?.models?.[0]; @@ -92,28 +96,7 @@ describe("applyModelDefaults", () => { }); it("clamps maxTokens to contextWindow", () => { - const cfg = { - models: { - providers: { - myproxy: { - baseUrl: "https://proxy.example/v1", - apiKey: "sk-test", - api: "openai-completions", - models: [ - { - id: "gpt-5.2", - name: "GPT-5.2", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 32768, - maxTokens: 40960, - }, - ], - }, - }, - }, - } satisfies OpenClawConfig; + const cfg = buildProxyProviderConfig({ contextWindow: 32768, maxTokens: 40960 }); const next = applyModelDefaults(cfg); const model = next.models?.providers?.myproxy?.models?.[0]; From 733e385843b4d34aad260f3505996283a0d8f235 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:16:08 +0000 Subject: [PATCH 0054/2904] test(hooks): dedupe gmail runtime path assertions --- src/hooks/gmail.test.ts | 135 ++++++++++++++++------------------------ 1 file changed, 52 insertions(+), 83 deletions(-) diff --git a/src/hooks/gmail.test.ts b/src/hooks/gmail.test.ts index 514e89c0776..7df9da83364 100644 --- a/src/hooks/gmail.test.ts +++ b/src/hooks/gmail.test.ts @@ -19,6 +19,40 @@ const baseConfig = { } satisfies OpenClawConfig; describe("gmail hook config", () => { + function resolveWithGmailOverrides( + overrides: Partial["gmail"]>, + ) { + return resolveGmailHookRuntimeConfig( + { + hooks: { + token: "hook-token", + gmail: { + account: "openclaw@gmail.com", + topic: "projects/demo/topics/gog-gmail-watch", + pushToken: "push-token", + ...overrides, + }, + }, + }, + {}, + ); + } + + function expectResolvedPaths( + result: ReturnType, + expected: { servePath: string; publicPath: string; target?: string }, + ) { + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.value.serve.path).toBe(expected.servePath); + expect(result.value.tailscale.path).toBe(expected.publicPath); + if (expected.target !== undefined) { + expect(result.value.tailscale.target).toBe(expected.target); + } + } + it("builds default hook url", () => { expect(buildDefaultHookUrl("/hooks", DEFAULT_GATEWAY_PORT)).toBe( `http://127.0.0.1:${DEFAULT_GATEWAY_PORT}/hooks/gmail`, @@ -62,97 +96,32 @@ describe("gmail hook config", () => { }); it("defaults serve path to / when tailscale is enabled", () => { - const result = resolveGmailHookRuntimeConfig( - { - hooks: { - token: "hook-token", - gmail: { - account: "openclaw@gmail.com", - topic: "projects/demo/topics/gog-gmail-watch", - pushToken: "push-token", - tailscale: { mode: "funnel" }, - }, - }, - }, - {}, - ); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.value.serve.path).toBe("/"); - expect(result.value.tailscale.path).toBe("/gmail-pubsub"); - } + const result = resolveWithGmailOverrides({ tailscale: { mode: "funnel" } }); + expectResolvedPaths(result, { servePath: "/", publicPath: "/gmail-pubsub" }); }); it("keeps the default public path when serve path is explicit", () => { - const result = resolveGmailHookRuntimeConfig( - { - hooks: { - token: "hook-token", - gmail: { - account: "openclaw@gmail.com", - topic: "projects/demo/topics/gog-gmail-watch", - pushToken: "push-token", - serve: { path: "/gmail-pubsub" }, - tailscale: { mode: "funnel" }, - }, - }, - }, - {}, - ); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.value.serve.path).toBe("/"); - expect(result.value.tailscale.path).toBe("/gmail-pubsub"); - } + const result = resolveWithGmailOverrides({ + serve: { path: "/gmail-pubsub" }, + tailscale: { mode: "funnel" }, + }); + expectResolvedPaths(result, { servePath: "/", publicPath: "/gmail-pubsub" }); }); it("keeps custom public path when serve path is set", () => { - const result = resolveGmailHookRuntimeConfig( - { - hooks: { - token: "hook-token", - gmail: { - account: "openclaw@gmail.com", - topic: "projects/demo/topics/gog-gmail-watch", - pushToken: "push-token", - serve: { path: "/custom" }, - tailscale: { mode: "funnel" }, - }, - }, - }, - {}, - ); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.value.serve.path).toBe("/"); - expect(result.value.tailscale.path).toBe("/custom"); - } + const result = resolveWithGmailOverrides({ + serve: { path: "/custom" }, + tailscale: { mode: "funnel" }, + }); + expectResolvedPaths(result, { servePath: "/", publicPath: "/custom" }); }); it("keeps serve path when tailscale target is set", () => { - const result = resolveGmailHookRuntimeConfig( - { - hooks: { - token: "hook-token", - gmail: { - account: "openclaw@gmail.com", - topic: "projects/demo/topics/gog-gmail-watch", - pushToken: "push-token", - serve: { path: "/custom" }, - tailscale: { - mode: "funnel", - target: "http://127.0.0.1:8788/custom", - }, - }, - }, - }, - {}, - ); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.value.serve.path).toBe("/custom"); - expect(result.value.tailscale.path).toBe("/custom"); - expect(result.value.tailscale.target).toBe("http://127.0.0.1:8788/custom"); - } + const target = "http://127.0.0.1:8788/custom"; + const result = resolveWithGmailOverrides({ + serve: { path: "/custom" }, + tailscale: { mode: "funnel", target }, + }); + expectResolvedPaths(result, { servePath: "/custom", publicPath: "/custom", target }); }); }); From edce5a505ae0a6d6f1e457ff1c374fbaf2cec1ca Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:17:50 +0000 Subject: [PATCH 0055/2904] test(cron): dedupe applyJobPatch fixture setup --- src/cron/service.jobs.test.ts | 95 ++++++++++++++--------------------- 1 file changed, 37 insertions(+), 58 deletions(-) diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index 4dd44ca010a..adbf7ee4b29 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -5,11 +5,15 @@ import { DEFAULT_TOP_OF_HOUR_STAGGER_MS } from "./stagger.js"; import type { CronJob, CronJobPatch } from "./types.js"; describe("applyJobPatch", () => { - it("clears delivery when switching to main session", () => { + const createIsolatedAgentTurnJob = ( + id: string, + delivery: CronJob["delivery"], + overrides?: Partial, + ): CronJob => { const now = Date.now(); - const job: CronJob = { - id: "job-1", - name: "job-1", + return { + id, + name: id, enabled: true, createdAtMs: now, updatedAtMs: now, @@ -17,62 +21,47 @@ describe("applyJobPatch", () => { sessionTarget: "isolated", wakeMode: "now", payload: { kind: "agentTurn", message: "do it" }, - delivery: { mode: "announce", channel: "telegram", to: "123" }, + delivery, state: {}, + ...overrides, }; + }; - const patch: CronJobPatch = { - sessionTarget: "main", - payload: { kind: "systemEvent", text: "ping" }, - }; + const switchToMainPatch = (): CronJobPatch => ({ + sessionTarget: "main", + payload: { kind: "systemEvent", text: "ping" }, + }); - expect(() => applyJobPatch(job, patch)).not.toThrow(); + it("clears delivery when switching to main session", () => { + const job = createIsolatedAgentTurnJob("job-1", { + mode: "announce", + channel: "telegram", + to: "123", + }); + + expect(() => applyJobPatch(job, switchToMainPatch())).not.toThrow(); expect(job.sessionTarget).toBe("main"); expect(job.payload.kind).toBe("systemEvent"); expect(job.delivery).toBeUndefined(); }); it("keeps webhook delivery when switching to main session", () => { - const now = Date.now(); - const job: CronJob = { - id: "job-webhook", - name: "job-webhook", - enabled: true, - createdAtMs: now, - updatedAtMs: now, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "isolated", - wakeMode: "now", - payload: { kind: "agentTurn", message: "do it" }, - delivery: { mode: "webhook", to: "https://example.invalid/cron" }, - state: {}, - }; + const job = createIsolatedAgentTurnJob("job-webhook", { + mode: "webhook", + to: "https://example.invalid/cron", + }); - const patch: CronJobPatch = { - sessionTarget: "main", - payload: { kind: "systemEvent", text: "ping" }, - }; - - expect(() => applyJobPatch(job, patch)).not.toThrow(); + expect(() => applyJobPatch(job, switchToMainPatch())).not.toThrow(); expect(job.sessionTarget).toBe("main"); expect(job.delivery).toEqual({ mode: "webhook", to: "https://example.invalid/cron" }); }); it("maps legacy payload delivery updates onto delivery", () => { - const now = Date.now(); - const job: CronJob = { - id: "job-2", - name: "job-2", - enabled: true, - createdAtMs: now, - updatedAtMs: now, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "isolated", - wakeMode: "now", - payload: { kind: "agentTurn", message: "do it" }, - delivery: { mode: "announce", channel: "telegram", to: "123" }, - state: {}, - }; + const job = createIsolatedAgentTurnJob("job-2", { + mode: "announce", + channel: "telegram", + to: "123", + }); const patch: CronJobPatch = { payload: { @@ -101,20 +90,10 @@ describe("applyJobPatch", () => { }); it("treats legacy payload targets as announce requests", () => { - const now = Date.now(); - const job: CronJob = { - id: "job-3", - name: "job-3", - enabled: true, - createdAtMs: now, - updatedAtMs: now, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "isolated", - wakeMode: "now", - payload: { kind: "agentTurn", message: "do it" }, - delivery: { mode: "none", channel: "telegram" }, - state: {}, - }; + const job = createIsolatedAgentTurnJob("job-3", { + mode: "none", + channel: "telegram", + }); const patch: CronJobPatch = { payload: { kind: "agentTurn", to: " 999 " }, From e0c3cc4981d6cd9829d1877d2e1341791abbdfd4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:18:32 +0000 Subject: [PATCH 0056/2904] test(browser): dedupe auth mode no-token assertions --- src/browser/control-auth.test.ts | 40 +++++++++++--------------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/src/browser/control-auth.test.ts b/src/browser/control-auth.test.ts index b88816adb5e..f80740d01f5 100644 --- a/src/browser/control-auth.test.ts +++ b/src/browser/control-auth.test.ts @@ -3,6 +3,16 @@ import type { OpenClawConfig } from "../config/types.js"; import { ensureBrowserControlAuth } from "./control-auth.js"; describe("ensureBrowserControlAuth", () => { + async function expectNoAutoGeneratedAuth(cfg: OpenClawConfig): Promise { + const result = await ensureBrowserControlAuth({ + cfg, + env: { OPENCLAW_BROWSER_AUTO_AUTH: "1" }, + }); + expect(result.generatedToken).toBeUndefined(); + expect(result.auth.token).toBeUndefined(); + expect(result.auth.password).toBeUndefined(); + } + describe("trusted-proxy mode", () => { it("should not auto-generate token when auth mode is trusted-proxy", async () => { const cfg: OpenClawConfig = { @@ -16,15 +26,7 @@ describe("ensureBrowserControlAuth", () => { trustedProxies: ["192.168.1.1"], }, }; - - const result = await ensureBrowserControlAuth({ - cfg, - env: { OPENCLAW_BROWSER_AUTO_AUTH: "1" }, - }); - - expect(result.generatedToken).toBeUndefined(); - expect(result.auth.token).toBeUndefined(); - expect(result.auth.password).toBeUndefined(); + await expectNoAutoGeneratedAuth(cfg); }); }); @@ -37,15 +39,7 @@ describe("ensureBrowserControlAuth", () => { }, }, }; - - const result = await ensureBrowserControlAuth({ - cfg, - env: { OPENCLAW_BROWSER_AUTO_AUTH: "1" }, - }); - - expect(result.generatedToken).toBeUndefined(); - expect(result.auth.token).toBeUndefined(); - expect(result.auth.password).toBeUndefined(); + await expectNoAutoGeneratedAuth(cfg); }); }); @@ -58,15 +52,7 @@ describe("ensureBrowserControlAuth", () => { }, }, }; - - const result = await ensureBrowserControlAuth({ - cfg, - env: { OPENCLAW_BROWSER_AUTO_AUTH: "1" }, - }); - - expect(result.generatedToken).toBeUndefined(); - expect(result.auth.token).toBeUndefined(); - expect(result.auth.password).toBeUndefined(); + await expectNoAutoGeneratedAuth(cfg); }); }); From 3c7c45e153578b54bf070b8fd5ec58a8f159db51 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:19:14 +0000 Subject: [PATCH 0057/2904] test(gateway): dedupe config.apply request scaffolding --- src/gateway/server.config-apply.e2e.test.ts | 45 ++++++++------------- 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/src/gateway/server.config-apply.e2e.test.ts b/src/gateway/server.config-apply.e2e.test.ts index f1b71568512..da13d93e247 100644 --- a/src/gateway/server.config-apply.e2e.test.ts +++ b/src/gateway/server.config-apply.e2e.test.ts @@ -29,25 +29,27 @@ const openClient = async () => { return ws; }; +const sendConfigApply = async (ws: WebSocket, id: string, raw: unknown) => { + ws.send( + JSON.stringify({ + type: "req", + id, + method: "config.apply", + params: { raw }, + }), + ); + return onceMessage<{ ok: boolean; error?: { message?: string } }>(ws, (o) => { + const msg = o as { type?: string; id?: string }; + return msg.type === "res" && msg.id === id; + }); +}; + describe("gateway config.apply", () => { it("rejects invalid raw config", async () => { const ws = await openClient(); try { const id = "req-1"; - ws.send( - JSON.stringify({ - type: "req", - id, - method: "config.apply", - params: { - raw: "{", - }, - }), - ); - const res = await onceMessage<{ ok: boolean; error?: { message?: string } }>(ws, (o) => { - const msg = o as { type?: string; id?: string }; - return msg.type === "res" && msg.id === id; - }); + const res = await sendConfigApply(ws, id, "{"); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toMatch(/invalid|SyntaxError/i); } finally { @@ -59,20 +61,7 @@ describe("gateway config.apply", () => { const ws = await openClient(); try { const id = "req-2"; - ws.send( - JSON.stringify({ - type: "req", - id, - method: "config.apply", - params: { - raw: { gateway: { mode: "local" } }, - }, - }), - ); - const res = await onceMessage<{ ok: boolean; error?: { message?: string } }>(ws, (o) => { - const msg = o as { type?: string; id?: string }; - return msg.type === "res" && msg.id === id; - }); + const res = await sendConfigApply(ws, id, { gateway: { mode: "local" } }); expect(res.ok).toBe(false); expect(res.error?.message ?? "").toContain("raw"); } finally { From 69e6da0e2807f41b6de252dac18409d6590b239f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:20:07 +0000 Subject: [PATCH 0058/2904] test(auto-reply): dedupe heartbeat typing flow setup --- src/auto-reply/reply.heartbeat-typing.test.ts | 51 ++++++++----------- 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/src/auto-reply/reply.heartbeat-typing.test.ts b/src/auto-reply/reply.heartbeat-typing.test.ts index eaac90a0871..41da12974c3 100644 --- a/src/auto-reply/reply.heartbeat-typing.test.ts +++ b/src/auto-reply/reply.heartbeat-typing.test.ts @@ -49,43 +49,34 @@ afterEach(() => { }); describe("getReplyFromConfig typing (heartbeat)", () => { + async function runReplyFlow(isHeartbeat: boolean): Promise> { + const onReplyStart = vi.fn(); + await withTempHome(async (home) => { + runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [{ text: "ok" }], + meta: {}, + }); + + await getReplyFromConfig( + { Body: "hi", From: "+1000", To: "+2000", Provider: "whatsapp" }, + { onReplyStart, isHeartbeat }, + makeReplyConfig(home) as unknown as OpenClawConfig, + ); + }); + return onReplyStart; + } + beforeEach(() => { vi.stubEnv("OPENCLAW_TEST_FAST", "1"); }); it("starts typing for normal runs", async () => { - await withTempHome(async (home) => { - runEmbeddedPiAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "ok" }], - meta: {}, - }); - const onReplyStart = vi.fn(); - - await getReplyFromConfig( - { Body: "hi", From: "+1000", To: "+2000", Provider: "whatsapp" }, - { onReplyStart, isHeartbeat: false }, - makeReplyConfig(home) as unknown as OpenClawConfig, - ); - - expect(onReplyStart).toHaveBeenCalled(); - }); + const onReplyStart = await runReplyFlow(false); + expect(onReplyStart).toHaveBeenCalled(); }); it("does not start typing for heartbeat runs", async () => { - await withTempHome(async (home) => { - runEmbeddedPiAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "ok" }], - meta: {}, - }); - const onReplyStart = vi.fn(); - - await getReplyFromConfig( - { Body: "hi", From: "+1000", To: "+2000", Provider: "whatsapp" }, - { onReplyStart, isHeartbeat: true }, - makeReplyConfig(home) as unknown as OpenClawConfig, - ); - - expect(onReplyStart).not.toHaveBeenCalled(); - }); + const onReplyStart = await runReplyFlow(true); + expect(onReplyStart).not.toHaveBeenCalled(); }); }); From 53a4e5151d7eb216801fc1e1804b6e4844cec20f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:20:52 +0000 Subject: [PATCH 0059/2904] test(agents): dedupe tool image fixture setup --- src/agents/tool-images.e2e.test.ts | 54 ++++++++++++++---------------- 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/src/agents/tool-images.e2e.test.ts b/src/agents/tool-images.e2e.test.ts index e51f9bc6994..6de86b0e4bd 100644 --- a/src/agents/tool-images.e2e.test.ts +++ b/src/agents/tool-images.e2e.test.ts @@ -3,6 +3,27 @@ import { describe, expect, it } from "vitest"; import { sanitizeContentBlocksImages, sanitizeImageBlocks } from "./tool-images.js"; describe("tool image sanitizing", () => { + const getImageBlock = ( + blocks: Awaited>, + ): (typeof blocks)[number] & { type: "image"; data: string; mimeType?: string } => { + const image = blocks.find((block) => block.type === "image"); + if (!image || image.type !== "image") { + throw new Error("expected image block"); + } + return image; + }; + + const createWidePng = async () => { + const width = 2600; + const height = 400; + const raw = Buffer.alloc(width * height * 3, 0x7f); + return sharp(raw, { + raw: { width, height, channels: 3 }, + }) + .png({ compressionLevel: 9 }) + .toBuffer(); + }; + it("shrinks oversized images to <=5MB", async () => { const width = 2800; const height = 2800; @@ -23,24 +44,14 @@ describe("tool image sanitizing", () => { ]; const out = await sanitizeContentBlocksImages(blocks, "test"); - const image = out.find((b) => b.type === "image"); - if (!image || image.type !== "image") { - throw new Error("expected image block"); - } + const image = getImageBlock(out); const size = Buffer.from(image.data, "base64").byteLength; expect(size).toBeLessThanOrEqual(5 * 1024 * 1024); expect(image.mimeType).toBe("image/jpeg"); }, 20_000); it("sanitizes image arrays and reports drops", async () => { - const width = 2600; - const height = 400; - const raw = Buffer.alloc(width * height * 3, 0x7f); - const png = await sharp(raw, { - raw: { width, height, channels: 3 }, - }) - .png({ compressionLevel: 9 }) - .toBuffer(); + const png = await createWidePng(); const images = [ { type: "image" as const, data: png.toString("base64"), mimeType: "image/png" }, @@ -54,14 +65,7 @@ describe("tool image sanitizing", () => { }, 20_000); it("shrinks images that exceed max dimension even if size is small", async () => { - const width = 2600; - const height = 400; - const raw = Buffer.alloc(width * height * 3, 0x7f); - const png = await sharp(raw, { - raw: { width, height, channels: 3 }, - }) - .png({ compressionLevel: 9 }) - .toBuffer(); + const png = await createWidePng(); const blocks = [ { @@ -72,10 +76,7 @@ describe("tool image sanitizing", () => { ]; const out = await sanitizeContentBlocksImages(blocks, "test"); - const image = out.find((b) => b.type === "image"); - if (!image || image.type !== "image") { - throw new Error("expected image block"); - } + const image = getImageBlock(out); const meta = await sharp(Buffer.from(image.data, "base64")).metadata(); expect(meta.width).toBeLessThanOrEqual(1200); expect(meta.height).toBeLessThanOrEqual(1200); @@ -103,10 +104,7 @@ describe("tool image sanitizing", () => { ]; const out = await sanitizeContentBlocksImages(blocks, "test"); - const image = out.find((b) => b.type === "image"); - if (!image || image.type !== "image") { - throw new Error("expected image block"); - } + const image = getImageBlock(out); expect(image.mimeType).toBe("image/jpeg"); }); }); From a76f552b00b0c4e48fa319f29c62ce549bd9963b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:22:17 +0000 Subject: [PATCH 0060/2904] test(agents): dedupe workspace memory-entry assertions --- src/agents/workspace.e2e.test.ts | 37 ++++++++++++++++---------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/agents/workspace.e2e.test.ts b/src/agents/workspace.e2e.test.ts index 085afbcb39b..2fef954c1f7 100644 --- a/src/agents/workspace.e2e.test.ts +++ b/src/agents/workspace.e2e.test.ts @@ -103,18 +103,27 @@ describe("ensureAgentWorkspace", () => { }); describe("loadWorkspaceBootstrapFiles", () => { + const getMemoryEntries = (files: Awaited>) => + files.filter((file) => + [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME].includes(file.name), + ); + + const expectSingleMemoryEntry = ( + files: Awaited>, + content: string, + ) => { + const memoryEntries = getMemoryEntries(files); + expect(memoryEntries).toHaveLength(1); + expect(memoryEntries[0]?.missing).toBe(false); + expect(memoryEntries[0]?.content).toBe(content); + }; + it("includes MEMORY.md when present", async () => { const tempDir = await makeTempWorkspace("openclaw-workspace-"); await writeWorkspaceFile({ dir: tempDir, name: "MEMORY.md", content: "memory" }); const files = await loadWorkspaceBootstrapFiles(tempDir); - const memoryEntries = files.filter((file) => - [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME].includes(file.name), - ); - - expect(memoryEntries).toHaveLength(1); - expect(memoryEntries[0]?.missing).toBe(false); - expect(memoryEntries[0]?.content).toBe("memory"); + expectSingleMemoryEntry(files, "memory"); }); it("includes memory.md when MEMORY.md is absent", async () => { @@ -122,23 +131,13 @@ describe("loadWorkspaceBootstrapFiles", () => { await writeWorkspaceFile({ dir: tempDir, name: "memory.md", content: "alt" }); const files = await loadWorkspaceBootstrapFiles(tempDir); - const memoryEntries = files.filter((file) => - [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME].includes(file.name), - ); - - expect(memoryEntries).toHaveLength(1); - expect(memoryEntries[0]?.missing).toBe(false); - expect(memoryEntries[0]?.content).toBe("alt"); + expectSingleMemoryEntry(files, "alt"); }); it("omits memory entries when no memory files exist", async () => { const tempDir = await makeTempWorkspace("openclaw-workspace-"); const files = await loadWorkspaceBootstrapFiles(tempDir); - const memoryEntries = files.filter((file) => - [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME].includes(file.name), - ); - - expect(memoryEntries).toHaveLength(0); + expect(getMemoryEntries(files)).toHaveLength(0); }); }); From 148116048499b8acb4c3f512e59767679132e0ff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:22:57 +0000 Subject: [PATCH 0061/2904] test(cli): dedupe browser state command setup --- ...rowser-cli-state.option-collisions.test.ts | 52 +++++++++---------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/src/cli/browser-cli-state.option-collisions.test.ts b/src/cli/browser-cli-state.option-collisions.test.ts index 700079ca233..935355b6710 100644 --- a/src/cli/browser-cli-state.option-collisions.test.ts +++ b/src/cli/browser-cli-state.option-collisions.test.ts @@ -26,6 +26,26 @@ vi.mock("../runtime.js", () => ({ })); describe("browser state option collisions", () => { + const createBrowserProgram = () => { + const program = new Command(); + const browser = program + .command("browser") + .option("--browser-profile ", "Browser profile") + .option("--json", "Output JSON", false); + const parentOpts = (cmd: Command) => cmd.parent?.opts?.() as BrowserParentOpts; + registerBrowserStateCommands(browser, parentOpts); + return program; + }; + + const getLastRequest = () => { + const call = mocks.callBrowserRequest.mock.calls.at(-1); + expect(call).toBeDefined(); + if (!call) { + throw new Error("expected browser request call"); + } + return call[1] as { body?: Record }; + }; + beforeEach(() => { mocks.callBrowserRequest.mockClear(); mocks.runBrowserResizeWithOutput.mockClear(); @@ -35,14 +55,7 @@ describe("browser state option collisions", () => { }); it("forwards parent-captured --target-id on `browser cookies set`", async () => { - const program = new Command(); - const browser = program - .command("browser") - .option("--browser-profile ", "Browser profile") - .option("--json", "Output JSON", false); - - const parentOpts = (cmd: Command) => cmd.parent?.opts?.() as BrowserParentOpts; - registerBrowserStateCommands(browser, parentOpts); + const program = createBrowserProgram(); await program.parseAsync( [ @@ -59,35 +72,18 @@ describe("browser state option collisions", () => { { from: "user" }, ); - const call = mocks.callBrowserRequest.mock.calls.at(-1); - expect(call).toBeDefined(); - if (!call) { - throw new Error("expected browser request call"); - } - const request = call[1] as { body?: { targetId?: string } }; + const request = getLastRequest() as { body?: { targetId?: string } }; expect(request.body?.targetId).toBe("tab-1"); }); it("accepts legacy parent `--json` by parsing payload via positional headers fallback", async () => { - const program = new Command(); - const browser = program - .command("browser") - .option("--browser-profile ", "Browser profile") - .option("--json", "Output JSON", false); - - const parentOpts = (cmd: Command) => cmd.parent?.opts?.() as BrowserParentOpts; - registerBrowserStateCommands(browser, parentOpts); + const program = createBrowserProgram(); await program.parseAsync(["browser", "set", "headers", "--json", '{"x-auth":"ok"}'], { from: "user", }); - const call = mocks.callBrowserRequest.mock.calls.at(-1); - expect(call).toBeDefined(); - if (!call) { - throw new Error("expected browser request call"); - } - const request = call[1] as { body?: { headers?: Record } }; + const request = getLastRequest() as { body?: { headers?: Record } }; expect(request.body?.headers).toEqual({ "x-auth": "ok" }); }); }); From fe3bd9d65be95367f6d1fcbc0fcfd124a7e7ec75 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:26:43 +0000 Subject: [PATCH 0062/2904] test: merge duplicate gateway token coercion checks --- src/commands/onboard-helpers.e2e.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/commands/onboard-helpers.e2e.test.ts b/src/commands/onboard-helpers.e2e.test.ts index f9af2e9599a..3f70ccccfcb 100644 --- a/src/commands/onboard-helpers.e2e.test.ts +++ b/src/commands/onboard-helpers.e2e.test.ts @@ -128,11 +128,8 @@ describe("normalizeGatewayTokenInput", () => { expect(normalizeGatewayTokenInput(123)).toBe(""); }); - it('rejects the literal string "undefined"', () => { + it('rejects literal string coercion artifacts ("undefined"/"null")', () => { expect(normalizeGatewayTokenInput("undefined")).toBe(""); - }); - - it('rejects the literal string "null"', () => { expect(normalizeGatewayTokenInput("null")).toBe(""); }); }); From 47bbef30f91f8db9efab3afc6b8a21e58a5534df Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:27:40 +0000 Subject: [PATCH 0063/2904] test: merge duplicate undefined api-key persistence checks --- src/commands/auth-choice.e2e.test.ts | 79 +++++++++++++--------------- 1 file changed, 36 insertions(+), 43 deletions(-) diff --git a/src/commands/auth-choice.e2e.test.ts b/src/commands/auth-choice.e2e.test.ts index 2d61c858e41..a886f01d27f 100644 --- a/src/commands/auth-choice.e2e.test.ts +++ b/src/commands/auth-choice.e2e.test.ts @@ -399,54 +399,47 @@ describe("applyAuthChoice", () => { expect(result.agentModelOverride).toBe("opencode/claude-opus-4-6"); }); - it("does not persist literal 'undefined' when Anthropic API key prompt returns undefined", async () => { - await setupTempState(); - delete process.env.ANTHROPIC_API_KEY; + it("does not persist literal 'undefined' when API key prompts return undefined", async () => { + const scenarios = [ + { + authChoice: "apiKey" as const, + envKey: "ANTHROPIC_API_KEY", + profileId: "anthropic:default", + provider: "anthropic", + }, + { + authChoice: "openrouter-api-key" as const, + envKey: "OPENROUTER_API_KEY", + profileId: "openrouter:default", + provider: "openrouter", + }, + ]; - const text = vi.fn(async () => undefined as unknown as string); - const prompter = createPrompter({ text }); - const runtime = createExitThrowingRuntime(); + for (const scenario of scenarios) { + await setupTempState(); + delete process.env[scenario.envKey]; - const result = await applyAuthChoice({ - authChoice: "apiKey", - config: {}, - prompter, - runtime, - setDefaultModel: false, - }); + const text = vi.fn(async () => undefined as unknown as string); + const prompter = createPrompter({ text }); + const runtime = createExitThrowingRuntime(); - expect(result.config.auth?.profiles?.["anthropic:default"]).toMatchObject({ - provider: "anthropic", - mode: "api_key", - }); + const result = await applyAuthChoice({ + authChoice: scenario.authChoice, + config: {}, + prompter, + runtime, + setDefaultModel: false, + }); - expect((await readAuthProfile("anthropic:default"))?.key).toBe(""); - expect((await readAuthProfile("anthropic:default"))?.key).not.toBe("undefined"); - }); + expect(result.config.auth?.profiles?.[scenario.profileId]).toMatchObject({ + provider: scenario.provider, + mode: "api_key", + }); - it("does not persist literal 'undefined' when OpenRouter API key prompt returns undefined", async () => { - await setupTempState(); - delete process.env.OPENROUTER_API_KEY; - - const text = vi.fn(async () => undefined as unknown as string); - const prompter = createPrompter({ text }); - const runtime = createExitThrowingRuntime(); - - const result = await applyAuthChoice({ - authChoice: "openrouter-api-key", - config: {}, - prompter, - runtime, - setDefaultModel: false, - }); - - expect(result.config.auth?.profiles?.["openrouter:default"]).toMatchObject({ - provider: "openrouter", - mode: "api_key", - }); - - expect((await readAuthProfile("openrouter:default"))?.key).toBe(""); - expect((await readAuthProfile("openrouter:default"))?.key).not.toBe("undefined"); + const profile = await readAuthProfile(scenario.profileId); + expect(profile?.key).toBe(""); + expect(profile?.key).not.toBe("undefined"); + } }); it("uses existing OPENROUTER_API_KEY when selecting openrouter-api-key", async () => { From c4c2060b81b73140734b36db0e91a853f17b0b4d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:26:03 +0000 Subject: [PATCH 0064/2904] test(agents): dedupe sessions_spawn requester run setup --- .../sessions-spawn-threadid.e2e.test.ts | 53 ++++++++----------- 1 file changed, 22 insertions(+), 31 deletions(-) diff --git a/src/agents/sessions-spawn-threadid.e2e.test.ts b/src/agents/sessions-spawn-threadid.e2e.test.ts index 9f57566c491..9dd46addac4 100644 --- a/src/agents/sessions-spawn-threadid.e2e.test.ts +++ b/src/agents/sessions-spawn-threadid.e2e.test.ts @@ -11,6 +11,24 @@ import { } from "./subagent-registry.js"; describe("sessions_spawn requesterOrigin threading", () => { + const spawnAndReadRequesterRun = async (opts?: { agentThreadId?: number }) => { + const tool = await getSessionsSpawnTool({ + agentSessionKey: "main", + agentChannel: "telegram", + agentTo: "telegram:123", + ...(opts?.agentThreadId === undefined ? {} : { agentThreadId: opts.agentThreadId }), + }); + const result = await tool.execute("call", { + task: "do thing", + runTimeoutSeconds: 1, + }); + expect(result.details).toMatchObject({ status: "accepted", runId: "run-1" }); + + const runs = listSubagentRunsForRequester("main"); + expect(runs).toHaveLength(1); + return runs[0]; + }; + beforeEach(() => { const callGatewayMock = getCallGatewayMock(); resetSubagentRegistryForTests(); @@ -36,22 +54,8 @@ describe("sessions_spawn requesterOrigin threading", () => { }); it("captures threadId in requesterOrigin", async () => { - const tool = await getSessionsSpawnTool({ - agentSessionKey: "main", - agentChannel: "telegram", - agentTo: "telegram:123", - agentThreadId: 42, - }); - - const result = await tool.execute("call", { - task: "do thing", - runTimeoutSeconds: 1, - }); - expect(result.details).toMatchObject({ status: "accepted", runId: "run-1" }); - - const runs = listSubagentRunsForRequester("main"); - expect(runs).toHaveLength(1); - expect(runs[0]?.requesterOrigin).toMatchObject({ + const run = await spawnAndReadRequesterRun({ agentThreadId: 42 }); + expect(run?.requesterOrigin).toMatchObject({ channel: "telegram", to: "telegram:123", threadId: 42, @@ -59,20 +63,7 @@ describe("sessions_spawn requesterOrigin threading", () => { }); it("stores requesterOrigin without threadId when none is provided", async () => { - const tool = await getSessionsSpawnTool({ - agentSessionKey: "main", - agentChannel: "telegram", - agentTo: "telegram:123", - }); - - const result = await tool.execute("call", { - task: "do thing", - runTimeoutSeconds: 1, - }); - expect(result.details).toMatchObject({ status: "accepted", runId: "run-1" }); - - const runs = listSubagentRunsForRequester("main"); - expect(runs).toHaveLength(1); - expect(runs[0]?.requesterOrigin?.threadId).toBeUndefined(); + const run = await spawnAndReadRequesterRun(); + expect(run?.requesterOrigin?.threadId).toBeUndefined(); }); }); From 3cfcb25999fa0d58d08fa4f30e421a2c3edfcf6b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:27:21 +0000 Subject: [PATCH 0065/2904] test(agents): dedupe transcript duplicate-tool fixtures --- .../session-transcript-repair.e2e.test.ts | 72 +++++++++---------- 1 file changed, 32 insertions(+), 40 deletions(-) diff --git a/src/agents/session-transcript-repair.e2e.test.ts b/src/agents/session-transcript-repair.e2e.test.ts index b87eed2ec6b..de988edf605 100644 --- a/src/agents/session-transcript-repair.e2e.test.ts +++ b/src/agents/session-transcript-repair.e2e.test.ts @@ -7,6 +7,32 @@ import { } from "./session-transcript-repair.js"; describe("sanitizeToolUseResultPairing", () => { + const buildDuplicateToolResultInput = (opts?: { + middleMessage?: unknown; + secondText?: string; + }): AgentMessage[] => + [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [{ type: "text", text: "first" }], + isError: false, + }, + ...(opts?.middleMessage ? [opts.middleMessage as AgentMessage] : []), + { + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [{ type: "text", text: opts?.secondText ?? "second" }], + isError: false, + }, + ] as unknown as AgentMessage[]; + it("moves tool results directly after tool calls and inserts missing results", () => { const input = [ { @@ -37,53 +63,19 @@ describe("sanitizeToolUseResultPairing", () => { it("drops duplicate tool results for the same id within a span", () => { const input = [ - { - role: "assistant", - content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }], - }, - { - role: "toolResult", - toolCallId: "call_1", - toolName: "read", - content: [{ type: "text", text: "first" }], - isError: false, - }, - { - role: "toolResult", - toolCallId: "call_1", - toolName: "read", - content: [{ type: "text", text: "second" }], - isError: false, - }, + ...buildDuplicateToolResultInput(), { role: "user", content: "ok" }, - ] as unknown as AgentMessage[]; + ] as AgentMessage[]; const out = sanitizeToolUseResultPairing(input); expect(out.filter((m) => m.role === "toolResult")).toHaveLength(1); }); it("drops duplicate tool results for the same id across the transcript", () => { - const input = [ - { - role: "assistant", - content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }], - }, - { - role: "toolResult", - toolCallId: "call_1", - toolName: "read", - content: [{ type: "text", text: "first" }], - isError: false, - }, - { role: "assistant", content: [{ type: "text", text: "ok" }] }, - { - role: "toolResult", - toolCallId: "call_1", - toolName: "read", - content: [{ type: "text", text: "second (duplicate)" }], - isError: false, - }, - ] as unknown as AgentMessage[]; + const input = buildDuplicateToolResultInput({ + middleMessage: { role: "assistant", content: [{ type: "text", text: "ok" }] }, + secondText: "second (duplicate)", + }); const out = sanitizeToolUseResultPairing(input); const results = out.filter((m) => m.role === "toolResult") as Array<{ From 7d12c5ea4d2d0ae5c32222630dc092f259f1e2aa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:30:26 +0000 Subject: [PATCH 0066/2904] test: remove duplicate extra-high think-level case --- src/auto-reply/thinking.test.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/auto-reply/thinking.test.ts b/src/auto-reply/thinking.test.ts index dd0523fcc3f..4588eee791e 100644 --- a/src/auto-reply/thinking.test.ts +++ b/src/auto-reply/thinking.test.ts @@ -30,11 +30,6 @@ describe("normalizeThinkLevel", () => { expect(normalizeThinkLevel("xhigher")).toBeUndefined(); }); - it("accepts extra-high aliases as xhigh", () => { - expect(normalizeThinkLevel("extra-high")).toBe("xhigh"); - expect(normalizeThinkLevel("extra high")).toBe("xhigh"); - }); - it("accepts on as low", () => { expect(normalizeThinkLevel("on")).toBe("low"); }); From cdee4333325be0e5a76c3dfcfd3a9e693c4ab8bf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:30:37 +0000 Subject: [PATCH 0067/2904] test(browser): dedupe explicit auth-mode auto-token checks --- src/browser/control-auth.auto-token.test.ts | 50 ++++++++------------- 1 file changed, 18 insertions(+), 32 deletions(-) diff --git a/src/browser/control-auth.auto-token.test.ts b/src/browser/control-auth.auto-token.test.ts index 41107b2cbf0..3fa03df89d9 100644 --- a/src/browser/control-auth.auto-token.test.ts +++ b/src/browser/control-auth.auto-token.test.ts @@ -18,6 +18,22 @@ vi.mock("../config/config.js", async (importOriginal) => { import { ensureBrowserControlAuth } from "./control-auth.js"; describe("ensureBrowserControlAuth", () => { + const expectExplicitModeSkipsAutoAuth = async (mode: "password" | "none") => { + const cfg: OpenClawConfig = { + gateway: { + auth: { mode }, + }, + browser: { + enabled: true, + }, + }; + + const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv }); + expect(result).toEqual({ auth: {} }); + expect(mocks.loadConfig).not.toHaveBeenCalled(); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }; + beforeEach(() => { vi.restoreAllMocks(); mocks.loadConfig.mockReset(); @@ -80,41 +96,11 @@ describe("ensureBrowserControlAuth", () => { }); it("respects explicit password mode", async () => { - const cfg: OpenClawConfig = { - gateway: { - auth: { - mode: "password", - }, - }, - browser: { - enabled: true, - }, - }; - - const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv }); - - expect(result).toEqual({ auth: {} }); - expect(mocks.loadConfig).not.toHaveBeenCalled(); - expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + await expectExplicitModeSkipsAutoAuth("password"); }); it("respects explicit none mode", async () => { - const cfg: OpenClawConfig = { - gateway: { - auth: { - mode: "none", - }, - }, - browser: { - enabled: true, - }, - }; - - const result = await ensureBrowserControlAuth({ cfg, env: {} as NodeJS.ProcessEnv }); - - expect(result).toEqual({ auth: {} }); - expect(mocks.loadConfig).not.toHaveBeenCalled(); - expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + await expectExplicitModeSkipsAutoAuth("none"); }); it("reuses auth from latest config snapshot", async () => { From e4bb6e044d3ff87b5959409255c10e87d39c0364 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:31:28 +0000 Subject: [PATCH 0068/2904] test(cron): dedupe delayed-timer job assertions --- src/cron/service.every-jobs-fire.test.ts | 66 +++++++++++++++--------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/src/cron/service.every-jobs-fire.test.ts b/src/cron/service.every-jobs-fire.test.ts index b97edc09955..f1ef2d9eeb4 100644 --- a/src/cron/service.every-jobs-fire.test.ts +++ b/src/cron/service.every-jobs-fire.test.ts @@ -14,6 +14,34 @@ const { makeStorePath } = createCronStoreHarness(); installCronTestHooks({ logger: noopLogger }); describe("CronService interval/cron jobs fire on time", () => { + const runLateTimerAndLoadJob = async ({ + cron, + finished, + jobId, + firstDueAt, + }: { + cron: CronService; + finished: { waitForOk: (id: string) => Promise }; + jobId: string; + firstDueAt: number; + }) => { + vi.setSystemTime(new Date(firstDueAt + 5)); + await vi.runOnlyPendingTimersAsync(); + await finished.waitForOk(jobId); + const jobs = await cron.list({ includeDisabled: true }); + return jobs.find((current) => current.id === jobId); + }; + + const expectMainSystemEvent = ( + enqueueSystemEvent: ReturnType, + expectedText: string, + ) => { + expect(enqueueSystemEvent).toHaveBeenCalledWith( + expectedText, + expect.objectContaining({ agentId: undefined }), + ); + }; + it("fires an every-type main job when the timer fires a few ms late", async () => { const store = await makeStorePath(); const { cron, enqueueSystemEvent, finished } = createStartedCronServiceWithFinishedBarrier({ @@ -34,18 +62,13 @@ describe("CronService interval/cron jobs fire on time", () => { const firstDueAt = job.state.nextRunAtMs!; expect(firstDueAt).toBe(Date.parse("2025-12-13T00:00:00.000Z") + 10_000); - // Simulate setTimeout firing 5ms late (the race condition). - vi.setSystemTime(new Date(firstDueAt + 5)); - await vi.runOnlyPendingTimersAsync(); - - await finished.waitForOk(job.id); - const jobs = await cron.list({ includeDisabled: true }); - const updated = jobs.find((current) => current.id === job.id); - - expect(enqueueSystemEvent).toHaveBeenCalledWith( - "tick", - expect.objectContaining({ agentId: undefined }), - ); + const updated = await runLateTimerAndLoadJob({ + cron, + finished, + jobId: job.id, + firstDueAt, + }); + expectMainSystemEvent(enqueueSystemEvent, "tick"); expect(updated?.state.lastStatus).toBe("ok"); // nextRunAtMs must advance by at least one full interval past the due time. expect(updated?.state.nextRunAtMs).toBeGreaterThanOrEqual(firstDueAt + 10_000); @@ -76,18 +99,13 @@ describe("CronService interval/cron jobs fire on time", () => { const firstDueAt = job.state.nextRunAtMs!; - // Simulate setTimeout firing 5ms late. - vi.setSystemTime(new Date(firstDueAt + 5)); - await vi.runOnlyPendingTimersAsync(); - - await finished.waitForOk(job.id); - const jobs = await cron.list({ includeDisabled: true }); - const updated = jobs.find((current) => current.id === job.id); - - expect(enqueueSystemEvent).toHaveBeenCalledWith( - "cron-tick", - expect.objectContaining({ agentId: undefined }), - ); + const updated = await runLateTimerAndLoadJob({ + cron, + finished, + jobId: job.id, + firstDueAt, + }); + expectMainSystemEvent(enqueueSystemEvent, "cron-tick"); expect(updated?.state.lastStatus).toBe("ok"); // nextRunAtMs should be the next whole-minute boundary (60s later). expect(updated?.state.nextRunAtMs).toBe(firstDueAt + 60_000); From 65cf56d48219b64489fe1e31877f13197206753e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:33:49 +0000 Subject: [PATCH 0069/2904] test(agents): dedupe generic repeat loop fixtures --- src/agents/pi-tools.before-tool-call.test.ts | 33 +++++++++----------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/src/agents/pi-tools.before-tool-call.test.ts b/src/agents/pi-tools.before-tool-call.test.ts index df5d9d3a2c2..3348c3e334d 100644 --- a/src/agents/pi-tools.before-tool-call.test.ts +++ b/src/agents/pi-tools.before-tool-call.test.ts @@ -109,6 +109,18 @@ describe("before_tool_call loop detection behavior", () => { } } } + + function createGenericReadRepeatFixture() { + const execute = vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "same output" }], + details: { ok: true }, + }); + return { + tool: createWrappedTool("read", execute), + params: { path: "/tmp/file" }, + }; + } + it("blocks known poll loops when no progress repeats", async () => { const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "(no new output)\n\nProcess still running." }], @@ -160,12 +172,7 @@ describe("before_tool_call loop detection behavior", () => { }); it("keeps generic repeated calls warn-only below global breaker", async () => { - const execute = vi.fn().mockResolvedValue({ - content: [{ type: "text", text: "same output" }], - details: { ok: true }, - }); - const tool = createWrappedTool("read", execute); - const params = { path: "/tmp/file" }; + const { tool, params } = createGenericReadRepeatFixture(); for (let i = 0; i < CRITICAL_THRESHOLD + 5; i += 1) { await expect(tool.execute(`read-${i}`, params, undefined, undefined)).resolves.toBeDefined(); @@ -173,12 +180,7 @@ describe("before_tool_call loop detection behavior", () => { }); it("blocks generic repeated no-progress calls at global breaker threshold", async () => { - const execute = vi.fn().mockResolvedValue({ - content: [{ type: "text", text: "same output" }], - details: { ok: true }, - }); - const tool = createWrappedTool("read", execute); - const params = { path: "/tmp/file" }; + const { tool, params } = createGenericReadRepeatFixture(); for (let i = 0; i < GLOBAL_CIRCUIT_BREAKER_THRESHOLD; i += 1) { await expect(tool.execute(`read-${i}`, params, undefined, undefined)).resolves.toBeDefined(); @@ -192,12 +194,7 @@ describe("before_tool_call loop detection behavior", () => { it("coalesces repeated generic warning events into threshold buckets", async () => { await withToolLoopEvents( async (emitted) => { - const execute = vi.fn().mockResolvedValue({ - content: [{ type: "text", text: "same output" }], - details: { ok: true }, - }); - const tool = createWrappedTool("read", execute); - const params = { path: "/tmp/file" }; + const { tool, params } = createGenericReadRepeatFixture(); for (let i = 0; i < 21; i += 1) { await tool.execute(`read-bucket-${i}`, params, undefined, undefined); From 647a46a06161c34a05cb94212983deca62840735 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:36:03 +0000 Subject: [PATCH 0070/2904] ci: skip bun setup for windows checks --- .github/workflows/ci.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d38391db2f..a054cd61ab7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -370,16 +370,10 @@ jobs: pnpm-version: "10.23.0" cache-key-suffix: "node22" - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: "1.3.9" - - name: Runtime versions run: | node -v npm -v - bun -v pnpm -v - name: Capture node path From 072b16b58f7de06ffc6eb4cb3dd8961d59d37cfb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:37:36 +0000 Subject: [PATCH 0071/2904] ci: use git context for docker metadata extraction --- .github/workflows/docker-release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 05e63005dd5..80f10f4b886 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -49,6 +49,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: + context: git images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=ref,event=branch @@ -98,6 +99,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: + context: git images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=ref,event=branch @@ -128,6 +130,9 @@ jobs: contents: read needs: [build-amd64, build-arm64] steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: @@ -139,6 +144,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: + context: git images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=ref,event=branch From 64546d33eeef85437f475fe366142b04e44ad5ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:36:48 +0000 Subject: [PATCH 0072/2904] test(cli): dedupe cron edit existing-job lookup mocks --- src/cli/cron-cli.test.ts | 55 +++++++++++++++------------------------- 1 file changed, 20 insertions(+), 35 deletions(-) diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index f347f061b92..86bc0b01c6d 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -91,6 +91,24 @@ async function runCronSimpleAndGetUpdatePatch( }; } +function mockCronEditJobLookup(schedule: unknown): void { + callGatewayFromCli.mockImplementation( + async (method: string, _opts: unknown, params?: unknown) => { + if (method === "cron.status") { + return { enabled: true }; + } + if (method === "cron.list") { + return { + ok: true, + params: {}, + jobs: [{ id: "job-1", schedule }], + }; + } + return { ok: true, params }; + }, + ); +} + describe("cron cli", () => { it("trims model and thinking on cron add", { timeout: 60_000 }, async () => { resetGatewayMock(); @@ -535,26 +553,7 @@ describe("cron cli", () => { it("applies --exact to existing cron job without requiring --cron on edit", async () => { resetGatewayMock(); - callGatewayFromCli.mockImplementation( - async (method: string, _opts: unknown, params?: unknown) => { - if (method === "cron.status") { - return { enabled: true }; - } - if (method === "cron.list") { - return { - ok: true, - params: {}, - jobs: [ - { - id: "job-1", - schedule: { kind: "cron", expr: "0 */2 * * *", tz: "UTC", staggerMs: 300_000 }, - }, - ], - }; - } - return { ok: true, params }; - }, - ); + mockCronEditJobLookup({ kind: "cron", expr: "0 */2 * * *", tz: "UTC", staggerMs: 300_000 }); const program = buildProgram(); await program.parseAsync(["cron", "edit", "job-1", "--exact"], { from: "user" }); @@ -573,21 +572,7 @@ describe("cron cli", () => { it("rejects --exact on edit when existing job is not cron", async () => { resetGatewayMock(); - callGatewayFromCli.mockImplementation( - async (method: string, _opts: unknown, params?: unknown) => { - if (method === "cron.status") { - return { enabled: true }; - } - if (method === "cron.list") { - return { - ok: true, - params: {}, - jobs: [{ id: "job-1", schedule: { kind: "every", everyMs: 60_000 } }], - }; - } - return { ok: true, params }; - }, - ); + mockCronEditJobLookup({ kind: "every", everyMs: 60_000 }); const program = buildProgram(); await expect( From 13f2fa0c5c430a491adc7a83531392b723fd8980 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:41:25 +0000 Subject: [PATCH 0073/2904] ci: avoid bun setup API flake in node checks --- .github/actions/setup-node-env/action.yml | 2 +- .github/workflows/ci.yml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/actions/setup-node-env/action.yml b/.github/actions/setup-node-env/action.yml index d21bc987e25..a722982004b 100644 --- a/.github/actions/setup-node-env/action.yml +++ b/.github/actions/setup-node-env/action.yml @@ -52,7 +52,7 @@ runs: if: inputs.install-bun == 'true' uses: oven-sh/setup-bun@v2 with: - bun-version: "1.3.9" + bun-version: "1.3.9+cf6cdbbba" - name: Runtime versions shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a054cd61ab7..12457ff78d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -199,6 +199,8 @@ jobs: - name: Setup Node environment uses: ./.github/actions/setup-node-env + with: + install-bun: "${{ matrix.runtime == 'bun' }}" - name: Configure vitest JSON reports if: matrix.task == 'test' && matrix.runtime == 'node' From 2ddc13cdb7cc2de5b83daa2e64b0bf51a747c2bd Mon Sep 17 00:00:00 2001 From: orlyjamie <6668807+orlyjamie@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:03:37 +1100 Subject: [PATCH 0074/2904] feat(ui): add update warning banner to control dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SecurityScorecard's STRIKE research recently identified over 40,000 exposed OpenClaw gateway instances, with 35.4% running known-vulnerable versions. The gateway already performs an npm update check on startup and compares against the registry every 24 hours — but the result is only logged to the server console. The control UI has zero visibility into whether the running version is outdated, which means operators have no idea they're exposed unless they happen to read server logs. OpenClaw's user base is broadening well beyond developers who live in terminals. Self-hosters, small teams, and non-technical operators are deploying gateways and relying on the control dashboard as their primary management interface. For these users, security has to be surfaced where they already are — not hidden behind CLI output they will never see. Making version awareness frictionless and actionable is a prerequisite for reducing that 35.4% number. This PR adds a sticky red warning banner to the top of the control UI content area whenever the gateway detects it is running behind the latest published version. The banner includes an "Update now" button wired to the existing update.run RPC (the same mechanism the config page already uses), so operators can act immediately without switching to a terminal. Server side: - Cache the update check result in a module-level variable with a typed UpdateAvailable shape (currentVersion, latestVersion, channel) - Export a getUpdateAvailable() getter for the rest of the process - Add an optional updateAvailable field to SnapshotSchema (backward compatible — old clients ignore it, old servers simply omit it) - Include the cached update status in buildGatewaySnapshot() so it is delivered to every UI client on connect and reconnect UI side: - Add updateAvailable to GatewayHost, AppViewState, and the app's reactive state so it flows through the standard snapshot pipeline - Extract updateAvailable from the hello snapshot in applySnapshot() - Render a .update-banner.callout.danger element with role="alert" as the first child of
, before the content header - Wire the "Update now" button to runUpdate(state), the same controller function used by the config tab - Use position:sticky and negative margins to pin the banner edge-to-edge at the top of the scrollable content area --- src/gateway/protocol/schema/snapshot.ts | 7 +++++ src/gateway/server/health-state.ts | 3 +++ src/infra/update-startup.ts | 17 ++++++++++++ ui/src/styles/components.css | 36 +++++++++++++++++++++++++ ui/src/ui/app-gateway.ts | 3 +++ ui/src/ui/app-render.ts | 11 ++++++++ ui/src/ui/app-view-state.ts | 1 + ui/src/ui/app.ts | 6 +++++ 8 files changed, 84 insertions(+) diff --git a/src/gateway/protocol/schema/snapshot.ts b/src/gateway/protocol/schema/snapshot.ts index 1ac6ebc1a85..98e31826045 100644 --- a/src/gateway/protocol/schema/snapshot.ts +++ b/src/gateway/protocol/schema/snapshot.ts @@ -60,6 +60,13 @@ export const SnapshotSchema = Type.Object( Type.Literal("trusted-proxy"), ]), ), + updateAvailable: Type.Optional( + Type.Object({ + currentVersion: NonEmptyString, + latestVersion: NonEmptyString, + channel: NonEmptyString, + }), + ), }, { additionalProperties: false }, ); diff --git a/src/gateway/server/health-state.ts b/src/gateway/server/health-state.ts index 3e2ef9522d9..5d875388149 100644 --- a/src/gateway/server/health-state.ts +++ b/src/gateway/server/health-state.ts @@ -2,6 +2,7 @@ import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { getHealthSnapshot, type HealthSummary } from "../../commands/health.js"; import { CONFIG_PATH, STATE_DIR, loadConfig } from "../../config/config.js"; import { resolveMainSessionKey } from "../../config/sessions.js"; +import { getUpdateAvailable } from "../../infra/update-startup.js"; import { listSystemPresence } from "../../infra/system-presence.js"; import { normalizeMainKey } from "../../routing/session-key.js"; import { resolveGatewayAuth } from "../auth.js"; @@ -22,6 +23,7 @@ export function buildGatewaySnapshot(): Snapshot { const presence = listSystemPresence(); const uptimeMs = Math.round(process.uptime() * 1000); const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env }); + const updateAvailable = getUpdateAvailable() ?? undefined; // Health is async; caller should await getHealthSnapshot and replace later if needed. const emptyHealth: unknown = {}; return { @@ -39,6 +41,7 @@ export function buildGatewaySnapshot(): Snapshot { scope, }, authMode: auth.mode, + updateAvailable, }; } diff --git a/src/infra/update-startup.ts b/src/infra/update-startup.ts index 7ef7c5c40f6..5739c38cab8 100644 --- a/src/infra/update-startup.ts +++ b/src/infra/update-startup.ts @@ -14,6 +14,18 @@ type UpdateCheckState = { lastNotifiedTag?: string; }; +type UpdateAvailable = { + currentVersion: string; + latestVersion: string; + channel: string; +}; + +let updateAvailableCache: UpdateAvailable | null = null; + +export function getUpdateAvailable(): UpdateAvailable | null { + return updateAvailableCache; +} + const UPDATE_CHECK_FILENAME = "update-check.json"; const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; @@ -100,6 +112,11 @@ export async function runGatewayUpdateCheck(params: { const cmp = compareSemverStrings(VERSION, resolved.version); if (cmp != null && cmp < 0) { + updateAvailableCache = { + currentVersion: VERSION, + latestVersion: resolved.version, + channel: tag, + }; const shouldNotify = state.lastNotifiedVersion !== resolved.version || state.lastNotifiedTag !== tag; if (shouldNotify) { diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 0b1d56ef773..77f1212919b 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1,5 +1,41 @@ @import "./chat.css"; +/* =========================================== + Update Banner + =========================================== */ + +.update-banner { + position: sticky; + top: 0; + z-index: 10; + margin: 0 calc(-1 * var(--shell-pad)) 0; + border-radius: 0; + border-left: none; + border-right: none; + text-align: center; + font-weight: 500; + padding: 10px 16px; +} + +.update-banner code { + background: rgba(239, 68, 68, 0.15); + padding: 2px 6px; + border-radius: 4px; + font-size: 12px; +} + +.update-banner__btn { + margin-left: 8px; + border-color: var(--danger); + color: var(--danger); + font-size: 12px; + padding: 4px 12px; +} + +.update-banner__btn:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.15); +} + /* =========================================== Cards - Refined with depth =========================================== */ diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index aaa02c44405..8200c797577 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -54,6 +54,7 @@ type GatewayHost = { refreshSessionsAfterChat: Set; execApprovalQueue: ExecApprovalRequest[]; execApprovalError: string | null; + updateAvailable: { currentVersion: string; latestVersion: string; channel: string } | null; }; type SessionDefaultsSnapshot = { @@ -278,6 +279,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) { presence?: PresenceEntry[]; health?: HealthSnapshot; sessionDefaults?: SessionDefaultsSnapshot; + updateAvailable?: { currentVersion: string; latestVersion: string; channel: string }; } | undefined; if (snapshot?.presence && Array.isArray(snapshot.presence)) { @@ -289,4 +291,5 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) { if (snapshot?.sessionDefaults) { applySessionDefaults(host, snapshot.sessionDefaults); } + host.updateAvailable = snapshot?.updateAvailable ?? null; } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index ee47ea94a9e..c5e7dc435f9 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -188,6 +188,17 @@ export function renderApp(state: AppViewState) {
+ ${state.updateAvailable + ? html`` + : nothing}
${state.tab === "usage" ? nothing : html`
${titleForTab(state.tab)}
`} diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index ea41b4b073c..ff6b0042948 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -221,6 +221,7 @@ export type AppViewState = { logsLimit: number; logsMaxBytes: number; logsAtBottom: boolean; + updateAvailable: { currentVersion: string; latestVersion: string; channel: string } | null; client: GatewayBrowserClient | null; refreshSessionsAfterChat: Set; connect: () => void; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 84e39067bad..22ca6d1d4bf 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -300,6 +300,12 @@ export class OpenClawApp extends LitElement { @state() cronRuns: CronRunLogEntry[] = []; @state() cronBusy = false; + @state() updateAvailable: { + currentVersion: string; + latestVersion: string; + channel: string; + } | null = null; + @state() skillsLoading = false; @state() skillsReport: SkillStatusReport | null = null; @state() skillsError: string | null = null; From 586b1f6ee698816bbdf988074c925456d31b8fc2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:44:23 +0000 Subject: [PATCH 0075/2904] ci: drop docker metadata action to avoid API throttling --- .github/workflows/docker-release.yml | 127 +++++++++++++++++---------- 1 file changed, 83 insertions(+), 44 deletions(-) diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 80f10f4b886..28d6164bc56 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -30,7 +30,6 @@ jobs: contents: read outputs: image-digest: ${{ steps.build.outputs.digest }} - image-metadata: ${{ steps.meta.outputs.json }} steps: - name: Checkout uses: actions/checkout@v4 @@ -45,19 +44,30 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - context: git - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=ref,event=branch - type=semver,pattern={{version}} - type=semver,pattern={{version}},suffix=-amd64 - type=semver,pattern={{version}},suffix=-arm64 - type=ref,event=branch,suffix=-amd64 - type=ref,event=branch,suffix=-arm64 + - name: Resolve image tags (amd64) + id: tags + shell: bash + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + run: | + set -euo pipefail + tags=() + if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then + tags+=("${IMAGE}:main-amd64") + fi + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + version="${GITHUB_REF#refs/tags/v}" + tags+=("${IMAGE}:${version}-amd64") + fi + if [[ ${#tags[@]} -eq 0 ]]; then + echo "::error::No amd64 tags resolved for ref ${GITHUB_REF}" + exit 1 + fi + { + echo "value<> "$GITHUB_OUTPUT" - name: Build and push amd64 image id: build @@ -65,8 +75,7 @@ jobs: with: context: . platforms: linux/amd64 - labels: ${{ steps.meta.outputs.labels }} - tags: ${{ steps.meta.outputs.tags }} + tags: ${{ steps.tags.outputs.value }} cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64 cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64,mode=max provenance: false @@ -80,7 +89,6 @@ jobs: contents: read outputs: image-digest: ${{ steps.build.outputs.digest }} - image-metadata: ${{ steps.meta.outputs.json }} steps: - name: Checkout uses: actions/checkout@v4 @@ -95,19 +103,30 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - context: git - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=ref,event=branch - type=semver,pattern={{version}} - type=semver,pattern={{version}},suffix=-amd64 - type=semver,pattern={{version}},suffix=-arm64 - type=ref,event=branch,suffix=-amd64 - type=ref,event=branch,suffix=-arm64 + - name: Resolve image tags (arm64) + id: tags + shell: bash + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + run: | + set -euo pipefail + tags=() + if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then + tags+=("${IMAGE}:main-arm64") + fi + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + version="${GITHUB_REF#refs/tags/v}" + tags+=("${IMAGE}:${version}-arm64") + fi + if [[ ${#tags[@]} -eq 0 ]]; then + echo "::error::No arm64 tags resolved for ref ${GITHUB_REF}" + exit 1 + fi + { + echo "value<> "$GITHUB_OUTPUT" - name: Build and push arm64 image id: build @@ -115,8 +134,7 @@ jobs: with: context: . platforms: linux/arm64 - labels: ${{ steps.meta.outputs.labels }} - tags: ${{ steps.meta.outputs.tags }} + tags: ${{ steps.tags.outputs.value }} cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64 cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64,mode=max provenance: false @@ -140,20 +158,41 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata for manifest - id: meta - uses: docker/metadata-action@v5 - with: - context: git - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=ref,event=branch - type=semver,pattern={{version}} + - name: Resolve manifest tags + id: tags + shell: bash + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + run: | + set -euo pipefail + tags=() + if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then + tags+=("${IMAGE}:main") + fi + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + version="${GITHUB_REF#refs/tags/v}" + tags+=("${IMAGE}:${version}") + fi + if [[ ${#tags[@]} -eq 0 ]]; then + echo "::error::No manifest tags resolved for ref ${GITHUB_REF}" + exit 1 + fi + { + echo "value<> "$GITHUB_OUTPUT" - name: Create and push manifest + shell: bash run: | - docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + set -euo pipefail + mapfile -t tags <<< "${{ steps.tags.outputs.value }}" + args=() + for tag in "${tags[@]}"; do + [ -z "$tag" ] && continue + args+=("-t" "$tag") + done + docker buildx imagetools create "${args[@]}" \ ${{ needs.build-amd64.outputs.image-digest }} \ ${{ needs.build-arm64.outputs.image-digest }} - env: - DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }} From 429b8783fda13ab34786749c33cdd6bebf820050 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:43:29 +0000 Subject: [PATCH 0076/2904] test(agents): dedupe avatar and compaction fixtures --- src/agents/compaction.e2e.test.ts | 69 ++++++++------------------ src/agents/identity-avatar.e2e.test.ts | 44 +++++++++++----- 2 files changed, 53 insertions(+), 60 deletions(-) diff --git a/src/agents/compaction.e2e.test.ts b/src/agents/compaction.e2e.test.ts index 877a48f8a11..de5f4ec4dba 100644 --- a/src/agents/compaction.e2e.test.ts +++ b/src/agents/compaction.e2e.test.ts @@ -14,14 +14,25 @@ function makeMessage(id: number, size: number): AgentMessage { }; } +function makeMessages(count: number, size: number): AgentMessage[] { + return Array.from({ length: count }, (_, index) => makeMessage(index + 1, size)); +} + +function pruneLargeSimpleHistory() { + const messages = makeMessages(4, 4000); + const maxContextTokens = 2000; // budget is 1000 tokens (50%) + const pruned = pruneHistoryForContextShare({ + messages, + maxContextTokens, + maxHistoryShare: 0.5, + parts: 2, + }); + return { messages, pruned, maxContextTokens }; +} + describe("splitMessagesByTokenShare", () => { it("splits messages into two non-empty parts", () => { - const messages: AgentMessage[] = [ - makeMessage(1, 4000), - makeMessage(2, 4000), - makeMessage(3, 4000), - makeMessage(4, 4000), - ]; + const messages = makeMessages(4, 4000); const parts = splitMessagesByTokenShare(messages, 2); expect(parts.length).toBeGreaterThanOrEqual(2); @@ -31,14 +42,7 @@ describe("splitMessagesByTokenShare", () => { }); it("preserves message order across parts", () => { - const messages: AgentMessage[] = [ - makeMessage(1, 4000), - makeMessage(2, 4000), - makeMessage(3, 4000), - makeMessage(4, 4000), - makeMessage(5, 4000), - makeMessage(6, 4000), - ]; + const messages = makeMessages(6, 4000); const parts = splitMessagesByTokenShare(messages, 3); expect(parts.flat().map((msg) => msg.timestamp)).toEqual(messages.map((msg) => msg.timestamp)); @@ -47,19 +51,7 @@ describe("splitMessagesByTokenShare", () => { describe("pruneHistoryForContextShare", () => { it("drops older chunks until the history budget is met", () => { - const messages: AgentMessage[] = [ - makeMessage(1, 4000), - makeMessage(2, 4000), - makeMessage(3, 4000), - makeMessage(4, 4000), - ]; - const maxContextTokens = 2000; // budget is 1000 tokens (50%) - const pruned = pruneHistoryForContextShare({ - messages, - maxContextTokens, - maxHistoryShare: 0.5, - parts: 2, - }); + const { pruned, maxContextTokens } = pruneLargeSimpleHistory(); expect(pruned.droppedChunks).toBeGreaterThan(0); expect(pruned.keptTokens).toBeLessThanOrEqual(Math.floor(maxContextTokens * 0.5)); @@ -67,14 +59,7 @@ describe("pruneHistoryForContextShare", () => { }); it("keeps the newest messages when pruning", () => { - const messages: AgentMessage[] = [ - makeMessage(1, 4000), - makeMessage(2, 4000), - makeMessage(3, 4000), - makeMessage(4, 4000), - makeMessage(5, 4000), - makeMessage(6, 4000), - ]; + const messages = makeMessages(6, 4000); const totalTokens = estimateMessagesTokens(messages); const maxContextTokens = Math.max(1, Math.floor(totalTokens * 0.5)); // budget = 25% const pruned = pruneHistoryForContextShare({ @@ -110,19 +95,7 @@ describe("pruneHistoryForContextShare", () => { // When orphaned tool_results exist, droppedMessages may exceed // droppedMessagesList.length since orphans are counted but not // added to the list (they lack context for summarization). - const messages: AgentMessage[] = [ - makeMessage(1, 4000), - makeMessage(2, 4000), - makeMessage(3, 4000), - makeMessage(4, 4000), - ]; - const maxContextTokens = 2000; // budget is 1000 tokens (50%) - const pruned = pruneHistoryForContextShare({ - messages, - maxContextTokens, - maxHistoryShare: 0.5, - parts: 2, - }); + const { messages, pruned } = pruneLargeSimpleHistory(); expect(pruned.droppedChunks).toBeGreaterThan(0); // Without orphaned tool_results, counts match exactly diff --git a/src/agents/identity-avatar.e2e.test.ts b/src/agents/identity-avatar.e2e.test.ts index bb9404395f3..2e06c545ff7 100644 --- a/src/agents/identity-avatar.e2e.test.ts +++ b/src/agents/identity-avatar.e2e.test.ts @@ -10,6 +10,20 @@ async function writeFile(filePath: string, contents = "avatar") { await fs.writeFile(filePath, contents, "utf-8"); } +async function expectLocalAvatarPath( + cfg: OpenClawConfig, + workspace: string, + expectedRelativePath: string, +) { + const workspaceReal = await fs.realpath(workspace); + const resolved = resolveAgentAvatar(cfg, "main"); + expect(resolved.kind).toBe("local"); + if (resolved.kind === "local") { + const resolvedReal = await fs.realpath(resolved.filePath); + expect(path.relative(workspaceReal, resolvedReal)).toBe(expectedRelativePath); + } +} + describe("resolveAgentAvatar", () => { it("resolves local avatar from config when inside workspace", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-avatar-")); @@ -29,13 +43,7 @@ describe("resolveAgentAvatar", () => { }, }; - const workspaceReal = await fs.realpath(workspace); - const resolved = resolveAgentAvatar(cfg, "main"); - expect(resolved.kind).toBe("local"); - if (resolved.kind === "local") { - const resolvedReal = await fs.realpath(resolved.filePath); - expect(path.relative(workspaceReal, resolvedReal)).toBe(path.join("avatars", "main.png")); - } + await expectLocalAvatarPath(cfg, workspace, path.join("avatars", "main.png")); }); it("rejects avatars outside the workspace", async () => { @@ -82,12 +90,24 @@ describe("resolveAgentAvatar", () => { }, }; - const workspaceReal = await fs.realpath(workspace); + await expectLocalAvatarPath(cfg, workspace, path.join("avatars", "fallback.png")); + }); + + it("returns missing for non-existent local avatar files", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-avatar-")); + const workspace = path.join(root, "work"); + await fs.mkdir(workspace, { recursive: true }); + + const cfg: OpenClawConfig = { + agents: { + list: [{ id: "main", workspace, identity: { avatar: "avatars/missing.png" } }], + }, + }; + const resolved = resolveAgentAvatar(cfg, "main"); - expect(resolved.kind).toBe("local"); - if (resolved.kind === "local") { - const resolvedReal = await fs.realpath(resolved.filePath); - expect(path.relative(workspaceReal, resolvedReal)).toBe(path.join("avatars", "fallback.png")); + expect(resolved.kind).toBe("none"); + if (resolved.kind === "none") { + expect(resolved.reason).toBe("missing"); } }); From 50805d89776cf2eeda5b4b09a947d08aec3fed4b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:43:35 +0000 Subject: [PATCH 0077/2904] test(agents): dedupe patch and cli credential assertions --- src/agents/apply-patch.e2e.test.ts | 41 +++++++++++++++++---------- src/agents/cli-credentials.test.ts | 45 +++++++++++------------------- 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/src/agents/apply-patch.e2e.test.ts b/src/agents/apply-patch.e2e.test.ts index 99990fcb823..5a2dae87e75 100644 --- a/src/agents/apply-patch.e2e.test.ts +++ b/src/agents/apply-patch.e2e.test.ts @@ -13,6 +13,23 @@ async function withTempDir(fn: (dir: string) => Promise) { } } +function buildAddFilePatch(targetPath: string): string { + return `*** Begin Patch +*** Add File: ${targetPath} ++escaped +*** End Patch`; +} + +async function expectOutsideWriteRejected(params: { + dir: string; + patchTargetPath: string; + outsidePath: string; +}) { + const patch = buildAddFilePatch(params.patchTargetPath); + await expect(applyPatch(patch, { cwd: params.dir })).rejects.toThrow(/Path escapes sandbox root/); + await expect(fs.readFile(params.outsidePath, "utf8")).rejects.toBeDefined(); +} + describe("applyPatch", () => { it("adds a file", async () => { await withTempDir(async (dir) => { @@ -79,14 +96,12 @@ describe("applyPatch", () => { ); const relativeEscape = path.relative(dir, escapedPath); - const patch = `*** Begin Patch -*** Add File: ${relativeEscape} -+escaped -*** End Patch`; - try { - await expect(applyPatch(patch, { cwd: dir })).rejects.toThrow(/Path escapes sandbox root/); - await expect(fs.readFile(escapedPath, "utf8")).rejects.toBeDefined(); + await expectOutsideWriteRejected({ + dir, + patchTargetPath: relativeEscape, + outsidePath: escapedPath, + }); } finally { await fs.rm(escapedPath, { force: true }); } @@ -97,14 +112,12 @@ describe("applyPatch", () => { await withTempDir(async (dir) => { const escapedPath = path.join(os.tmpdir(), `openclaw-apply-patch-${Date.now()}.txt`); - const patch = `*** Begin Patch -*** Add File: ${escapedPath} -+escaped -*** End Patch`; - try { - await expect(applyPatch(patch, { cwd: dir })).rejects.toThrow(/Path escapes sandbox root/); - await expect(fs.readFile(escapedPath, "utf8")).rejects.toBeDefined(); + await expectOutsideWriteRejected({ + dir, + patchTargetPath: escapedPath, + outsidePath: escapedPath, + }); } finally { await fs.rm(escapedPath, { force: true }); } diff --git a/src/agents/cli-credentials.test.ts b/src/agents/cli-credentials.test.ts index ad62fd67732..909d69ff387 100644 --- a/src/agents/cli-credentials.test.ts +++ b/src/agents/cli-credentials.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const execSyncMock = vi.fn(); const execFileSyncMock = vi.fn(); +const CLI_CREDENTIALS_CACHE_TTL_MS = 15 * 60 * 1000; function mockExistingClaudeKeychainItem() { execFileSyncMock.mockImplementation((file: unknown, args: unknown) => { @@ -31,6 +32,16 @@ function getAddGenericPasswordCall() { ); } +async function readCachedClaudeCliCredentials(allowKeychainPrompt: boolean) { + const { readClaudeCliCredentialsCached } = await import("./cli-credentials.js"); + return readClaudeCliCredentialsCached({ + allowKeychainPrompt, + ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS, + platform: "darwin", + execSync: execSyncMock, + }); +} + describe("cli credentials", () => { beforeEach(() => { vi.useFakeTimers(); @@ -189,20 +200,8 @@ describe("cli credentials", () => { vi.setSystemTime(new Date("2025-01-01T00:00:00Z")); - const { readClaudeCliCredentialsCached } = await import("./cli-credentials.js"); - - const first = readClaudeCliCredentialsCached({ - allowKeychainPrompt: true, - ttlMs: 15 * 60 * 1000, - platform: "darwin", - execSync: execSyncMock, - }); - const second = readClaudeCliCredentialsCached({ - allowKeychainPrompt: false, - ttlMs: 15 * 60 * 1000, - platform: "darwin", - execSync: execSyncMock, - }); + const first = await readCachedClaudeCliCredentials(true); + const second = await readCachedClaudeCliCredentials(false); expect(first).toBeTruthy(); expect(second).toEqual(first); @@ -222,23 +221,11 @@ describe("cli credentials", () => { vi.setSystemTime(new Date("2025-01-01T00:00:00Z")); - const { readClaudeCliCredentialsCached } = await import("./cli-credentials.js"); + const first = await readCachedClaudeCliCredentials(true); - const first = readClaudeCliCredentialsCached({ - allowKeychainPrompt: true, - ttlMs: 15 * 60 * 1000, - platform: "darwin", - execSync: execSyncMock, - }); + vi.advanceTimersByTime(CLI_CREDENTIALS_CACHE_TTL_MS + 1); - vi.advanceTimersByTime(15 * 60 * 1000 + 1); - - const second = readClaudeCliCredentialsCached({ - allowKeychainPrompt: true, - ttlMs: 15 * 60 * 1000, - platform: "darwin", - execSync: execSyncMock, - }); + const second = await readCachedClaudeCliCredentials(true); expect(first).toBeTruthy(); expect(second).toBeTruthy(); From c9b5def1b85e3bad61075af5658ad828ee243706 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:43:41 +0000 Subject: [PATCH 0078/2904] test(agents): dedupe openai reasoning replay fixtures --- .../openai-responses.reasoning-replay.test.ts | 94 +++++++++---------- 1 file changed, 43 insertions(+), 51 deletions(-) diff --git a/src/agents/openai-responses.reasoning-replay.test.ts b/src/agents/openai-responses.reasoning-replay.test.ts index 68cb352d02d..b5ccc50e4b4 100644 --- a/src/agents/openai-responses.reasoning-replay.test.ts +++ b/src/agents/openai-responses.reasoning-replay.test.ts @@ -30,6 +30,43 @@ function extractInputTypes(input: unknown[]) { .filter((t): t is string => typeof t === "string"); } +const ZERO_USAGE = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +} as const; + +function buildReasoningPart(id = "rs_test") { + return { + type: "thinking" as const, + thinking: "internal", + thinkingSignature: JSON.stringify({ + type: "reasoning", + id, + summary: [], + }), + }; +} + +function buildAssistantMessage(params: { + stopReason: AssistantMessage["stopReason"]; + content: AssistantMessage["content"]; +}): AssistantMessage { + return { + role: "assistant", + api: "openai-responses", + provider: "openai", + model: "gpt-5.2", + usage: ZERO_USAGE, + stopReason: params.stopReason, + timestamp: Date.now(), + content: params.content, + }; +} + async function runAbortedOpenAIResponsesStream(params: { messages: Array< AssistantMessage | ToolResultMessage | { role: "user"; content: string; timestamp: number } @@ -70,31 +107,10 @@ async function runAbortedOpenAIResponsesStream(params: { describe("openai-responses reasoning replay", () => { it("replays reasoning for tool-call-only turns (OpenAI requires it)", async () => { - const assistantToolOnly: AssistantMessage = { - role: "assistant", - api: "openai-responses", - provider: "openai", - model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, + const assistantToolOnly = buildAssistantMessage({ stopReason: "toolUse", - timestamp: Date.now(), content: [ - { - type: "thinking", - thinking: "internal", - thinkingSignature: JSON.stringify({ - type: "reasoning", - id: "rs_test", - summary: [], - }), - }, + buildReasoningPart(), { type: "toolCall", id: "call_123|fc_123", @@ -102,7 +118,7 @@ describe("openai-responses reasoning replay", () => { arguments: {}, }, ], - }; + }); const toolResult: ToolResultMessage = { role: "toolResult", @@ -152,34 +168,10 @@ describe("openai-responses reasoning replay", () => { }); it("still replays reasoning when paired with an assistant message", async () => { - const assistantWithText: AssistantMessage = { - role: "assistant", - api: "openai-responses", - provider: "openai", - model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, + const assistantWithText = buildAssistantMessage({ stopReason: "stop", - timestamp: Date.now(), - content: [ - { - type: "thinking", - thinking: "internal", - thinkingSignature: JSON.stringify({ - type: "reasoning", - id: "rs_test", - summary: [], - }), - }, - { type: "text", text: "hello", textSignature: "msg_test" }, - ], - }; + content: [buildReasoningPart(), { type: "text", text: "hello", textSignature: "msg_test" }], + }); const { types } = await runAbortedOpenAIResponsesStream({ messages: [ From d1cb779f5fc0bdcf52fa932017a5cd5f9a637cb4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:47:14 +0000 Subject: [PATCH 0079/2904] test(agents): dedupe embedded runner and sessions lifecycle fixtures --- ...gents.sessions-spawn.lifecycle.e2e.test.ts | 38 +++---- ...pi-embedded-runner-extraparams.e2e.test.ts | 98 +++++++------------ 2 files changed, 54 insertions(+), 82 deletions(-) diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts index e65af829cd7..b3fbdacf152 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts @@ -148,6 +148,20 @@ function expectSingleCompletionSend( expect(send?.message).toBe(expected.message); } +function createDeleteCleanupHooks(setDeletedKey: (key: string | undefined) => void) { + return { + onAgentSubagentSpawn: (params: unknown) => { + const rec = params as { channel?: string; timeout?: number } | undefined; + expect(rec?.channel).toBe("discord"); + expect(rec?.timeout).toBe(1); + }, + onSessionsDelete: (params: unknown) => { + const rec = params as { key?: string } | undefined; + setDeletedKey(rec?.key); + }, + }; +} + describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { beforeEach(() => { resetSessionsSpawnConfigOverride(); @@ -231,15 +245,9 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { callGatewayMock.mockReset(); let deletedKey: string | undefined; const ctx = setupSessionsSpawnGatewayMock({ - onAgentSubagentSpawn: (params) => { - const rec = params as { channel?: string; timeout?: number } | undefined; - expect(rec?.channel).toBe("discord"); - expect(rec?.timeout).toBe(1); - }, - onSessionsDelete: (params) => { - const rec = params as { key?: string } | undefined; - deletedKey = rec?.key; - }, + ...createDeleteCleanupHooks((key) => { + deletedKey = key; + }), }); const tool = await getSessionsSpawnTool({ @@ -315,15 +323,9 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { let deletedKey: string | undefined; const ctx = setupSessionsSpawnGatewayMock({ includeChatHistory: true, - onAgentSubagentSpawn: (params) => { - const rec = params as { channel?: string; timeout?: number } | undefined; - expect(rec?.channel).toBe("discord"); - expect(rec?.timeout).toBe(1); - }, - onSessionsDelete: (params) => { - const rec = params as { key?: string } | undefined; - deletedKey = rec?.key; - }, + ...createDeleteCleanupHooks((key) => { + deletedKey = key; + }), agentWaitResult: { status: "ok", startedAt: 3000, endedAt: 4000 }, }); diff --git a/src/agents/pi-embedded-runner-extraparams.e2e.test.ts b/src/agents/pi-embedded-runner-extraparams.e2e.test.ts index c1d76f35180..28ef1f0ea8a 100644 --- a/src/agents/pi-embedded-runner-extraparams.e2e.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.e2e.test.ts @@ -64,6 +64,30 @@ describe("resolveExtraParams", () => { }); describe("applyExtraParamsToAgent", () => { + function createOptionsCaptureAgent() { + const calls: Array = []; + const baseStreamFn: StreamFn = (_model, _context, options) => { + calls.push(options); + return {} as ReturnType; + }; + return { + calls, + agent: { streamFn: baseStreamFn }, + }; + } + + function buildAnthropicModelConfig(modelKey: string, params: Record) { + return { + agents: { + defaults: { + models: { + [modelKey]: { params }, + }, + }, + }, + }; + } + function runStoreMutationCase(params: { applyProvider: string; applyModelId: string; @@ -86,12 +110,7 @@ describe("applyExtraParamsToAgent", () => { } it("adds OpenRouter attribution headers to stream options", () => { - const calls: Array = []; - const baseStreamFn: StreamFn = (_model, _context, options) => { - calls.push(options); - return {} as ReturnType; - }; - const agent = { streamFn: baseStreamFn }; + const { calls, agent } = createOptionsCaptureAgent(); applyExtraParamsToAgent(agent, undefined, "openrouter", "openrouter/auto"); @@ -113,25 +132,8 @@ describe("applyExtraParamsToAgent", () => { }); it("adds Anthropic 1M beta header when context1m is enabled for Opus/Sonnet", () => { - const calls: Array = []; - const baseStreamFn: StreamFn = (_model, _context, options) => { - calls.push(options); - return {} as ReturnType; - }; - const agent = { streamFn: baseStreamFn }; - const cfg = { - agents: { - defaults: { - models: { - "anthropic/claude-opus-4-6": { - params: { - context1m: true, - }, - }, - }, - }, - }, - }; + const { calls, agent } = createOptionsCaptureAgent(); + const cfg = buildAnthropicModelConfig("anthropic/claude-opus-4-6", { context1m: true }); applyExtraParamsToAgent(agent, cfg, "anthropic", "claude-opus-4-6"); @@ -152,26 +154,11 @@ describe("applyExtraParamsToAgent", () => { }); it("merges existing anthropic-beta headers with configured betas", () => { - const calls: Array = []; - const baseStreamFn: StreamFn = (_model, _context, options) => { - calls.push(options); - return {} as ReturnType; - }; - const agent = { streamFn: baseStreamFn }; - const cfg = { - agents: { - defaults: { - models: { - "anthropic/claude-sonnet-4-5": { - params: { - context1m: true, - anthropicBeta: ["files-api-2025-04-14"], - }, - }, - }, - }, - }, - }; + const { calls, agent } = createOptionsCaptureAgent(); + const cfg = buildAnthropicModelConfig("anthropic/claude-sonnet-4-5", { + context1m: true, + anthropicBeta: ["files-api-2025-04-14"], + }); applyExtraParamsToAgent(agent, cfg, "anthropic", "claude-sonnet-4-5"); @@ -193,25 +180,8 @@ describe("applyExtraParamsToAgent", () => { }); it("ignores context1m for non-Opus/Sonnet Anthropic models", () => { - const calls: Array = []; - const baseStreamFn: StreamFn = (_model, _context, options) => { - calls.push(options); - return {} as ReturnType; - }; - const agent = { streamFn: baseStreamFn }; - const cfg = { - agents: { - defaults: { - models: { - "anthropic/claude-haiku-3-5": { - params: { - context1m: true, - }, - }, - }, - }, - }, - }; + const { calls, agent } = createOptionsCaptureAgent(); + const cfg = buildAnthropicModelConfig("anthropic/claude-haiku-3-5", { context1m: true }); applyExtraParamsToAgent(agent, cfg, "anthropic", "claude-haiku-3-5"); From 34ddf0edc0d97819c1e2de0021e61b0e14569740 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 08:49:38 +0000 Subject: [PATCH 0080/2904] style: format gateway health state and ui render --- src/gateway/server/health-state.ts | 2 +- ui/src/ui/app-render.ts | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/gateway/server/health-state.ts b/src/gateway/server/health-state.ts index 5d875388149..b3a9c1f33b1 100644 --- a/src/gateway/server/health-state.ts +++ b/src/gateway/server/health-state.ts @@ -2,8 +2,8 @@ import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { getHealthSnapshot, type HealthSummary } from "../../commands/health.js"; import { CONFIG_PATH, STATE_DIR, loadConfig } from "../../config/config.js"; import { resolveMainSessionKey } from "../../config/sessions.js"; -import { getUpdateAvailable } from "../../infra/update-startup.js"; import { listSystemPresence } from "../../infra/system-presence.js"; +import { getUpdateAvailable } from "../../infra/update-startup.js"; import { normalizeMainKey } from "../../routing/session-key.js"; import { resolveGatewayAuth } from "../auth.js"; import type { Snapshot } from "../protocol/index.js"; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index c5e7dc435f9..a9ebc1d7cba 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -188,8 +188,9 @@ export function renderApp(state: AppViewState) {
- ${state.updateAvailable - ? html`