diff --git a/AGENTS.md b/AGENTS.md index 51649bdb2f5b..af621d185cea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ Skills own workflows; root owns hard policy and routing. Product direction and m - Verify the premise before fixing: restrictions and missing links are sometimes intentional design, and removed code often had a reason. Check history (`git log -p -S `) and name the exact line where the reported bug manifests before treating a gap as unfinished work. - Won't-implement and out-of-scope closes are maintainer product judgment. Automated review may recommend with evidence but never executes that close on its own; when design intent is plausible, escalate instead of closing. - Doctrine-class findings are first-class: an action path that can end with no visible outcome and no recorded reason; a default-path regression; prompt/tool-description text that contradicts shipped behavior; multi-signal inference where a recorded fact belongs; a new default-off capability with no named enablement path. -- Before landing any PR: read the latest ClawSweeper comment and its `Rank-up moves:` list. Apply each move, or state in the PR why it is skipped; never merge past them silently. No `@clawsweeper re-review` round-trip is required — the moves are already in the existing comment; re-review only refreshes the rating. +- Before landing any PR: read the latest ClawSweeper comment and its `Rank-up moves:` list. Apply each move, or state in the PR why it is skipped; never merge past them silently. Head unchanged since the reviewed SHA: no `@clawsweeper re-review` round-trip — the moves are already in the existing comment; re-review only refreshes the rating. Head changed after the review (moves applied, fixes pushed): request one exact-head re-review and land when it shows no actionable finding and no remaining rank-up move; the stale comment's verdict does not cover the new code. - Changelog findings: see Docs / Changelog. - Public ClawSweeper comments prefer `https://docs.openclaw.ai/...` when a public docs page exists; structured evidence still cites repo files, lines, SHAs. - Findings need current source, shipped/current behavior, tests/CI evidence, and dependency contract proof when dependency-backed behavior is involved. Validation is judged against touched and sibling surfaces plus this file's commands; clear evidence matters for user-visible changes, with Telegram/Desktop proof for Telegram-visible behavior when feasible. diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 7fcf523160fc..6566fd44e3a2 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -470,6 +470,7 @@ Low-level RPC helper. ```bash openclaw gateway call status +openclaw gateway call health --port 18999 openclaw gateway call logs.tail --params '{"limit": 200}' ``` @@ -479,6 +480,9 @@ openclaw gateway call logs.tail --params '{"limit": 200}' Gateway WebSocket URL. + + Target a local loopback Gateway on this port. Overrides `OPENCLAW_GATEWAY_URL` and `OPENCLAW_GATEWAY_PORT` for this call. Cannot combine with `--url`. + Gateway token. @@ -496,7 +500,7 @@ openclaw gateway call logs.tail --params '{"limit": 200}' -`--params` must be valid JSON, and each method validates its own param shape (extra/misnamed fields are rejected). +`--params` must be valid JSON, and each method validates its own param shape (extra/misnamed fields are rejected). Use `--port` for a custom-port local Gateway; explicit `--url` targets still require explicit credentials. ## Manage the Gateway service diff --git a/docs/platforms/mac/bundled-gateway.md b/docs/platforms/mac/bundled-gateway.md index 59324202510e..7e3bfb223e1e 100644 --- a/docs/platforms/mac/bundled-gateway.md +++ b/docs/platforms/mac/bundled-gateway.md @@ -115,7 +115,7 @@ openclaw gateway --port 18999 --bind loopback Then: ```bash -openclaw gateway call health --url ws://127.0.0.1:18999 --timeout 3000 +openclaw gateway call health --port 18999 --timeout 3000 ``` ## Related diff --git a/extensions/qa-lab/src/scenario-catalog.test.ts b/extensions/qa-lab/src/scenario-catalog.test.ts index 79696239a1a8..14b6782d8f2a 100644 --- a/extensions/qa-lab/src/scenario-catalog.test.ts +++ b/extensions/qa-lab/src/scenario-catalog.test.ts @@ -674,7 +674,10 @@ describe("qa scenario catalog", () => { it("loads the opt-in update.run package self-upgrade script proof", () => { const scenario = readQaScenarioById("update-run-package-self-upgrade"); - expect(scenario.coverage?.primary).toEqual([`${cli}.update-status-and-rpc`]); + expect(scenario.coverage?.primary).toEqual([ + `${cli}.update-status-and-rpc`, + "gateway.update-and-setup-apis", + ]); expect(scenario.coverage?.secondary).toEqual([`${cli}.managed-gateway-restart`]); expect(scenario.execution.kind).toBe("script"); if (scenario.execution.kind !== "script") { diff --git a/extensions/qa-lab/src/test-file-scenario-runner.test.ts b/extensions/qa-lab/src/test-file-scenario-runner.test.ts index ecbb2867d346..30db81481148 100644 --- a/extensions/qa-lab/src/test-file-scenario-runner.test.ts +++ b/extensions/qa-lab/src/test-file-scenario-runner.test.ts @@ -262,7 +262,7 @@ describe("qa test file scenario runner", () => { expect(result.executionKind).toBe("playwright"); expect(commands.map((command) => command.args)).toEqual([ - ["scripts/ensure-playwright-chromium.mjs", "--skip-ffmpeg"], + ["scripts/ensure-playwright-chromium.mjs"], [ "scripts/run-vitest.mjs", "run", diff --git a/extensions/qa-lab/src/test-file-scenario-runner.ts b/extensions/qa-lab/src/test-file-scenario-runner.ts index a4a90abf9288..eda3a6ee45fc 100644 --- a/extensions/qa-lab/src/test-file-scenario-runner.ts +++ b/extensions/qa-lab/src/test-file-scenario-runner.ts @@ -143,7 +143,7 @@ function playwrightSteps( return [ { command: process.execPath, - args: ["scripts/ensure-playwright-chromium.mjs", "--skip-ffmpeg"], + args: ["scripts/ensure-playwright-chromium.mjs"], }, { command: process.execPath, diff --git a/extensions/xai/realtime-voice-bridge.ts b/extensions/xai/realtime-voice-bridge.ts index a24062c20208..785162adad16 100644 --- a/extensions/xai/realtime-voice-bridge.ts +++ b/extensions/xai/realtime-voice-bridge.ts @@ -26,6 +26,7 @@ import { type XaiRealtimeEvent, } from "./realtime-voice-config.js"; import { XaiRealtimeMalformedAudioError, XaiRealtimeVoiceEvents } from "./realtime-voice-events.js"; +import { XaiRealtimePlaybackMarkOverflowError } from "./realtime-voice-protocol.js"; import { xaiUserAgentHeaderFor } from "./src/xai-user-agent.js"; export class XaiRealtimeVoiceBridge extends XaiRealtimeVoiceEvents implements RealtimeVoiceBridge { @@ -230,7 +231,10 @@ export class XaiRealtimeVoiceBridge extends XaiRealtimeVoiceEvents implements Re attempt.resolve(true); } } catch (error) { - if (error instanceof XaiRealtimeMalformedAudioError) { + if ( + error instanceof XaiRealtimeMalformedAudioError || + error instanceof XaiRealtimePlaybackMarkOverflowError + ) { attempt.reject(error); this.failConnection(error, ws, connection); return; @@ -468,21 +472,25 @@ export class XaiRealtimeVoiceBridge extends XaiRealtimeVoiceEvents implements Re } private failConnection( - error: XaiRealtimeMalformedAudioError, + error: XaiRealtimeMalformedAudioError | XaiRealtimePlaybackMarkOverflowError, ws: WebSocket, connection: RealtimeVoiceSessionConnection, ): void { - if (this.terminalError) { + if (!this.lifecycle.failure(connection)) { return; } this.terminalError = error; - this.lifecycle.failure(connection); this.resetTerminalState(); try { this.config.onError?.(error); } finally { if (ws.readyState !== WebSocket.CLOSED) { - ws.close(1002, "Malformed audio payload"); + ws.close( + 1002, + error instanceof XaiRealtimePlaybackMarkOverflowError + ? "Playback mark overflow" + : "Malformed audio payload", + ); } else { this.notifyClose(connection, "error"); } diff --git a/extensions/xai/realtime-voice-config.ts b/extensions/xai/realtime-voice-config.ts index a5e821cc5c51..b5f9244c389c 100644 --- a/extensions/xai/realtime-voice-config.ts +++ b/extensions/xai/realtime-voice-config.ts @@ -114,6 +114,7 @@ export const XAI_REALTIME_MAX_RECONNECT_ATTEMPTS = 5; export const XAI_REALTIME_BASE_RECONNECT_DELAY_MS = 1000; export const XAI_REALTIME_MAX_PENDING_TOOL_RESULTS = 128; export const XAI_REALTIME_MAX_PENDING_USER_MESSAGES = 128; +export const XAI_REALTIME_MAX_PENDING_PLAYBACK_MARKS = 1_024; export const XAI_REALTIME_DEFAULT_VAD_THRESHOLD = 0.85; export const XAI_REALTIME_DEFAULT_PREFIX_PADDING_MS = 333; export const XAI_REALTIME_DEFAULT_SILENCE_DURATION_MS = 500; diff --git a/extensions/xai/realtime-voice-events.ts b/extensions/xai/realtime-voice-events.ts index 6e526c8ed18d..12368f0c8d83 100644 --- a/extensions/xai/realtime-voice-events.ts +++ b/extensions/xai/realtime-voice-events.ts @@ -77,7 +77,7 @@ export abstract class XaiRealtimeVoiceEvents extends XaiRealtimeVoiceProtocol { "xAI realtime voice stream returned malformed base64 audio data", ); } - this.config.onAudio(Buffer.from(canonicalAudio, "base64")); + this.emitAudioWithPlaybackMark(Buffer.from(canonicalAudio, "base64")); if (event.item_id && event.item_id !== this.lastAssistantItemId) { this.lastAssistantItemId = event.item_id; this.responseStartTimestamp = this.latestMediaTimestamp; @@ -85,7 +85,6 @@ export abstract class XaiRealtimeVoiceEvents extends XaiRealtimeVoiceProtocol { this.responseStartTimestamp = this.latestMediaTimestamp; } this.responseActive = true; - this.sendMark(); return; } case "input_audio_buffer.speech_started": diff --git a/extensions/xai/realtime-voice-protocol.ts b/extensions/xai/realtime-voice-protocol.ts index 76459add98d7..9d6f395fba66 100644 --- a/extensions/xai/realtime-voice-protocol.ts +++ b/extensions/xai/realtime-voice-protocol.ts @@ -12,12 +12,15 @@ import { XAI_REALTIME_DEFAULT_SILENCE_DURATION_MS, XAI_REALTIME_DEFAULT_VAD_THRESHOLD, XAI_REALTIME_INPUT_TRANSCRIPTION_MODEL, + XAI_REALTIME_MAX_PENDING_PLAYBACK_MARKS, type XaiRealtimeAudioFormatConfig, type XaiRealtimeEvent, type XaiRealtimeSessionUpdate, type XaiRealtimeVoiceBridgeConfig, } from "./realtime-voice-config.js"; +export class XaiRealtimePlaybackMarkOverflowError extends Error {} + export abstract class XaiRealtimeVoiceProtocol { protected readonly audioFormat: RealtimeVoiceAudioFormat; protected markQueue: string[] = []; @@ -317,8 +320,16 @@ export abstract class XaiRealtimeVoiceProtocol { } } - protected sendMark(): void { + protected emitAudioWithPlaybackMark(audio: Buffer): void { + // Playback marks gate the next response. Dropping one would invent an + // acknowledgement, so fail before delivering audio that cannot be tracked. + if (this.markQueue.length >= XAI_REALTIME_MAX_PENDING_PLAYBACK_MARKS) { + throw new XaiRealtimePlaybackMarkOverflowError( + `xAI realtime voice playback mark limit exceeded (${XAI_REALTIME_MAX_PENDING_PLAYBACK_MARKS})`, + ); + } const markName = `audio-${randomUUID()}`; + this.config.onAudio(audio); this.markQueue.push(markName); this.config.onMark?.(markName); } diff --git a/extensions/xai/realtime-voice-provider.test.ts b/extensions/xai/realtime-voice-provider.test.ts index c8ca46f6f73f..1f896cabd03f 100644 --- a/extensions/xai/realtime-voice-provider.test.ts +++ b/extensions/xai/realtime-voice-provider.test.ts @@ -1,6 +1,7 @@ // Xai tests cover realtime voice provider plugin behavior. import { REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ } from "openclaw/plugin-sdk/realtime-voice"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { XAI_REALTIME_MAX_PENDING_PLAYBACK_MARKS } from "./realtime-voice-config.js"; import { buildXaiRealtimeVoiceProvider } from "./realtime-voice-provider.js"; const { FakeWebSocket, isProviderAuthProfileConfiguredMock, resolveApiKeyForProviderMock } = @@ -1256,6 +1257,44 @@ describe("buildXaiRealtimeVoiceProvider", () => { expect(parseSent(socket).slice(-1)).toEqual([{ type: "response.create" }]); }); + it("fails the session when playback marks exceed their ownership bound", async () => { + vi.stubEnv("XAI_API_KEY", "xai-env"); // pragma: allowlist secret + const onAudio = vi.fn(); + const onClose = vi.fn(); + const onError = vi.fn(); + const onMark = vi.fn(); + const bridge = createTestBridge({ onAudio, onClose, onError, onMark }); + const socket = await openRealtimeBridge(bridge); + const delta = Buffer.from("assistant audio").toString("base64"); + + socket.emitServer({ type: "response.created" }); + for (let index = 0; index < XAI_REALTIME_MAX_PENDING_PLAYBACK_MARKS; index += 1) { + socket.emitServer({ type: "response.output_audio.delta", delta }); + } + socket.emitServer({ type: "response.output_audio.delta", delta }); + + expect(onAudio).toHaveBeenCalledTimes(XAI_REALTIME_MAX_PENDING_PLAYBACK_MARKS); + expect(onMark).toHaveBeenCalledTimes(XAI_REALTIME_MAX_PENDING_PLAYBACK_MARKS); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + message: `xAI realtime voice playback mark limit exceeded (${XAI_REALTIME_MAX_PENDING_PLAYBACK_MARKS})`, + }), + ); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith("error"); + expect(socket.closed).toBe(true); + + socket.emitServer({ type: "response.output_audio.delta", delta }); + bridge.close(); + expect(onAudio).toHaveBeenCalledTimes(XAI_REALTIME_MAX_PENDING_PLAYBACK_MARKS); + expect(onError).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + await expect(bridge.connect()).rejects.toThrow( + `xAI realtime voice playback mark limit exceeded (${XAI_REALTIME_MAX_PENDING_PLAYBACK_MARKS})`, + ); + }); + it("preserves pending parallel tool calls across resumed reconnects", async () => { vi.useFakeTimers(); vi.stubEnv("XAI_API_KEY", "xai-env"); // pragma: allowlist secret diff --git a/qa/scenarios/agents/session-scope-continuity.yaml b/qa/scenarios/agents/session-scope-continuity.yaml new file mode 100644 index 000000000000..7def72e28a75 --- /dev/null +++ b/qa/scenarios/agents/session-scope-continuity.yaml @@ -0,0 +1,28 @@ +title: Agent session scope and multi-turn continuity + +scenario: + id: session-scope-continuity + surface: agent-runtime + category: agent-runtime.agent-turn-execution + coverage: + primary: + - agent-runtime.session-scope + - agent-runtime.startup-multi-turn-continuity + objective: Verify fresh embedded agent turns reuse one canonical session transcript while a distinct session key remains isolated. + successCriteria: + - Session A completes two deliver:false agent RPC turns through one real child Gateway and three-call deterministic loopback Responses provider. + - The second provider request contains Session A turn one user and assistant markers followed by turn two user input in exact order. + - Public session and history RPCs expose one stable Session A session ID with exactly four canonical user and assistant transcript messages. + - Session B receives a distinct session ID and neither its provider request nor transcript contains Session A markers. + - The provider is called exactly three times and neither transcript contains duplicate canonical turns. + docsRefs: + - docs/gateway/protocol.md + - docs/help/testing.md + codeRefs: + - src/gateway/server-methods/agent-session-prepare.ts + - src/agents/embedded-agent-runner/history.ts + - test/e2e/qa-lab/runtime/agent-session-scope-continuity.e2e.test.ts + execution: + kind: vitest + path: test/e2e/qa-lab/runtime/agent-session-scope-continuity.e2e.test.ts + summary: Run three fresh embedded turns through one child Gateway, inspect provider history, and compare public session and transcript identities across two keys. diff --git a/qa/scenarios/personal/tool-safety-followthrough.yaml b/qa/scenarios/personal/tool-safety-followthrough.yaml index ee8b66470597..4d6be93b6351 100644 --- a/qa/scenarios/personal/tool-safety-followthrough.yaml +++ b/qa/scenarios/personal/tool-safety-followthrough.yaml @@ -5,8 +5,6 @@ scenario: surface: personal category: tool-safety coverage: - primary: - - agent-runtime.tool-safety-controls secondary: - security.approval-policy-followthrough - security.approval-policy-approvals diff --git a/qa/scenarios/runtime/agent-tool-safety-approvals.yaml b/qa/scenarios/runtime/agent-tool-safety-approvals.yaml new file mode 100644 index 000000000000..f0e80de1ad37 --- /dev/null +++ b/qa/scenarios/runtime/agent-tool-safety-approvals.yaml @@ -0,0 +1,33 @@ +title: Agent tool safety approvals + +scenario: + id: agent-tool-safety-approvals + surface: agent-runtime + coverage: + primary: + - agent-runtime.tool-safety + - agent-runtime.tool-approvals + - agent-runtime.approval-flow-approval-denial + - agent-runtime.approval-flow-approvals + - agent-runtime.approval-flow-followthrough + - agent-runtime.tool-safety-controls + - agent-runtime.tool-safety-controls-safety + objective: Prove approval-gated agent tools cannot execute before a matching decision and that allow-once grants do not persist. + successCriteria: + - Pending approval records identify the exact agent, session, tool call, and allowed decisions before execution. + - Denial clears the request, reports a visible blocked outcome and diagnostic, and produces no tool side effect. + - Allow-once executes rewritten arguments exactly once and returns the sentinel marker. + - Resolution callbacks and broker events match each decision. + - A later tool call creates a fresh approval request instead of inheriting the prior grant. + docsRefs: + - docs/plugins/plugin-permission-requests.md + - docs/tools/exec-approvals.md + codeRefs: + - src/agents/agent-tools.before-tool-call.wrapper.ts + - src/agents/agent-tools.before-tool-call.approval.ts + - src/infra/embedded-plugin-approval-broker.ts + - test/e2e/qa-lab/runtime/agent-tool-safety-approvals.e2e.test.ts + execution: + kind: vitest + path: test/e2e/qa-lab/runtime/agent-tool-safety-approvals.e2e.test.ts + summary: Run a sentinel tool through the production wrapper and embedded approval broker for deny, allow-once, and fresh-request decisions. diff --git a/qa/scenarios/runtime/approval-turn-tool-followthrough.yaml b/qa/scenarios/runtime/approval-turn-tool-followthrough.yaml index 95c71c30463e..6ecfbe07a281 100644 --- a/qa/scenarios/runtime/approval-turn-tool-followthrough.yaml +++ b/qa/scenarios/runtime/approval-turn-tool-followthrough.yaml @@ -5,8 +5,6 @@ scenario: surface: harness runtimePairLane: core coverage: - primary: - - agent-runtime.approval-flow-approvals secondary: - agent-runtime.approval-flow-followthrough objective: Verify a short approval like "ok do it" triggers immediate tool use instead of fake-progress narration. diff --git a/qa/scenarios/runtime/openclaw-workspace-mutation-tools.yaml b/qa/scenarios/runtime/openclaw-workspace-mutation-tools.yaml new file mode 100644 index 000000000000..99fb0075ab01 --- /dev/null +++ b/qa/scenarios/runtime/openclaw-workspace-mutation-tools.yaml @@ -0,0 +1,27 @@ +title: OpenClaw workspace mutation tools + +scenario: + id: openclaw-workspace-mutation-tools + surface: runtime-tools + coverage: + primary: + - agent-runtime.tool-apply-patch + - agent-runtime.tool-edit + objective: Prove OpenClaw's assembled workspace mutation tools persist exact bytes, report useful receipts, and reject workspace escapes. + successCriteria: + - The real assembled apply_patch tool creates an artifact with exact draft bytes and reports its path and add summary. + - A traversal patch is rejected by the workspace root guard without changing the outside sentinel. + - The real assembled edit tool changes the same artifact to exact final bytes and reports its display diff, unified patch, and first changed line. + - Final disk bytes contain no draft marker. + docsRefs: + - docs/help/testing.md + - docs/gateway/sandboxing.md + codeRefs: + - src/agents/agent-tools.ts + - src/agents/apply-patch.ts + - src/agents/sessions/tools/edit.ts + - test/e2e/qa-lab/runtime/openclaw-workspace-mutation-tools.e2e.test.ts + execution: + kind: vitest + path: test/e2e/qa-lab/runtime/openclaw-workspace-mutation-tools.e2e.test.ts + summary: Execute real OpenClaw apply_patch and edit tools against exact workspace and outside-sentinel bytes. diff --git a/src/cli/gateway-cli/register.option-collisions.test.ts b/src/cli/gateway-cli/register.option-collisions.test.ts index d9174e1c790f..81f0fdf8d817 100644 --- a/src/cli/gateway-cli/register.option-collisions.test.ts +++ b/src/cli/gateway-cli/register.option-collisions.test.ts @@ -135,6 +135,23 @@ function firstGatewayStatusCall() { return gatewayStatusCommand.mock.calls[0] ?? []; } +function expectLocalGatewayCall(method: string, port: number, params?: unknown) { + expect(defaultRuntime.error.mock.calls).toEqual([]); + expect(callGatewayCli).toHaveBeenCalledTimes(1); + const [actualMethod, opts, actualParams] = firstGatewayCall(); + expect(actualMethod).toBe(method); + if (params !== undefined) { + expect(actualParams).toEqual(params); + } + const gatewayOpts = opts as + | { config?: { gateway?: { port?: number } }; localPortOverride?: number } + | undefined; + expect(gatewayOpts?.localPortOverride).toBe(port); + expect(gatewayOpts?.config).toEqual({ + gateway: { mode: "local", port }, + }); +} + describe("gateway register option collisions", () => { const sharedProgram: Command = new Command(); @@ -185,6 +202,20 @@ describe("gateway register option collisions", () => { expect(params).toEqual({}); }, }, + { + name: "projects gateway call --port into local config", + argv: ["gateway", "call", "health", "--port", "19084", "--json"], + assert: () => { + expectLocalGatewayCall("health", 19084, {}); + }, + }, + { + name: "inherits parent --port for gateway call", + argv: ["gateway", "--port", "19085", "call", "health", "--json"], + assert: () => { + expectLocalGatewayCall("health", 19085); + }, + }, { name: "forwards --token to gateway probe when parent and child option names collide", argv: ["gateway", "probe", "--token", "tok_probe", "--json"], @@ -217,34 +248,14 @@ describe("gateway register option collisions", () => { name: "projects gateway health --port into local config", argv: ["gateway", "health", "--port", "19081", "--json"], assert: () => { - expect(defaultRuntime.error.mock.calls).toEqual([]); - expect(callGatewayCli).toHaveBeenCalledTimes(1); - const [method, opts] = firstGatewayCall(); - expect(method).toBe("health"); - const gatewayOpts = opts as - | { config?: { gateway?: { port?: number } }; localPortOverride?: number } - | undefined; - expect(gatewayOpts?.localPortOverride).toBe(19081); - expect(gatewayOpts?.config).toEqual({ - gateway: { mode: "local", port: 19081 }, - }); + expectLocalGatewayCall("health", 19081); }, }, { name: "inherits parent --port for gateway health", argv: ["gateway", "--port", "19083", "health", "--json"], assert: () => { - expect(defaultRuntime.error.mock.calls).toEqual([]); - expect(callGatewayCli).toHaveBeenCalledTimes(1); - const [method, opts] = firstGatewayCall(); - expect(method).toBe("health"); - const gatewayOpts = opts as - | { config?: { gateway?: { port?: number } }; localPortOverride?: number } - | undefined; - expect(gatewayOpts?.localPortOverride).toBe(19083); - expect(gatewayOpts?.config).toEqual({ - gateway: { mode: "local", port: 19083 }, - }); + expectLocalGatewayCall("health", 19083); }, }, { @@ -272,6 +283,19 @@ describe("gateway register option collisions", () => { assert(); }); + it("rejects combining --url and --port for gateway call", async () => { + await sharedProgram.parseAsync( + ["gateway", "call", "health", "--url", "ws://127.0.0.1:19084", "--port", "19084", "--json"], + { from: "user" }, + ); + + expect(callGatewayCli).not.toHaveBeenCalled(); + expect(defaultRuntime.error).toHaveBeenCalledWith( + "Gateway call failed: Error: Use either --url or --port, not both.", + ); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + }); + it("uses the effective local port config for gateway health auth diagnostics", async () => { const authError = new Error("gateway auth required"); callGatewayCli.mockRejectedValueOnce(authError); diff --git a/src/cli/gateway-cli/register.ts b/src/cli/gateway-cli/register.ts index ae923c87a7f5..b10ea56eb717 100644 --- a/src/cli/gateway-cli/register.ts +++ b/src/cli/gateway-cli/register.ts @@ -572,10 +572,11 @@ export function registerGatewayCli(program: Command, deps: GatewayCliDependencie .description("Call a Gateway method") .argument("", "Method name (health/status/system-presence/cron.*)") .option("--params ", "JSON object string for params", "{}") + .option("--port ", "Local Gateway port") .action(async (method, opts, command) => { await runGatewayCommand( async () => { - const rpcOpts = resolveGatewayRpcOptions(opts, command); + const rpcOpts = await resolveGatewayRpcOptionsWithLocalPort(opts, command); const params = parseGatewayCallParams(String(opts.params ?? "{}")); const result = await callGatewayCli(method, rpcOpts, params); if (rpcOpts.json) { diff --git a/test/e2e/qa-lab/runtime/agent-session-scope-continuity.e2e.test.ts b/test/e2e/qa-lab/runtime/agent-session-scope-continuity.e2e.test.ts new file mode 100644 index 000000000000..72ed95fad058 --- /dev/null +++ b/test/e2e/qa-lab/runtime/agent-session-scope-continuity.e2e.test.ts @@ -0,0 +1,383 @@ +import { createServer, type ServerResponse } from "node:http"; +import { GatewayClient } from "openclaw/plugin-sdk/gateway-runtime"; +import { afterEach, describe, expect, it } from "vitest"; +import { startQaGatewayChild } from "../../../../extensions/qa-lab/api.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../../../../packages/gateway-protocol/src/client-info.js"; + +const TEST_TIMEOUT_MS = 120_000; +const REQUEST_TIMEOUT_MS = 20_000; +const MODEL_REF = "mock-openai/gpt-5.6-luna"; +const SESSION_A_KEY = "agent:qa:qa:session-scope-continuity-a"; +const SESSION_B_KEY = "agent:qa:qa:session-scope-continuity-b"; +const SESSION_A_USER_1 = "SESSION-SCOPE-A-USER-1"; +const SESSION_A_ASSISTANT_1 = "SESSION-SCOPE-A-ASSISTANT-1"; +const SESSION_A_USER_2 = "SESSION-SCOPE-A-USER-2"; +const SESSION_A_ASSISTANT_2 = "SESSION-SCOPE-A-ASSISTANT-2"; +const SESSION_B_USER_1 = "SESSION-SCOPE-B-USER-1"; +const SESSION_B_ASSISTANT_1 = "SESSION-SCOPE-B-ASSISTANT-1"; +const MARKERS = [ + SESSION_A_USER_1, + SESSION_A_ASSISTANT_1, + SESSION_A_USER_2, + SESSION_A_ASSISTANT_2, + SESSION_B_USER_1, + SESSION_B_ASSISTANT_1, +] as const; + +type GatewayHandle = Awaited>; +type AgentResult = { + runId?: string; + status?: string; + result?: { + payloads?: Array<{ text?: string }>; + }; +}; +type SessionRow = { + key?: string; + sessionId?: string; +}; +type ChatHistory = { + sessionId?: string; + messages?: unknown[]; +}; +type CanonicalTurn = { + role: "user" | "assistant"; + marker: (typeof MARKERS)[number]; +}; + +const cleanups: Array<() => Promise> = []; + +afterEach(async () => { + const errors: unknown[] = []; + for (const cleanup of cleanups.splice(0).toReversed()) { + try { + await cleanup(); + } catch (error) { + errors.push(error); + } + } + if (errors.length === 1) { + throw errors[0]; + } + if (errors.length > 1) { + throw new AggregateError(errors, "session scope continuity cleanup failed"); + } +}); + +function writeResponsesEvents(response: ServerResponse, events: unknown[]): void { + response.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-store", + connection: "keep-alive", + }); + response.end( + `${events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("")}data: [DONE]\n\n`, + ); +} + +function writeAssistantResponse(response: ServerResponse, text: string, index: number): void { + const message = { + type: "message", + id: `qa-session-scope-message-${index}`, + role: "assistant", + status: "completed", + content: [{ type: "output_text", text, annotations: [] }], + }; + writeResponsesEvents(response, [ + { + type: "response.output_item.added", + output_index: 0, + item: { ...message, status: "in_progress", content: [] }, + }, + { type: "response.output_item.done", output_index: 0, item: message }, + { + type: "response.completed", + response: { + id: `qa-session-scope-response-${index}`, + status: "completed", + output: [message], + usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, + }, + }, + ]); +} + +async function startDeterministicProvider() { + const requests: Array> = []; + const replies = [SESSION_A_ASSISTANT_1, SESSION_A_ASSISTANT_2, SESSION_B_ASSISTANT_1]; + const server = createServer((request, response) => { + void (async () => { + if (request.method === "GET" && request.url === "/v1/models") { + response.writeHead(200, { "content-type": "application/json" }); + response.end( + JSON.stringify({ + data: [{ id: "gpt-5.6-luna", object: "model" }], + }), + ); + return; + } + if (request.method !== "POST" || request.url !== "/v1/responses") { + response.writeHead(404).end(); + return; + } + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Record; + requests.push(body); + const reply = replies[requests.length - 1]; + if (!reply) { + response.writeHead(500).end("unexpected provider call"); + return; + } + writeAssistantResponse(response, reply, requests.length); + })().catch((error: unknown) => { + response.writeHead(500).end(error instanceof Error ? error.message : String(error)); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("deterministic provider did not bind a loopback port"); + } + return { + baseUrl: `http://127.0.0.1:${address.port}`, + requests, + stop: async () => { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + }, + }; +} + +async function connectOperator(gateway: GatewayHandle): Promise { + return await new Promise((resolve, reject) => { + let settled = false; + let timeout: ReturnType; + const finish = (error?: Error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + if (error) { + client.stop(); + reject(error); + return; + } + resolve(client); + }; + const client = new GatewayClient({ + url: gateway.wsUrl, + token: gateway.token, + env: gateway.runtimeEnv, + role: "operator", + clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + clientDisplayName: "Session scope continuity client", + clientVersion: "1.0.0", + platform: process.platform, + mode: GATEWAY_CLIENT_MODES.BACKEND, + scopes: ["operator.admin", "operator.read", "operator.write"], + deviceIdentity: null, + requestTimeoutMs: REQUEST_TIMEOUT_MS, + onHelloOk: () => finish(), + onConnectError: (error) => finish(error), + onClose: (code, reason) => finish(new Error(`Gateway closed (${code}): ${reason}`)), + }); + timeout = setTimeout( + () => finish(new Error(`Gateway client connection timed out:\n${gateway.logs()}`)), + REQUEST_TIMEOUT_MS, + ); + timeout.unref(); + client.start(); + }); +} + +function messageRole(message: unknown): "user" | "assistant" | undefined { + if (!message || typeof message !== "object") { + return undefined; + } + const role = (message as { role?: unknown }).role; + return role === "user" || role === "assistant" ? role : undefined; +} + +function messageText(message: unknown): string { + if (!message || typeof message !== "object") { + return ""; + } + const content = (message as { content?: unknown }).content; + if (typeof content === "string") { + return content; + } + if (!Array.isArray(content)) { + return ""; + } + return content + .flatMap((part) => + part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string" + ? [(part as { text: string }).text] + : [], + ) + .join("\n"); +} + +function markerTurns(messages: unknown[]): CanonicalTurn[] { + return messages.flatMap((message) => { + const role = messageRole(message); + if (!role) { + return []; + } + const text = messageText(message); + const markers = MARKERS.filter((marker) => text.includes(marker)); + if (markers.length > 0) { + expect(markers).toHaveLength(1); + } + return markers.map((marker) => ({ role, marker })); + }); +} + +function providerMarkerTurns(request: Record): CanonicalTurn[] { + return markerTurns(Array.isArray(request.input) ? request.input : []); +} + +async function runAgentTurn(params: { + client: GatewayClient; + sessionKey: string; + userText: string; + runId: string; +}): Promise { + const accepted = await params.client.request("agent", { + sessionKey: params.sessionKey, + message: params.userText, + deliver: false, + idempotencyKey: params.runId, + }); + expect(accepted).toMatchObject({ + status: "accepted", + runId: params.runId, + }); + const terminal = await params.client.request( + "agent.wait", + { runId: params.runId, timeoutMs: 30_000 }, + { timeoutMs: 35_000 }, + ); + expect(terminal).toMatchObject({ + status: "ok", + runId: params.runId, + }); +} + +async function readSession(client: GatewayClient, sessionKey: string): Promise { + const result = await client.request<{ sessions?: SessionRow[] }>("sessions.list", { + agentId: "qa", + includeGlobal: true, + limit: 200, + }); + const session = result.sessions?.find((candidate) => candidate.key === sessionKey); + expect(session, `expected sessions.list row for ${sessionKey}`).toBeDefined(); + expect(session?.sessionId).toEqual(expect.any(String)); + return session ?? {}; +} + +async function readHistory(client: GatewayClient, sessionKey: string): Promise { + return await client.request("chat.history", { + sessionKey, + limit: 20, + }); +} + +describe("agent session scope continuity", () => { + it( + "reuses one session across fresh turns without leaking history into another key", + { timeout: TEST_TIMEOUT_MS }, + async () => { + const provider = await startDeterministicProvider(); + cleanups.push(() => provider.stop()); + const gateway = await startQaGatewayChild({ + repoRoot: process.cwd(), + command: { + executablePath: process.execPath, + argsPrefix: ["--import", "tsx", "src/entry.ts"], + cwd: process.cwd(), + usePackagedPlugins: true, + }, + providerBaseUrl: `${provider.baseUrl}/v1`, + providerMode: "mock-openai", + primaryModel: MODEL_REF, + alternateModel: MODEL_REF, + transportBaseUrl: "http://127.0.0.1", + controlUiEnabled: false, + fastMode: true, + runtimeEnvPatch: { + OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1", + OPENCLAW_SKIP_CHANNELS: "1", + OPENCLAW_TEST_MINIMAL_GATEWAY: "1", + }, + mutateConfig: ({ plugins: _plugins, ...config }) => config, + }); + cleanups.push(() => gateway.stop()); + const client = await connectOperator(gateway); + cleanups.push(() => client.stopAndWait({ timeoutMs: 1_000 })); + + await runAgentTurn({ + client, + sessionKey: SESSION_A_KEY, + userText: SESSION_A_USER_1, + runId: "qa-session-scope-a-turn-1", + }); + const sessionAAfterTurn1 = await readSession(client, SESSION_A_KEY); + + await runAgentTurn({ + client, + sessionKey: SESSION_A_KEY, + userText: SESSION_A_USER_2, + runId: "qa-session-scope-a-turn-2", + }); + const sessionAAfterTurn2 = await readSession(client, SESSION_A_KEY); + expect(sessionAAfterTurn2.sessionId).toBe(sessionAAfterTurn1.sessionId); + + const historyA = await readHistory(client, SESSION_A_KEY); + expect(historyA.sessionId).toBe(sessionAAfterTurn1.sessionId); + expect(markerTurns(historyA.messages ?? [])).toEqual([ + { role: "user", marker: SESSION_A_USER_1 }, + { role: "assistant", marker: SESSION_A_ASSISTANT_1 }, + { role: "user", marker: SESSION_A_USER_2 }, + { role: "assistant", marker: SESSION_A_ASSISTANT_2 }, + ]); + expect(providerMarkerTurns(provider.requests[1] ?? {})).toEqual([ + { role: "user", marker: SESSION_A_USER_1 }, + { role: "assistant", marker: SESSION_A_ASSISTANT_1 }, + { role: "user", marker: SESSION_A_USER_2 }, + ]); + + await runAgentTurn({ + client, + sessionKey: SESSION_B_KEY, + userText: SESSION_B_USER_1, + runId: "qa-session-scope-b-turn-1", + }); + const sessionB = await readSession(client, SESSION_B_KEY); + expect(sessionB.sessionId).not.toBe(sessionAAfterTurn1.sessionId); + + const historyB = await readHistory(client, SESSION_B_KEY); + expect(historyB.sessionId).toBe(sessionB.sessionId); + expect(markerTurns(historyB.messages ?? [])).toEqual([ + { role: "user", marker: SESSION_B_USER_1 }, + { role: "assistant", marker: SESSION_B_ASSISTANT_1 }, + ]); + expect(providerMarkerTurns(provider.requests[2] ?? {})).toEqual([ + { role: "user", marker: SESSION_B_USER_1 }, + ]); + expect(JSON.stringify(provider.requests[2])).not.toContain("SESSION-SCOPE-A-"); + expect(provider.requests).toHaveLength(3); + }, + ); +}); diff --git a/test/e2e/qa-lab/runtime/agent-tool-safety-approvals.e2e.test.ts b/test/e2e/qa-lab/runtime/agent-tool-safety-approvals.e2e.test.ts new file mode 100644 index 000000000000..36b69921bbb9 --- /dev/null +++ b/test/e2e/qa-lab/runtime/agent-tool-safety-approvals.e2e.test.ts @@ -0,0 +1,219 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getBeforeToolCallFailureDisposition, + wrapToolWithBeforeToolCallHook, +} from "../../../../src/agents/agent-tools.before-tool-call.js"; +import type { AnyAgentTool } from "../../../../src/agents/tools/common.js"; +import { + onInternalDiagnosticEvent, + resetDiagnosticEventsForTest, + type DiagnosticEventPayload, +} from "../../../../src/infra/diagnostic-events.js"; +import { setEmbeddedMode } from "../../../../src/infra/embedded-mode.js"; +import { + EmbeddedPluginApprovalBroker, + setEmbeddedPluginApprovalBroker, +} from "../../../../src/infra/embedded-plugin-approval-broker.js"; +import { resetGlobalHookRunner } from "../../../../src/plugins/hook-runner-global.js"; +import { createEmptyPluginRegistry } from "../../../../src/plugins/registry-empty.js"; +import { setActivePluginRegistry } from "../../../../src/plugins/runtime.js"; +import { PluginApprovalResolutions } from "../../../../src/plugins/types.js"; + +type BrokerEvent = Parameters[0]>[0]; + +const AGENT_ID = "qa-agent"; +const SESSION_KEY = "agent:qa-agent:approval"; +const ALLOWED_DECISIONS = ["allow-once", "deny"] as const; + +function flushDiagnostics(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +describe("agent tool safety approvals", () => { + let broker: EmbeddedPluginApprovalBroker; + let brokerEvents: BrokerEvent[]; + let resolutions: Array<{ toolCallId?: string; resolution: string }>; + let execute: ReturnType; + let tool: AnyAgentTool; + + beforeEach(() => { + resetDiagnosticEventsForTest(); + resetGlobalHookRunner(); + setEmbeddedMode(true); + broker = new EmbeddedPluginApprovalBroker(); + brokerEvents = []; + resolutions = []; + broker.subscribe((event) => brokerEvents.push(event)); + setEmbeddedPluginApprovalBroker(broker); + + const registry = createEmptyPluginRegistry(); + registry.trustedToolPolicies = [ + { + pluginId: "qa-safety-policy", + pluginName: "QA Safety Policy", + source: "test", + policy: { + id: "approval-gate", + description: "Gate sentinel execution", + evaluate: (event) => ({ + params: { + value: `rewritten:${event.toolCallId}`, + marker: "APPROVED-SENTINEL", + }, + requireApproval: { + pluginId: "qa-safety-policy", + title: "Approve sentinel tool", + description: "Allow this exact sentinel invocation?", + severity: "warning", + allowedDecisions: [...ALLOWED_DECISIONS], + onResolution: (resolution) => { + resolutions.push({ toolCallId: event.toolCallId, resolution }); + }, + }, + }), + }, + }, + ]; + setActivePluginRegistry(registry); + + execute = vi.fn(async (_toolCallId: string, params: unknown) => ({ + content: [{ type: "text" as const, text: "APPROVED-SENTINEL" }], + details: { params }, + })); + tool = wrapToolWithBeforeToolCallHook( + { name: "sentinel", execute } as unknown as AnyAgentTool, + { + agentId: AGENT_ID, + sessionKey: SESSION_KEY, + loopDetection: { enabled: false }, + }, + ); + }); + + afterEach(() => { + broker.stop(); + setEmbeddedPluginApprovalBroker(null); + setEmbeddedMode(false); + setActivePluginRegistry(createEmptyPluginRegistry()); + resetGlobalHookRunner(); + resetDiagnosticEventsForTest(); + }); + + async function pendingApproval(toolCallId: string) { + await vi.waitFor(() => expect(broker.listPending()).toHaveLength(1)); + const pending = broker.listPending()[0]; + expect(pending).toBeDefined(); + expect(pending?.request).toMatchObject({ + pluginId: "qa-safety-policy", + title: "Approve sentinel tool", + description: "Allow this exact sentinel invocation?", + severity: "warning", + allowedDecisions: ALLOWED_DECISIONS, + toolName: "sentinel", + toolCallId, + agentId: AGENT_ID, + sessionKey: SESSION_KEY, + }); + return pending!; + } + + it("denies without execution and records a visible blocked outcome", async () => { + const toolEvents: DiagnosticEventPayload[] = []; + const stopDiagnostics = onInternalDiagnosticEvent((event) => { + if (event.type.startsWith("tool.execution.")) { + toolEvents.push(event); + } + }); + try { + const result = tool.execute("call-deny", { value: "original" }, undefined, undefined); + const pending = await pendingApproval("call-deny"); + await flushDiagnostics(); + + expect(execute).not.toHaveBeenCalled(); + expect(toolEvents).toEqual([]); + expect(brokerEvents).toEqual([{ event: "plugin.approval.requested", payload: pending }]); + + expect(broker.resolve(pending.id, "deny")).toBe(true); + let denied: unknown; + try { + await result; + } catch (error) { + denied = error; + } + await flushDiagnostics(); + + expect(denied).toMatchObject({ + name: "BeforeToolCallFailureError", + message: "Denied by user", + }); + expect(getBeforeToolCallFailureDisposition(denied)).toBe("blocked"); + expect(execute).not.toHaveBeenCalled(); + expect(broker.listPending()).toEqual([]); + expect(resolutions).toEqual([ + { toolCallId: "call-deny", resolution: PluginApprovalResolutions.DENY }, + ]); + expect(brokerEvents.at(-1)).toMatchObject({ + event: "plugin.approval.resolved", + payload: { id: pending.id, decision: "deny", request: pending.request }, + }); + expect(toolEvents).toContainEqual( + expect.objectContaining({ + type: "tool.execution.blocked", + toolName: "sentinel", + toolCallId: "call-deny", + deniedReason: "plugin-approval", + }), + ); + } finally { + stopDiagnostics(); + } + }); + + it("executes rewritten args once and requires a fresh later approval", async () => { + const first = tool.execute("call-allow", { value: "original" }, undefined, undefined); + const firstPending = await pendingApproval("call-allow"); + expect(execute).not.toHaveBeenCalled(); + expect(broker.resolve(firstPending.id, "allow-once")).toBe(true); + + await expect(first).resolves.toEqual({ + content: [{ type: "text", text: "APPROVED-SENTINEL" }], + details: { + params: { + value: "rewritten:call-allow", + marker: "APPROVED-SENTINEL", + }, + }, + }); + expect(execute).toHaveBeenCalledTimes(1); + expect(execute).toHaveBeenCalledWith( + "call-allow", + { value: "rewritten:call-allow", marker: "APPROVED-SENTINEL" }, + undefined, + undefined, + ); + expect(resolutions).toEqual([ + { toolCallId: "call-allow", resolution: PluginApprovalResolutions.ALLOW_ONCE }, + ]); + expect(broker.listPending()).toEqual([]); + + const later = tool.execute("call-later", { value: "second" }, undefined, undefined); + const laterPending = await pendingApproval("call-later"); + expect(laterPending.id).not.toBe(firstPending.id); + expect(execute).toHaveBeenCalledTimes(1); + expect(broker.resolve(laterPending.id, "deny")).toBe(true); + await expect(later).rejects.toThrow("Denied by user"); + + expect(execute).toHaveBeenCalledTimes(1); + expect(broker.listPending()).toEqual([]); + expect(resolutions).toEqual([ + { toolCallId: "call-allow", resolution: PluginApprovalResolutions.ALLOW_ONCE }, + { toolCallId: "call-later", resolution: PluginApprovalResolutions.DENY }, + ]); + expect(brokerEvents.map((event) => event.event)).toEqual([ + "plugin.approval.requested", + "plugin.approval.resolved", + "plugin.approval.requested", + "plugin.approval.resolved", + ]); + }); +}); diff --git a/test/e2e/qa-lab/runtime/openclaw-workspace-mutation-tools.e2e.test.ts b/test/e2e/qa-lab/runtime/openclaw-workspace-mutation-tools.e2e.test.ts new file mode 100644 index 000000000000..2dd9b05fa671 --- /dev/null +++ b/test/e2e/qa-lab/runtime/openclaw-workspace-mutation-tools.e2e.test.ts @@ -0,0 +1,110 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { createOpenClawCodingTools } from "../../../../src/agents/agent-tools.js"; +import type { AnyAgentTool } from "../../../../src/agents/agent-tools.types.js"; +import type { OpenClawConfig } from "../../../../src/config/types.openclaw.js"; +import { useAutoCleanupTempDirTracker } from "../../../helpers/temp-dir.js"; + +const ARTIFACT = "mutation-receipt.md"; +const DRAFT = "# Workspace mutation\n\nstate: DRAFT\n"; +const FINAL = "# Workspace mutation\n\nstate: FINAL\n"; +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +function requireTool(tools: AnyAgentTool[], name: string): AnyAgentTool { + const tool = tools.find((candidate) => candidate.name === name); + if (!tool) { + throw new Error(`expected assembled ${name} tool`); + } + return tool; +} + +function textOf(result: Awaited>): string { + return result.content.find((part) => part.type === "text")?.text ?? ""; +} + +test("OpenClaw applies and edits exact workspace bytes while rejecting escapes", async () => { + // Resolve once so macOS /var and /private/var aliases cannot skew workspace checks. + const root = await fs.realpath(tempDirs.make("openclaw-workspace-mutation-")); + const workspace = path.join(root, "workspace"); + const sentinel = path.join(root, "outside-sentinel"); + await fs.mkdir(workspace); + await fs.writeFile(sentinel, "OUTSIDE\n", "utf8"); + + const config: OpenClawConfig = { + tools: { + fs: { workspaceOnly: true }, + exec: { applyPatch: { enabled: true, workspaceOnly: true } }, + }, + }; + const tools = createOpenClawCodingTools({ + workspaceDir: workspace, + modelProvider: "openai", + modelId: "gpt-5.4", + config, + toolConstructionPlan: { + includeBaseCodingTools: true, + includeShellTools: true, + includeChannelTools: false, + includeOpenClawTools: false, + includePluginTools: false, + }, + }); + const applyPatch = requireTool(tools, "apply_patch"); + const edit = requireTool(tools, "edit"); + + const applied = await applyPatch.execute("apply-draft", { + input: [ + "*** Begin Patch", + `*** Add File: ${ARTIFACT}`, + "+# Workspace mutation", + "+", + "+state: DRAFT", + "*** End Patch", + ].join("\n"), + }); + expect(textOf(applied)).toBe(`Success. Updated the following files:\nA ${ARTIFACT}`); + expect(applied.details).toEqual({ + summary: { added: [ARTIFACT], modified: [], deleted: [] }, + }); + await expect(fs.readFile(path.join(workspace, ARTIFACT), "utf8")).resolves.toBe(DRAFT); + + await expect( + applyPatch.execute("reject-escape", { + input: [ + "*** Begin Patch", + "*** Update File: ../outside-sentinel", + "@@", + "-OUTSIDE", + "+ESCAPED", + "*** End Patch", + ].join("\n"), + }), + ).rejects.toThrow(/Path escapes sandbox root/); + await expect(fs.readFile(sentinel, "utf8")).resolves.toBe("OUTSIDE\n"); + + const edited = await edit.execute("edit-final", { + path: ARTIFACT, + edits: [{ oldText: "state: DRAFT", newText: "state: FINAL" }], + }); + expect(textOf(edited)).toBe(`Successfully replaced 1 block(s) in ${ARTIFACT}.`); + expect(edited.details).toMatchObject({ + changed: true, + firstChangedLine: 3, + }); + const details = edited.details as { + changed: true; + diff: string; + patch: string; + firstChangedLine?: number; + }; + expect(details.diff).toBe( + [" 1 # Workspace mutation", " 2 ", "-3 state: DRAFT", "+3 state: FINAL"].join("\n"), + ); + expect(details.patch).toContain(`--- ${ARTIFACT}`); + expect(details.patch).toContain(`+++ ${ARTIFACT}`); + expect(details.patch).toContain("-state: DRAFT\n+state: FINAL"); + const finalBytes = await fs.readFile(path.join(workspace, ARTIFACT), "utf8"); + expect(finalBytes).toBe(FINAL); + expect(finalBytes).not.toContain("DRAFT"); +});