merge: sync prepared model handoff with main

* origin/main:
  test(qa): prove agent tool approval controls (#119029)
  docs(agents): require exact-head ClawSweeper re-review after post-review pushes (#119069)
  fix(qa): provision ffmpeg for Playwright scenarios (#119064)
  test(qa): repair scenario catalog baselines (#119062)
  test(qa): prove agent session scope continuity (#119032)
  test(qa): prove workspace mutation tools (#119021)
  fix(cli): honor local port in gateway call (#119046)
  test(xai): cover playback mark overflow
  fix(xai): bound realtime playback marks
This commit is contained in:
Vincent Koc
2026-08-04 11:08:38 +08:00
21 changed files with 927 additions and 41 deletions
+1 -1
View File
@@ -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 <symbol>`) 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.
+5 -1
View File
@@ -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}'
<ParamField path="--url <url>" type="string">
Gateway WebSocket URL.
</ParamField>
<ParamField path="--port <port>" type="number">
Target a local loopback Gateway on this port. Overrides `OPENCLAW_GATEWAY_URL` and `OPENCLAW_GATEWAY_PORT` for this call. Cannot combine with `--url`.
</ParamField>
<ParamField path="--token <token>" type="string">
Gateway token.
</ParamField>
@@ -496,7 +500,7 @@ openclaw gateway call logs.tail --params '{"limit": 200}'
</ParamField>
<Note>
`--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.
</Note>
## Manage the Gateway service
+1 -1
View File
@@ -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
@@ -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") {
@@ -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",
@@ -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,
+13 -5
View File
@@ -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");
}
+1
View File
@@ -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;
+1 -2
View File
@@ -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":
+12 -1
View File
@@ -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);
}
@@ -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
@@ -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.
@@ -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
@@ -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.
@@ -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.
@@ -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.
@@ -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);
+2 -1
View File
@@ -572,10 +572,11 @@ export function registerGatewayCli(program: Command, deps: GatewayCliDependencie
.description("Call a Gateway method")
.argument("<method>", "Method name (health/status/system-presence/cron.*)")
.option("--params <json>", "JSON object string for params", "{}")
.option("--port <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) {
@@ -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<ReturnType<typeof startQaGatewayChild>>;
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<void>> = [];
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<Record<string, unknown>> = [];
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<string, unknown>;
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<void>((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<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
},
};
}
async function connectOperator(gateway: GatewayHandle): Promise<GatewayClient> {
return await new Promise<GatewayClient>((resolve, reject) => {
let settled = false;
let timeout: ReturnType<typeof setTimeout>;
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<string, unknown>): CanonicalTurn[] {
return markerTurns(Array.isArray(request.input) ? request.input : []);
}
async function runAgentTurn(params: {
client: GatewayClient;
sessionKey: string;
userText: string;
runId: string;
}): Promise<void> {
const accepted = await params.client.request<AgentResult>("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<AgentResult>(
"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<SessionRow> {
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<ChatHistory> {
return await client.request<ChatHistory>("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);
},
);
});
@@ -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<Parameters<EmbeddedPluginApprovalBroker["subscribe"]>[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<void> {
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<typeof vi.fn>;
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",
]);
});
});
@@ -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<ReturnType<AnyAgentTool["execute"]>>): 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");
});