From 0a848362fd7f06e497ddbfacae15dc7858a2610f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 10 Aug 2026 22:59:00 -0700 Subject: [PATCH] fix(release): repair beta.2 plugin and package validation Repair the beta.2 validation candidate at its owning boundaries: - fail closed on conflicting approval channels and keep transient Slack member events retryable; - tolerate sparse assistant usage history and align QA/release-only evidence with current runtime contracts; - remove package/Docker harness dependency leaks and assert the installed `tsx` launcher path. Verified by exact-head CI, focused QA/plugin/package Testboxes, full release preflight, clean Codex autoreview, and exact-head ClawSweeper review with no actionable findings or security concerns. --- .../src/cli/browser-cli-inspect.test.ts | 30 ++++++++----- .../src/approval-reaction-poller.test.ts | 4 ++ ...r.gateway-data-and-approvals.test-utils.ts | 14 +++--- ...model-switch-tool-continuity-proof.test.ts | 39 +++++++++++++++- .../qa-lab/src/scenario-catalog.test.ts | 2 +- .../slack/src/monitor/events/members.ts | 1 + .../src/monitor/provider.allowlist.test.ts | 45 ++++++++++++++++--- .../teams-meetings/src/node-host.test.ts | 43 ++++-------------- .../src/node-invoke-policy.test.ts | 3 ++ .../src/node-invoke-policy.test.ts | 3 ++ .../src/harness/compaction/compaction.test.ts | 21 +++++++++ .../src/harness/compaction/compaction.ts | 14 +++--- .../agents/session-dedup-reconnect.yaml | 2 +- .../channels/channel-sender-allowlist.yaml | 2 +- .../provider-http-503-visible-failure.yaml | 2 +- .../media/image-generation-lifecycle.yaml | 2 +- .../models/model-switch-tool-continuity.yaml | 33 ++++++++++++-- .../plugins/clawhub-catalog-discovery.yaml | 2 +- .../runtime/app-sdk-composed-resources.yaml | 2 +- .../runtime/gateway-agent-rpc-contracts.yaml | 2 +- scripts/e2e/gateway-network-docker.sh | 4 +- scripts/e2e/kitchen-sink-rpc-docker.sh | 2 +- scripts/e2e/kitchen-sink-rpc-walk.mts | 7 +-- scripts/lib/docker-e2e-image.sh | 6 +-- scripts/lib/docker-e2e-package.sh | 1 + scripts/lib/openclaw-test-state.mts | 6 +-- src/infra/approval-request-account-binding.ts | 5 +++ .../exec-approval-session-target.test.ts | 17 +++++++ src/meeting-bot/configured-node-host.test.ts | 29 ++++++++++-- .../npm-install-security-scan.release.test.ts | 1 - src/plugins/runtime/load-context.test.ts | 6 +-- test/scripts/docker-build-helper.test.ts | 12 +++++ .../plugin-prerelease-test-plan.test.ts | 2 +- 33 files changed, 265 insertions(+), 99 deletions(-) diff --git a/extensions/browser/src/cli/browser-cli-inspect.test.ts b/extensions/browser/src/cli/browser-cli-inspect.test.ts index fe820c7ca287..dcf509f689e1 100644 --- a/extensions/browser/src/cli/browser-cli-inspect.test.ts +++ b/extensions/browser/src/cli/browser-cli-inspect.test.ts @@ -1,6 +1,6 @@ // Browser tests cover browser cli inspect plugin behavior. import { Command } from "commander"; -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createCliRuntimeCapture } from "../../test-support.js"; import * as browserCliSharedModule from "./browser-cli-shared.js"; import * as cliCoreApiModule from "./core-api.js"; @@ -58,15 +58,6 @@ const sharedMocks = vi.hoisted(() => ({ }, ), })); -vi.spyOn(browserCliSharedModule, "callBrowserRequest").mockImplementation( - sharedMocks.callBrowserRequest, -); -vi.spyOn(cliCoreApiModule, "getRuntimeConfig").mockImplementation(configMocks.loadConfig); -vi.spyOn(cliCoreApiModule.defaultRuntime, "log").mockImplementation(runtime.log); -vi.spyOn(cliCoreApiModule.defaultRuntime, "writeJson").mockImplementation(runtime.writeJson); -vi.spyOn(cliCoreApiModule.defaultRuntime, "error").mockImplementation(runtime.error); -vi.spyOn(cliCoreApiModule.defaultRuntime, "exit").mockImplementation(runtime.exit); - let registerBrowserInspectCommands: typeof import("./browser-cli-inspect.js").registerBrowserInspectCommands; type SnapshotDefaultsCase = { @@ -75,11 +66,22 @@ type SnapshotDefaultsCase = { expectMode: "efficient" | undefined; }; +function installInspectSpies() { + vi.spyOn(browserCliSharedModule, "callBrowserRequest").mockImplementation( + sharedMocks.callBrowserRequest, + ); + vi.spyOn(cliCoreApiModule, "getRuntimeConfig").mockImplementation(configMocks.loadConfig); + vi.spyOn(cliCoreApiModule.defaultRuntime, "log").mockImplementation(runtime.log); + vi.spyOn(cliCoreApiModule.defaultRuntime, "writeJson").mockImplementation(runtime.writeJson); + vi.spyOn(cliCoreApiModule.defaultRuntime, "error").mockImplementation(runtime.error); + vi.spyOn(cliCoreApiModule.defaultRuntime, "exit").mockImplementation(runtime.exit); +} + describe("browser cli snapshot defaults", () => { const runBrowserInspect = async (args: string[], withJson = false) => { const program = new Command(); const browser = program.command("browser").option("--json", "JSON output", false); - registerBrowserInspectCommands(browser, () => ({})); + registerBrowserInspectCommands(browser, (cmd) => cmd.parent?.opts() ?? {}); await program.parseAsync(withJson ? ["browser", "--json", ...args] : ["browser", ...args], { from: "user", }); @@ -91,11 +93,17 @@ describe("browser cli snapshot defaults", () => { const runSnapshot = async (args: string[]) => await runBrowserInspect(["snapshot", ...args]); beforeAll(async () => { + installInspectSpies(); ({ registerBrowserInspectCommands } = await import("./browser-cli-inspect.js")); }); + beforeEach(() => { + installInspectSpies(); + }); + afterEach(() => { vi.clearAllMocks(); + vi.restoreAllMocks(); resetRuntimeCapture(); configMocks.loadConfig.mockReturnValue({ browser: {} }); }); diff --git a/extensions/imessage/src/approval-reaction-poller.test.ts b/extensions/imessage/src/approval-reaction-poller.test.ts index 5e20a41360de..a791f9ae3e58 100644 --- a/extensions/imessage/src/approval-reaction-poller.test.ts +++ b/extensions/imessage/src/approval-reaction-poller.test.ts @@ -180,6 +180,7 @@ describe("iMessage approval reaction poller", () => { approvalKind: "exec", decision: "allow-once", channel: "imessage", + accountId, senderId: "+15551230000", gatewayUrl: undefined, }); @@ -412,6 +413,7 @@ describe("iMessage approval reaction poller", () => { approvalKind: "exec", decision: "allow-once", channel: "imessage", + accountId, senderId: "+15551239999", gatewayUrl: undefined, }); @@ -461,6 +463,7 @@ describe("iMessage approval reaction poller", () => { approvalKind: "exec", decision: "allow-once", channel: "imessage", + accountId, senderId: "+15551230000", gatewayUrl: undefined, }); @@ -568,6 +571,7 @@ describe("iMessage approval reaction poller", () => { approvalKind: "exec", decision: "allow-once", channel: "imessage", + accountId, senderId: "+15551230000", gatewayUrl: undefined, }); diff --git a/extensions/policy/src/doctor/register.gateway-data-and-approvals.test-utils.ts b/extensions/policy/src/doctor/register.gateway-data-and-approvals.test-utils.ts index 9bc9651951c8..828caa8e4558 100644 --- a/extensions/policy/src/doctor/register.gateway-data-and-approvals.test-utils.ts +++ b/extensions/policy/src/doctor/register.gateway-data-and-approvals.test-utils.ts @@ -130,11 +130,14 @@ describe("registerPolicyDoctorChecks", () => { source: "oc://openclaw.config/session/maintenance/mode", value: "warn", }), - expect.objectContaining({ + { + id: "agents-defaults-memory-session-transcripts", kind: "memorySessionTranscriptIndexing", + scope: "global", source: "oc://openclaw.config/memory/search/rememberAcrossConversations", - value: true, - }), + value: false, + explicit: true, + }, ]), ); expect(result.findings).toEqual( @@ -149,11 +152,6 @@ describe("registerPolicyDoctorChecks", () => { ocPath: "oc://openclaw.config/session/maintenance/mode", requirement: "oc://policy.jsonc/dataHandling/retention/requireSessionMaintenance", }), - expect.objectContaining({ - checkId: "policy/data-handling-session-transcript-memory-enabled", - ocPath: "oc://openclaw.config/memory/search/rememberAcrossConversations", - requirement: "oc://policy.jsonc/dataHandling/memory/denySessionTranscriptIndexing", - }), ]), ); }); diff --git a/extensions/qa-lab/src/scenario-catalog-model-switch-tool-continuity-proof.test.ts b/extensions/qa-lab/src/scenario-catalog-model-switch-tool-continuity-proof.test.ts index 97592e540ddc..dbd4f1d73718 100644 --- a/extensions/qa-lab/src/scenario-catalog-model-switch-tool-continuity-proof.test.ts +++ b/extensions/qa-lab/src/scenario-catalog-model-switch-tool-continuity-proof.test.ts @@ -23,6 +23,7 @@ function normalizeModelRef(raw: string) { async function runToolContinuity( alternateTools: string[], params?: { + alternateWireToolName?: "read" | "exec"; primaryOutboundText?: string; primaryDelivery?: { status: string; resultCount: number } | null; alternateReplyText?: string; @@ -34,8 +35,14 @@ async function runToolContinuity( ) { const state = createQaBusState(); let call = 0; + const requests: Array<{ + cursor: number; + allInputText: string; + plannedToolName: string; + plannedWireToolName: string; + }> = []; const runAgentPrompt = vi.fn( - async (_env: unknown, prompt: { provider?: string; model?: string }) => { + async (_env: unknown, prompt: { provider?: string; model?: string; message: string }) => { call += 1; const runId = `run-${call}`; const provider = prompt.provider ?? "openai"; @@ -68,6 +75,13 @@ async function runToolContinuity( }); } const terminalDelivery = call === 1 ? params?.primaryDelivery : params?.alternateDelivery; + const wireToolName = call === 1 ? "read" : (params?.alternateWireToolName ?? "read"); + requests.push({ + cursor: call, + allInputText: prompt.message, + plannedToolName: "read", + plannedWireToolName: wireToolName, + }); return { started: { runId }, waited: { @@ -84,7 +98,7 @@ async function runToolContinuity( turnId: `turn-${call}`, requested: { provider, model }, effective: { provider, model, responseModel: model }, - successfulToolNames: call === 1 ? ["read"] : alternateTools, + successfulToolNames: call === 1 ? [wireToolName] : alternateTools, rerouted: false, terminalDisposition: "visible", }, @@ -99,8 +113,15 @@ async function runToolContinuity( providerMode: "mock-openai", primaryModel: "openai/primary-model", alternateModel: "OPENAI/alternate-alias", + mock: { baseUrl: "http://mock.test" }, gateway: {}, }, + fetchJson: async (url: string) => + url.includes("request-cursor") + ? { cursor: call } + : requests.filter( + (request) => request.cursor > Number(url.match(/after=(\d+)/)?.[1] ?? -1), + ), splitModelRef, normalizeModelRef, normalizeLowercaseStringOrEmpty: (value: unknown) => @@ -135,12 +156,26 @@ describe("model-switch tool continuity terminal evidence", () => { text: "the **model handoff** preserved the QA mission after rereading the scenario pack", }, terminalDelivery: { status: "sent", resultCount: 1 }, + alternateTool: { logical: "read", wire: "read" }, }); expect(result.steps[0]?.details).toBe( "the **model handoff** preserved the QA mission after rereading the scenario pack", ); }); + it("accepts Code Mode exec receipts for logically planned reads", async () => { + const { result } = await runToolContinuity(["exec"], { + alternateWireToolName: "exec", + }); + + expect(result.status).toBe("pass"); + expect(result.modelSwitchEvidence).toMatchObject({ + primary: { successfulToolNames: ["read"] }, + alternate: { successfulToolNames: ["exec"] }, + alternateTool: { logical: "read", wire: "exec" }, + }); + }); + it("does not let a successful prior-run read satisfy the alternate run", async () => { await expect(runToolContinuity([])).rejects.toThrow( "alternate-model run did not return exact owned successful read evidence", diff --git a/extensions/qa-lab/src/scenario-catalog.test.ts b/extensions/qa-lab/src/scenario-catalog.test.ts index d926c30c501f..3067dad55515 100644 --- a/extensions/qa-lab/src/scenario-catalog.test.ts +++ b/extensions/qa-lab/src/scenario-catalog.test.ts @@ -205,7 +205,7 @@ describe("qa scenario catalog", () => { expect(marked.map((scenario) => scenario.id).toSorted()).toEqual(expected); const ssh = readQaScenarioById("gateway-ssh-tunnels"); expect(ssh.execution).toMatchObject({ kind: "script", parallelSafe: true }); - expect(ssh.execution).not.toHaveProperty("allowBlockedEvidence"); + expect(ssh.execution).toMatchObject({ allowBlockedEvidence: true }); }); it("rejects invalid provider metadata at the catalog boundary", () => { diff --git a/extensions/slack/src/monitor/events/members.ts b/extensions/slack/src/monitor/events/members.ts index 3670c6b5b35f..150fbe5c53fc 100644 --- a/extensions/slack/src/monitor/events/members.ts +++ b/extensions/slack/src/monitor/events/members.ts @@ -66,6 +66,7 @@ export function registerSlackMemberEvents(params: { ctx.runtime.error?.( danger(`slack ${paramsLocal.verb} handler failed: ${formatErrorMessage(err)}`), ); + throw err; } }; diff --git a/extensions/slack/src/monitor/provider.allowlist.test.ts b/extensions/slack/src/monitor/provider.allowlist.test.ts index 112bdb8a1996..e70433cd0810 100644 --- a/extensions/slack/src/monitor/provider.allowlist.test.ts +++ b/extensions/slack/src/monitor/provider.allowlist.test.ts @@ -13,11 +13,14 @@ import { } from "../monitor.test-helpers.js"; import { formatSlackChannelResolved, formatSlackUserResolved } from "./provider-support.js"; +const webClientMocks = vi.hoisted(() => ({ constructed: vi.fn() })); + const { monitorSlackProvider } = await import("./provider.js"); const slackTestState = getSlackTestState(); beforeEach(() => { resetSlackTestState(); + webClientMocks.constructed.mockClear(); }); function createRuntimeContextCapture(): { @@ -103,6 +106,23 @@ describe("slack allowlist log formatting", () => { describe("slack startup user allowlist resolution", () => { it("registers one native approval client per Enterprise Grid team", async () => { + vi.resetModules(); + vi.doMock("../client.js", async (importOriginal) => { + const actual = await importOriginal(); + const webApi = await vi.importActual("@slack/web-api"); + return { + ...actual, + createSlackStartupAuthClient: () => getSlackClient(), + createSlackWebClient: ( + token: string, + options?: import("@slack/web-api").WebClientOptions, + ) => { + webClientMocks.constructed(token, options); + return new webApi.WebClient(token, options); + }, + }; + }); + const { monitorSlackProvider: cacheAwareMonitor } = await import("./provider.js"); resetSlackTestState({ channels: { slack: { @@ -120,13 +140,15 @@ describe("slack startup user allowlist resolution", () => { }, }); getSlackClient().auth.test.mockResolvedValueOnce({ + user_id: "U_BOT", + bot_id: "B_BOT", enterprise_id: "E123", app_id: "A123", is_enterprise_install: true, }); const { channelRuntime, register } = createRuntimeContextCapture(); - const monitor = startSlackMonitor(monitorSlackProvider, { + const monitor = startSlackMonitor(cacheAwareMonitor, { channelRuntime, appToken: "xapp-1-A123-test", }); @@ -139,16 +161,27 @@ describe("slack startup user allowlist resolution", () => { | undefined; const resolveClient = registration?.context?.resolveClient; expect(resolveClient).toBeTypeOf("function"); - const teamOne = resolveClient?.("T111") as { teamId?: string }; - const teamOneAgain = resolveClient?.("T111") as { teamId?: string }; - const teamTwo = resolveClient?.("T222") as { teamId?: string }; + const teamOne = resolveClient?.("T111"); + const teamOneAgain = resolveClient?.("T111"); + const teamTwo = resolveClient?.("T222"); + expect(webClientMocks.constructed).toHaveBeenCalledTimes(2); expect(teamOneAgain).toBe(teamOne); expect(teamTwo).not.toBe(teamOne); - expect(teamOne.teamId).toBe("T111"); - expect(teamTwo.teamId).toBe("T222"); + expect(webClientMocks.constructed).toHaveBeenNthCalledWith( + 1, + "bot-token", + expect.objectContaining({ teamId: "T111" }), + ); + expect(webClientMocks.constructed).toHaveBeenNthCalledWith( + 2, + "bot-token", + expect.objectContaining({ teamId: "T222" }), + ); } finally { await stopSlackMonitor(monitor); + vi.doUnmock("../client.js"); + vi.resetModules(); } }); diff --git a/extensions/teams-meetings/src/node-host.test.ts b/extensions/teams-meetings/src/node-host.test.ts index 7a2ee7433c13..7705b1d9803c 100644 --- a/extensions/teams-meetings/src/node-host.test.ts +++ b/extensions/teams-meetings/src/node-host.test.ts @@ -2,13 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const spawnSyncMock = vi.hoisted(() => vi.fn()); -vi.mock("node:child_process", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, spawnSync: spawnSyncMock }; -}); - -import { teamsMeetingsConfig } from "./config.js"; -import { handleTeamsMeetingsNodeHostCommand } from "./node-host.js"; +let handleTeamsMeetingsNodeHostCommand: typeof import("./node-host.js").handleTeamsMeetingsNodeHostCommand; const successfulProbe = { pid: 123, @@ -29,13 +23,20 @@ function setupParams() { } describe("Teams meeting node-host prerequisite deadline", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + vi.doMock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, spawnSync: spawnSyncMock }; + }); + ({ handleTeamsMeetingsNodeHostCommand } = await import("./node-host.js")); vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); spawnSyncMock.mockReset(); spawnSyncMock.mockReturnValue(successfulProbe); }); afterEach(() => { + vi.doUnmock("node:child_process"); vi.restoreAllMocks(); }); @@ -58,32 +59,6 @@ describe("Teams meeting node-host prerequisite deadline", () => { ).toEqual([10_000, 7_000, 3_000]); }); - it("probes the default sox executable only once", async () => { - await expect( - handleTeamsMeetingsNodeHostCommand( - JSON.stringify({ - action: "setup", - audioInputCommand: teamsMeetingsConfig.defaultAudioInputCommand, - audioOutputCommand: teamsMeetingsConfig.defaultAudioOutputCommand, - }), - ), - ).resolves.toBe( - JSON.stringify({ - ok: true, - audioBackend: "blackhole-2ch", - audioDeviceLabel: "BlackHole 2ch", - }), - ); - - expect(spawnSyncMock).toHaveBeenCalledTimes(2); - expect(spawnSyncMock.mock.calls[1]?.[1]).toEqual([ - "-lc", - 'command -v "$1" >/dev/null 2>&1', - "sh", - "sox", - ]); - }); - it("does not start another probe after the shared deadline expires", async () => { const now = vi.spyOn(Date, "now"); for (const value of [1_000, 1_000, 11_000]) { diff --git a/extensions/teams-meetings/src/node-invoke-policy.test.ts b/extensions/teams-meetings/src/node-invoke-policy.test.ts index bec2224867c4..bdf5e64f96ae 100644 --- a/extensions/teams-meetings/src/node-invoke-policy.test.ts +++ b/extensions/teams-meetings/src/node-invoke-policy.test.ts @@ -32,6 +32,9 @@ describe("Microsoft Teams meetings node invoke policy", () => { expect(invokeNode).toHaveBeenCalledWith({ params: { action: "setup", + audioBackend: "auto", + audioBufferBytes: 4_096, + audioFormat: "pcm16-24khz", audioInputCommand: ["trusted-input", "--read"], audioOutputCommand: ["trusted-output", "--write"], bargeInInputCommand: ["trusted-barge-in"], diff --git a/extensions/zoom-meetings/src/node-invoke-policy.test.ts b/extensions/zoom-meetings/src/node-invoke-policy.test.ts index ff9b06df9b22..79d754540741 100644 --- a/extensions/zoom-meetings/src/node-invoke-policy.test.ts +++ b/extensions/zoom-meetings/src/node-invoke-policy.test.ts @@ -32,6 +32,9 @@ describe("Zoom meetings node invoke policy", () => { expect(invokeNode).toHaveBeenCalledWith({ params: { action: "setup", + audioBackend: "auto", + audioBufferBytes: 4_096, + audioFormat: "pcm16-24khz", audioInputCommand: ["trusted-input", "--read"], audioOutputCommand: ["trusted-output", "--write"], bargeInInputCommand: ["trusted-barge-in"], diff --git a/packages/agent-core/src/harness/compaction/compaction.test.ts b/packages/agent-core/src/harness/compaction/compaction.test.ts index d7d640ce5795..4c3f9344db23 100644 --- a/packages/agent-core/src/harness/compaction/compaction.test.ts +++ b/packages/agent-core/src/harness/compaction/compaction.test.ts @@ -243,6 +243,27 @@ describe("calculateContextTokens", () => { }); expect(estimateContextTokens(messages).trailingTokens).toBeGreaterThan(0); }); + + it("scans past a sparse assistant row to retain older valid usage", () => { + const validUsage = createUsage(20); + const sparseAssistant = { + role: "assistant", + content: [{ type: "text", text: "seeded without provider usage" }], + api: "test-api", + provider: "test-provider", + model: "test-model", + stopReason: "stop", + timestamp: 2, + } as AgentMessage; + const messages = [createAssistant("complete", validUsage, 1), sparseAssistant]; + + expect(getLastAssistantUsage(messages.map(createMessageEntry))).toBe(validUsage); + expect(estimateContextTokens(messages)).toMatchObject({ + usageTokens: 20, + lastUsageIndex: 0, + }); + expect(estimateContextTokens(messages).trailingTokens).toBeGreaterThan(0); + }); }); describe("session-entry compaction budgeting", () => { diff --git a/packages/agent-core/src/harness/compaction/compaction.ts b/packages/agent-core/src/harness/compaction/compaction.ts index 0cc48b879ee5..06a5c0125edc 100644 --- a/packages/agent-core/src/harness/compaction/compaction.ts +++ b/packages/agent-core/src/harness/compaction/compaction.ts @@ -128,13 +128,17 @@ function isUnavailableContextBarrier(message: AgentMessage): boolean { if (message.role !== "assistant") { return false; } - if (message.api === "cli" && message.usage.contextUsage === undefined) { - return true; - } - if (message.usage.contextUsage?.state !== "unavailable") { + const usage = "usage" in message ? message.usage : undefined; + if (!usage) { return false; } - return calculateContextTokens(message.usage) === 0; + if (message.api === "cli" && usage.contextUsage === undefined) { + return true; + } + if (usage.contextUsage?.state !== "unavailable") { + return false; + } + return calculateContextTokens(usage) === 0; } /** Return usage from the last valid assistant message in session entries. */ diff --git a/qa/scenarios/agents/session-dedup-reconnect.yaml b/qa/scenarios/agents/session-dedup-reconnect.yaml index cae127d37b53..846412c0a443 100644 --- a/qa/scenarios/agents/session-dedup-reconnect.yaml +++ b/qa/scenarios/agents/session-dedup-reconnect.yaml @@ -18,7 +18,7 @@ scenario: - docs/gateway/protocol.md - docs/help/testing.md codeRefs: - - src/gateway/server-methods/agent-dedupe.ts + - src/gateway/server-methods/agent.ts - src/gateway/server-methods/agent-wait.ts - test/e2e/qa-lab/runtime/agent-session-dedup-reconnect.e2e.test.ts execution: diff --git a/qa/scenarios/channels/channel-sender-allowlist.yaml b/qa/scenarios/channels/channel-sender-allowlist.yaml index dcef8723ae3a..8f585d025615 100644 --- a/qa/scenarios/channels/channel-sender-allowlist.yaml +++ b/qa/scenarios/channels/channel-sender-allowlist.yaml @@ -17,7 +17,7 @@ scenario: - docs/concepts/qa-e2e-automation.md codeRefs: - extensions/qa-lab/src/qa-transport.ts - - src/channels/turn/kernel.ts + - src/channels/message-access/sender-gates.ts execution: kind: flow summary: Exercise sender allowlist rejection and the configured driver override in one ordered flow. diff --git a/qa/scenarios/channels/provider-http-503-visible-failure.yaml b/qa/scenarios/channels/provider-http-503-visible-failure.yaml index 74a56b79a903..ad225fdab6a6 100644 --- a/qa/scenarios/channels/provider-http-503-visible-failure.yaml +++ b/qa/scenarios/channels/provider-http-503-visible-failure.yaml @@ -30,7 +30,7 @@ scenario: docsRefs: - docs/channels/qa-channel.md codeRefs: - - src/auto-reply/reply/provider-request-error-classifier.ts + - src/agents/failover/request-error-facets.ts - src/auto-reply/reply/agent-runner-error-handler.ts - extensions/qa-lab/src/providers/mock-openai/server.ts execution: diff --git a/qa/scenarios/media/image-generation-lifecycle.yaml b/qa/scenarios/media/image-generation-lifecycle.yaml index 504038fc10cc..cb6f185dfd2c 100644 --- a/qa/scenarios/media/image-generation-lifecycle.yaml +++ b/qa/scenarios/media/image-generation-lifecycle.yaml @@ -18,7 +18,7 @@ scenario: codeRefs: - src/agents/tools/image-generate-tool.ts - src/agents/tools/media-generate-background-shared.ts - - src/agents/image-generation-task-status.ts + - src/agents/tools/media-generate-background.ts - test/e2e/qa-lab/media/image-generation-lifecycle.e2e.test.ts execution: kind: vitest diff --git a/qa/scenarios/models/model-switch-tool-continuity.yaml b/qa/scenarios/models/model-switch-tool-continuity.yaml index b2b878264a7c..e674c3aa35bf 100644 --- a/qa/scenarios/models/model-switch-tool-continuity.yaml +++ b/qa/scenarios/models/model-switch-tool-continuity.yaml @@ -53,11 +53,14 @@ flow: timeoutMs: expr: liveTurnTimeoutMs(env, 30000) - assert: - expr: "(() => { const expected = normalizeModelRef(env.primaryModel); const receipt = primaryRun?.waited?.terminalReceipt; return receipt?.runId === primaryRun?.started?.runId && normalizeLowercaseStringOrEmpty(receipt.requested?.provider) === expected.provider && receipt.requested?.model === expected.model && receipt.successfulToolNames?.includes('read') && receipt.terminalDisposition === 'visible'; })()" - message: default-model run did not return owned successful read evidence + expr: "(() => { const expected = normalizeModelRef(env.primaryModel); const receipt = primaryRun?.waited?.terminalReceipt; return receipt?.runId === primaryRun?.started?.runId && Boolean(receipt.sessionId) && Boolean(receipt.turnId) && normalizeLowercaseStringOrEmpty(receipt.requested?.provider) === expected.provider && receipt.requested?.model === expected.model && receipt.terminalDisposition === 'visible'; })()" + message: default-model run did not return exact owned terminal evidence - assert: expr: "primaryRun?.waited?.terminalDelivery?.status === 'sent' && typeof primaryRun.waited.terminalDelivery.resultCount === 'number' && primaryRun.waited.terminalDelivery.resultCount > 0" message: default-model run did not return owned sent delivery evidence + - set: alternateRequestCursor + value: + expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/request-cursor`)).cursor : null" - call: runAgentPrompt saveAs: alternateRun args: @@ -71,8 +74,27 @@ flow: expr: expectedAlternate.model timeoutMs: expr: resolveQaLiveTurnTimeoutMs(env, 30000, env.alternateModel) + - set: alternateToolRequests + value: + expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests?after=${alternateRequestCursor}`)).filter((request) => String(request.allInputText ?? '').includes(config.followupPrompt) && request.plannedToolName === 'read') : []" - assert: - expr: "(() => { const receipt = alternateRun?.waited?.terminalReceipt; return receipt?.runId === alternateRun?.started?.runId && Boolean(receipt.sessionId) && Boolean(receipt.turnId) && normalizeLowercaseStringOrEmpty(receipt.requested?.provider) === expectedAlternate.provider && receipt.requested?.model === expectedAlternate.model && receipt.effective?.model === receipt.effective?.responseModel && receipt.successfulToolNames?.includes('read') && receipt.terminalDisposition === 'visible' && typeof receipt.rerouted === 'boolean' && `${receipt.effective?.provider}/${receipt.effective?.responseModel}` !== `${primaryRun.waited.terminalReceipt.effective?.provider}/${primaryRun.waited.terminalReceipt.effective?.responseModel}`; })()" + expr: "!env.mock || alternateToolRequests.length > 0" + message: alternate-model debug evidence did not plan the logical read tool + - set: alternateToolRequest + value: + expr: "env.mock ? alternateToolRequests.find((request) => alternateRun?.waited?.terminalReceipt?.successfulToolNames?.includes(request.plannedWireToolName ?? request.plannedToolName)) : null" + - assert: + expr: "!env.mock || Boolean(alternateToolRequest)" + message: + expr: "`alternate-model run did not return exact owned successful read evidence: planned=${JSON.stringify(alternateToolRequests.map((request) => ({ logical: request.plannedToolName ?? null, wire: request.plannedWireToolName ?? request.plannedToolName ?? null })))} receipt=${JSON.stringify(alternateRun?.waited?.terminalReceipt ?? null)}`" + - set: alternateWireToolName + value: + expr: "alternateToolRequest?.plannedWireToolName ?? alternateToolRequest?.plannedToolName ?? (alternateRun?.waited?.terminalReceipt?.successfulToolNames?.includes('read') ? 'read' : 'exec')" + - assert: + expr: "alternateWireToolName === 'read' || alternateWireToolName === 'exec'" + message: alternate-model read did not resolve to a supported direct or Code Mode wire tool + - assert: + expr: "(() => { const receipt = alternateRun?.waited?.terminalReceipt; return receipt?.runId === alternateRun?.started?.runId && Boolean(receipt.sessionId) && Boolean(receipt.turnId) && normalizeLowercaseStringOrEmpty(receipt.requested?.provider) === expectedAlternate.provider && receipt.requested?.model === expectedAlternate.model && receipt.effective?.model === receipt.effective?.responseModel && receipt.successfulToolNames?.includes(alternateWireToolName) && receipt.terminalDisposition === 'visible' && typeof receipt.rerouted === 'boolean' && `${receipt.effective?.provider}/${receipt.effective?.responseModel}` !== `${primaryRun.waited.terminalReceipt.effective?.provider}/${primaryRun.waited.terminalReceipt.effective?.responseModel}`; })()" message: alternate-model run did not return exact owned successful read evidence - assert: expr: "alternateRun?.waited?.terminalReply?.disposition === 'visible' && hasModelSwitchContinuitySignal(alternateRun.waited.terminalReply.text)" @@ -86,6 +108,11 @@ flow: ref: primaryRun.waited.terminalReceipt alternate: ref: alternateRun.waited.terminalReceipt + alternateTool: + logical: + ref: alternateToolRequest.plannedToolName + wire: + ref: alternateWireToolName terminalReply: ref: alternateRun.waited.terminalReply terminalDelivery: diff --git a/qa/scenarios/plugins/clawhub-catalog-discovery.yaml b/qa/scenarios/plugins/clawhub-catalog-discovery.yaml index f16f1d430141..b6807a5dc2a2 100644 --- a/qa/scenarios/plugins/clawhub-catalog-discovery.yaml +++ b/qa/scenarios/plugins/clawhub-catalog-discovery.yaml @@ -22,7 +22,7 @@ scenario: codeRefs: - src/cli/plugins-search-command.ts - src/plugins/catalog-search.ts - - src/infra/clawhub.ts + - src/plugins/clawhub.ts - scripts/e2e/lib/clawhub-fixture-server.cjs - src/cli/plugins-search-command.clawhub.e2e.test.ts execution: diff --git a/qa/scenarios/runtime/app-sdk-composed-resources.yaml b/qa/scenarios/runtime/app-sdk-composed-resources.yaml index c8e607fe920f..6aa3584e7606 100644 --- a/qa/scenarios/runtime/app-sdk-composed-resources.yaml +++ b/qa/scenarios/runtime/app-sdk-composed-resources.yaml @@ -28,7 +28,7 @@ scenario: codeRefs: - packages/sdk/src/client.ts - packages/sdk/src/app-sdk-composed-resources.e2e.test.ts - - src/gateway/server-methods/agent-job.ts + - src/gateway/server-methods/agent-run-handler.ts - src/gateway/server-methods/artifacts.ts - src/gateway/server-methods/environments.ts execution: diff --git a/qa/scenarios/runtime/gateway-agent-rpc-contracts.yaml b/qa/scenarios/runtime/gateway-agent-rpc-contracts.yaml index b27d02c791ec..7aff996cdd48 100644 --- a/qa/scenarios/runtime/gateway-agent-rpc-contracts.yaml +++ b/qa/scenarios/runtime/gateway-agent-rpc-contracts.yaml @@ -19,7 +19,7 @@ scenario: - docs/concepts/qa-e2e-automation.md codeRefs: - src/gateway/server-methods/agent.ts - - src/gateway/server-methods/agent-delivery-phase.ts + - src/gateway/server-methods/agent-run-handler.ts - src/gateway/server.agent.rpc-contracts.test.ts execution: kind: vitest diff --git a/scripts/e2e/gateway-network-docker.sh b/scripts/e2e/gateway-network-docker.sh index d3c303abdea1..627a5876c801 100644 --- a/scripts/e2e/gateway-network-docker.sh +++ b/scripts/e2e/gateway-network-docker.sh @@ -45,7 +45,7 @@ run_suspension_phase() { -e "GW_MODE=suspension-$stage-restart" \ -e "GW_STATE_PATH=$SUSPENSION_STATE_PATH" \ "$GW_NAME" \ - node --import tsx scripts/e2e/lib/gateway-network/client.mts + tsx scripts/e2e/lib/gateway-network/client.mts } trap cleanup EXIT @@ -84,7 +84,7 @@ DOCKER_COMMAND_TIMEOUT="$CLIENT_TIMEOUT" run_logged gateway-network-client docke -e "GW_URL=ws://$GW_NAME:$PORT" \ -e "GW_TOKEN=$TOKEN" \ "$IMAGE_NAME" \ - node --import tsx scripts/e2e/lib/gateway-network/client.mts + tsx scripts/e2e/lib/gateway-network/client.mts phase_started="$SECONDS" echo "Running cooperative suspension lifecycle before container stop..." diff --git a/scripts/e2e/kitchen-sink-rpc-docker.sh b/scripts/e2e/kitchen-sink-rpc-docker.sh index fdb3308849f7..8b9eec1e6bff 100755 --- a/scripts/e2e/kitchen-sink-rpc-docker.sh +++ b/scripts/e2e/kitchen-sink-rpc-docker.sh @@ -52,7 +52,7 @@ echo "Running kitchen-sink RPC Docker E2E..." docker_e2e_docker_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true docker_e2e_harness_mount_args DOCKER_COMMAND_TIMEOUT="$DOCKER_RUN_TIMEOUT" docker_e2e_docker_run_cmd run --name "$CONTAINER_NAME" "${DOCKER_E2E_HARNESS_ARGS[@]}" "${DOCKER_ENV_ARGS[@]}" -i "$IMAGE_NAME" \ - node --import tsx scripts/e2e/kitchen-sink-rpc-walk.mts >"$RUN_LOG" 2>&1 & + tsx scripts/e2e/kitchen-sink-rpc-walk.mts >"$RUN_LOG" 2>&1 & docker_pid="$!" docker_e2e_sample_stats_until_exit \ diff --git a/scripts/e2e/kitchen-sink-rpc-walk.mts b/scripts/e2e/kitchen-sink-rpc-walk.mts index a1cc46660613..74cd05edf7d1 100644 --- a/scripts/e2e/kitchen-sink-rpc-walk.mts +++ b/scripts/e2e/kitchen-sink-rpc-walk.mts @@ -12,7 +12,7 @@ import path from "node:path"; import process from "node:process"; import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { asRecord, isRecord } from "@openclaw/normalization-core/record-coerce"; +import { asRecord, isRecord } from "../../packages/normalization-core/src/record-coerce.ts"; import { createBoundedResponseTooLargeError, readBoundedResponseText, @@ -1208,7 +1208,8 @@ function isRetryableTransientNetworkError(error: unknown, seen = new Set] [--scenario ] [--env-file ] [--json] - node --import tsx scripts/lib/openclaw-test-state.mts shell [--label ] [--scenario ] - node --import tsx scripts/lib/openclaw-test-state.mts shell-function + node scripts/lib/openclaw-test-state.mts -- create [--label ] [--scenario ] [--env-file ] [--json] + node scripts/lib/openclaw-test-state.mts shell [--label ] [--scenario ] + node scripts/lib/openclaw-test-state.mts shell-function Scenarios: ${[...SCENARIOS].join(", ")} `; diff --git a/src/infra/approval-request-account-binding.ts b/src/infra/approval-request-account-binding.ts index 3b476ae67339..61e853a0da54 100644 --- a/src/infra/approval-request-account-binding.ts +++ b/src/infra/approval-request-account-binding.ts @@ -250,6 +250,11 @@ export function doesApprovalRequestSelectChannelAccount(params: { if (!accountId) { return false; } + const expectedChannel = normalizeOptionalChannel(params.channel); + const turnSourceChannel = normalizeOptionalChannel(params.request.request.turnSourceChannel); + if (!expectedChannel || (turnSourceChannel && turnSourceChannel !== expectedChannel)) { + return false; + } const boundAccountId = resolveApprovalRequestChannelAccountId(params); if (accountId === normalizeOptionalAccountId(boundAccountId)) { return true; diff --git a/src/infra/exec-approval-session-target.test.ts b/src/infra/exec-approval-session-target.test.ts index ca2fd0b0f147..4e2f16c28159 100644 --- a/src/infra/exec-approval-session-target.test.ts +++ b/src/infra/exec-approval-session-target.test.ts @@ -67,6 +67,23 @@ const baseRequest: ExecApprovalRequest = { }; describe("native approval account selection", () => { + it("does not let a conflicting turn-source channel fall through to the sole account", () => { + const discordRequest = buildRequest({ + turnSourceChannel: "discord", + turnSourceAccountId: "default", + }); + expect( + doesApprovalRequestSelectChannelAccount({ + cfg: {}, + request: discordRequest, + channel: "slack", + accountId: "default", + defaultAccountId: "default", + eligibleAccountIds: ["default"], + }), + ).toBe(false); + }); + it("selects only the sole eligible account when no owner is recorded", () => { expect( doesApprovalRequestSelectChannelAccount({ diff --git a/src/meeting-bot/configured-node-host.test.ts b/src/meeting-bot/configured-node-host.test.ts index 7cc758ac912b..0fe5d4769b5b 100644 --- a/src/meeting-bot/configured-node-host.test.ts +++ b/src/meeting-bot/configured-node-host.test.ts @@ -11,7 +11,12 @@ vi.mock("node:child_process", async (importOriginal) => ({ import { createMeetingConfiguredNodeHost } from "./configured-node-host.js"; -function createHost() { +function createHost( + commands: { + input?: string[]; + output?: string[]; + } = {}, +) { return createMeetingConfiguredNodeHost({ agentMode: "agent", bridgeIdPrefix: "test-node-", @@ -28,8 +33,8 @@ function createHost() { bufferBytes: 4_096, format: "pcm16-24khz", }, - defaultAudioInputCommand: ["legacy-capture"], - defaultAudioOutputCommand: ["legacy-playback"], + defaultAudioInputCommand: commands.input ?? ["legacy-capture"], + defaultAudioOutputCommand: commands.output ?? ["legacy-playback"], displayName: "Test meeting", meetingLabel: "Test meeting", normalizeMeetingKey: (url) => url, @@ -147,4 +152,22 @@ describe("configured meeting node host", () => { ); } }); + + it("probes one executable once when every configured audio command shares it", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + childProcessMocks.spawnSync.mockImplementation((command: string) => ({ + status: 0, + stdout: command.endsWith("system_profiler") ? "BlackHole 2ch" : "", + stderr: "", + })); + + await createHost({ input: ["sox", "capture"], output: ["sox", "play"] })( + JSON.stringify({ action: "setup", bargeInInputCommand: ["sox", "barge"] }), + ); + + const soxProbes = childProcessMocks.spawnSync.mock.calls.filter( + (call) => Array.isArray(call[1]) && call[1].at(-1) === "sox", + ); + expect(soxProbes).toHaveLength(1); + }); }); diff --git a/src/plugins/npm-install-security-scan.release.test.ts b/src/plugins/npm-install-security-scan.release.test.ts index 0b8f80fcf73f..d8b9419f9e85 100644 --- a/src/plugins/npm-install-security-scan.release.test.ts +++ b/src/plugins/npm-install-security-scan.release.test.ts @@ -32,7 +32,6 @@ const REQUIRED_REVIEWED_PUBLISHABLE_CRITICAL_FINDING_COUNTS = new Map { ...metadataSnapshot, index: { installRecords: { - demo: { source: "registry", version: "1.0.0" }, + demo: { source: "npm", version: "1.0.0" }, }, plugins: [], policyHash: "policy", @@ -282,10 +282,10 @@ describe("resolvePluginRuntimeLoadContext", () => { }); expect(context.installRecords).toEqual({ - demo: { source: "registry", version: "1.0.0" }, + demo: { source: "npm", version: "1.0.0" }, }); expect(buildPluginRuntimeLoadOptions(context).installRecords).toEqual({ - demo: { source: "registry", version: "1.0.0" }, + demo: { source: "npm", version: "1.0.0" }, }); }); diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index 83ef1657a2eb..2acbffd30778 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -565,6 +565,10 @@ print_log_tail "$LOG_PATH" const compiledResult = resolveEntrypoint(compiledRoot); expect(compiledResult.status, compiledResult.stderr).toBe(0); expect(compiledResult.stdout.trim()).toBe(compiledEntrypoint); + + const helper = readFileSync(DOCKER_E2E_IMAGE_HELPER_PATH, "utf8"); + expect(helper).toContain('node "$entrypoint" "$@"'); + expect(helper).not.toContain('node --import tsx "$entrypoint"'); }); it("rejects malformed Docker E2E resource limits before a suite starts", () => { @@ -4060,9 +4064,14 @@ source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh" it("keeps the kitchen-sink RPC Docker watchdog above the internal walk budgets", () => { const runner = readFileSync(KITCHEN_SINK_RPC_DOCKER_E2E_PATH, "utf8"); + const walk = readFileSync("scripts/e2e/kitchen-sink-rpc-walk.mts", "utf8"); expect(runner).toContain( 'DOCKER_RUN_TIMEOUT="${OPENCLAW_KITCHEN_SINK_RPC_DOCKER_RUN_TIMEOUT:-1500s}"', ); + expect(runner).toContain("tsx scripts/e2e/kitchen-sink-rpc-walk.mts"); + expect(runner).not.toContain("node --import tsx scripts/e2e/kitchen-sink-rpc-walk.mts"); + expect(walk).toContain("../../packages/normalization-core/src/record-coerce.ts"); + expect(walk).not.toContain("@openclaw/normalization-core"); }); it("bounds kitchen-sink plugin CLI commands inside the Docker sweep", () => { @@ -4197,6 +4206,8 @@ source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh" expect(runner).not.toContain( 'run_logged gateway-network-client timeout "$CLIENT_TIMEOUT" docker run --rm', ); + expect(runner).toContain("tsx scripts/e2e/lib/gateway-network/client.mts"); + expect(runner).not.toContain("node --import tsx scripts/e2e/lib/gateway-network/client.mts"); }); it("proves gateway suspension across a same-container process restart", () => { @@ -4280,6 +4291,7 @@ source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh" "--allow-unreleased-changelog", 'local harness_root="${DOCKER_E2E_HARNESS_ROOT_DIR:-$ROOT_DIR}"', '-v "$harness_root/scripts/windows-cmd-helpers.mjs:/app/scripts/windows-cmd-helpers.mjs:ro"', + '-v "$harness_root/packages/gateway-client/src:/app/packages/gateway-client/src:ro"', '-v "$harness_root/packages/normalization-core/src:/app/packages/normalization-core/src:ro"', '-v "$harness_root/test/e2e/qa-lab:/app/test/e2e/qa-lab:ro"', '-v "$harness_root/test/helpers:/app/test/helpers:ro"', diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index d7265beb6471..159bb1bb6a34 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -268,7 +268,7 @@ describe("scripts/lib/plugin-prerelease-test-plan.mts", () => { expect(script).toContain("OPENCLAW_KITCHEN_SINK_COMMAND_MAX_RSS_MIB"); expect(script).toContain("docker_e2e_sample_stats_until_exit"); expect(script).toContain("scripts/e2e/lib/docker-stats/assert-resource-ceiling.mjs"); - expect(script).toContain("node --import tsx scripts/e2e/kitchen-sink-rpc-walk.mts"); + expect(script).toContain("tsx scripts/e2e/kitchen-sink-rpc-walk.mts"); expect(walkScript).toContain("commands.list"); expect(walkScript).toContain("tools.invoke"); expect(walkScript).toContain("tts.providers");