mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 10:55:31 -06:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user