Compare commits

..

3 Commits

Author SHA1 Message Date
joshavant
85afa3bce2 fix: forward codex approval reviewer device 2026-06-20 11:23:26 +02:00
joshavant
cd2ad47743 fix: surface iOS approval events in foreground 2026-06-20 06:07:19 +02:00
joshavant
802d2c21cb fix: route mobile exec approvals to reviewer device 2026-06-20 04:01:13 +02:00
91 changed files with 1329 additions and 1952 deletions

View File

@@ -23,6 +23,10 @@ private struct WatchChatPreview {
var statusText: String?
}
private struct ExecApprovalGatewayEventPayload: Decodable {
var id: String
}
/// Ensures notification requests return promptly even if the system prompt blocks.
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
private let lock = NSLock()
@@ -895,26 +899,49 @@ final class NodeAppModel {
for await evt in stream {
if Task.isCancelled { return }
guard let payload = evt.payload else { continue }
switch evt.event {
case "voicewake.changed":
struct Payload: Decodable { var triggers: [String] }
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
VoiceWakePreferences.saveTriggerWords(triggers)
case "talk.mode":
struct Payload: Decodable {
var enabled: Bool
var phase: String?
}
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase)
default:
continue
}
await self.handleOperatorGatewayServerEvent(evt)
}
}
}
private func handleOperatorGatewayServerEvent(_ evt: EventFrame) async {
guard let payload = evt.payload else { return }
switch evt.event {
case "voicewake.changed":
struct Payload: Decodable { var triggers: [String] }
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { return }
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
VoiceWakePreferences.saveTriggerWords(triggers)
case "talk.mode":
struct Payload: Decodable {
var enabled: Bool
var phase: String?
}
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { return }
self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase)
case ExecApprovalNotificationBridge.requestedKind:
guard let approvalId = Self.execApprovalEventID(from: payload) else { return }
await self.presentExecApprovalNotificationPrompt(
ExecApprovalNotificationPrompt(approvalId: approvalId))
case ExecApprovalNotificationBridge.resolvedKind:
guard let approvalId = Self.execApprovalEventID(from: payload) else { return }
await self.handleExecApprovalResolvedRemotePush(approvalId: approvalId)
default:
return
}
}
private nonisolated static func execApprovalEventID(from payload: AnyCodable) -> String? {
guard let decoded = try? GatewayPayloadDecoding.decode(
payload,
as: ExecApprovalGatewayEventPayload.self)
else {
return nil
}
let approvalId = decoded.id.trimmingCharacters(in: .whitespacesAndNewlines)
return approvalId.isEmpty ? nil : approvalId
}
private func applyTalkModeSync(enabled: Bool, phase: String?) {
_ = phase
guard self.talkMode.isEnabled != enabled else { return }
@@ -5139,6 +5166,14 @@ extension NodeAppModel {
isBackgrounded: isBackgrounded)
}
nonisolated static func _test_execApprovalEventID(from payload: AnyCodable) -> String? {
self.execApprovalEventID(from: payload)
}
func _test_handleOperatorGatewayServerEvent(_ event: EventFrame) async {
await self.handleOperatorGatewayServerEvent(event)
}
nonisolated static func _test_watchExecApprovalIDsNeedingFetch(
candidateIDs: [String],
cachedApprovalIDs: [String]) -> [String]

View File

@@ -1160,6 +1160,35 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
isBackgrounded: false))
}
@Test func execApprovalEventIDDecodesGatewayPayload() {
#expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["id": " approval-1 "])) == "approval-1")
#expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["id": " "])) == nil)
#expect(NodeAppModel._test_execApprovalEventID(from: AnyCodable(["other": "approval-1"])) == nil)
}
@Test @MainActor func operatorGatewayResolvedEventClearsPendingApprovalPrompt() async throws {
let appModel = NodeAppModel()
try appModel._test_presentExecApprovalPrompt(
#require(
NodeAppModel._test_makeExecApprovalPrompt(
id: "approval-event-resolved",
commandText: "echo clear",
allowedDecisions: ["allow-once", "deny"],
host: "gateway",
nodeId: nil,
agentId: nil,
expiresAtMs: Int(Date().timeIntervalSince1970 * 1000) + 60000)))
await appModel._test_handleOperatorGatewayServerEvent(EventFrame(
type: "event",
event: ExecApprovalNotificationBridge.resolvedKind,
payload: AnyCodable(["id": "approval-event-resolved"]),
seq: nil,
stateversion: nil))
#expect(appModel._test_pendingExecApprovalPrompt() == nil)
}
@Test func watchExecApprovalHydrateFetchesOnlyMissingIDs() {
let idsToFetch = NodeAppModel._test_watchExecApprovalIDsNeedingFetch(
candidateIDs: ["cached", "pending", "cached", "other", "", " pending "],

View File

@@ -128,6 +128,10 @@ const config = {
"**/*.test-utils.ts",
"test/helpers/live-image-probe.ts",
"src/secrets/credential-matrix.ts",
"src/agents/claude-cli-runner.ts",
"src/agents/agent-auth-json.ts",
"src/agents/tool-policy.conformance.ts",
"src/auto-reply/reply/audio-tags.ts",
"src/gateway/live-tool-probe-utils.ts",
"src/gateway/server.auth.shared.ts",
"src/shared/text/assistant-visible-text.ts",

View File

@@ -961,6 +961,26 @@ describe("Codex app-server dynamic tool build", () => {
});
});
it("passes the approval reviewer device into Codex dynamic tools", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.approvalReviewerDeviceId = "device-ios-reviewer";
params.runtimePlan = createCodexRuntimePlanFixture();
const factoryOptions: unknown[] = [];
setOpenClawCodingToolsFactoryForTests((options) => {
factoryOptions.push(options);
return [];
});
await buildDynamicToolsForTest(params, workspaceDir, { sandbox: null as never });
expect(factoryOptions[0]).toMatchObject({
approvalReviewerDeviceId: "device-ios-reviewer",
});
});
it("forwards tool outcome ordering into Codex dynamic tools", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -19,10 +19,7 @@ import {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
import { isToolAllowed } from "openclaw/plugin-sdk/sandbox";
import {
readCodexPluginConfig,
type CodexPluginConfig,
} from "./config.js";
import { readCodexPluginConfig, type CodexPluginConfig } from "./config.js";
import {
filterCodexDynamicTools,
isForcedPrivateQaCodexRuntime,
@@ -260,6 +257,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
...sessionKeys,
sessionId: params.sessionId,
runId: params.runId,
approvalReviewerDeviceId: params.approvalReviewerDeviceId,
agentDir,
cwd: input.effectiveCwd ?? input.effectiveWorkspace,
workspaceDir: input.effectiveWorkspace,
@@ -593,9 +591,10 @@ export function resolveCodexAppServerExecutionCwd(params: {
nativeToolSurfaceEnabled: boolean;
remoteWorkspaceRoot?: string;
}): string {
const cwd = params.environment && params.nativeToolSurfaceEnabled
? params.environment.cwd
: params.effectiveCwd;
const cwd =
params.environment && params.nativeToolSurfaceEnabled
? params.environment.cwd
: params.effectiveCwd;
return mapCodexAppServerRemoteWorkspacePath({
value: cwd,
localWorkspaceRoot: params.localWorkspaceRoot,

View File

@@ -1,18 +0,0 @@
import fs from "node:fs/promises";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { QaSuiteArtifactError } from "./errors.js";
export async function assertQaSuiteArtifactWritten(
kind: "evidence" | "report" | "summary",
filePath: string,
) {
try {
await fs.access(filePath);
} catch (error) {
throw new QaSuiteArtifactError(
`${kind}_missing`,
`QA suite did not produce ${kind} artifact at ${filePath}: ${formatErrorMessage(error)}`,
{ cause: error },
);
}
}

View File

@@ -3,7 +3,6 @@ import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { toQaErrorObject } from "./errors.js";
import { seedQaAgentWorkspace } from "./qa-agent-workspace.js";
import {
createQaChannelGatewayConfig,
@@ -344,7 +343,7 @@ export async function buildQaDockerHarnessImage(
return await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
execFile(command, args, { cwd }, (error, stdout, stderr) => {
if (error) {
reject(toQaErrorObject(error, "Non-Error rejection"));
reject(toLintErrorObject(error, "Non-Error rejection"));
return;
}
resolve({ stdout, stderr });
@@ -369,3 +368,17 @@ export async function buildQaDockerHarnessImage(
return { imageName };
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -34,17 +34,3 @@ export class QaSuiteInfraError extends Error {
this.code = code;
}
}
export function toQaErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -5,9 +5,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import {
buildQaEvidenceGalleryModel,
resolveQaEvidenceArtifactFileByIndex,
resolveQaEvidenceArtifactFile,
resolveQaEvidenceProducerFile,
resolveQaEvidenceFile,
} from "./evidence-gallery.js";
import {
@@ -25,23 +23,6 @@ async function writeJson(filePath: string, value: unknown) {
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function producerRootLeakSegments(repoRoot: string) {
if (process.platform !== "win32") {
return [`nested${repoRoot}`];
}
return [
"nested",
...repoRoot
.split(/[\\/]+/u)
.filter(Boolean)
.map((part) => part.replace(/[^A-Za-z0-9._-]/gu, "_")),
];
}
function repoRelativePath(repoRoot: string, filePath: string) {
return path.relative(repoRoot, filePath).split(path.sep).join("/");
}
function vitestArtifactEvidence(params: {
id: string;
title: string;
@@ -145,7 +126,7 @@ describe("evidence gallery", () => {
expect.objectContaining({
exists: true,
kind: "runner-result",
href: "/api/evidence/artifact?evidencePath=.artifacts%2Fqa-e2e%2Fvitest%2Fqa-evidence.json&entryIndex=0&artifactIndex=0",
href: "/api/evidence/artifact?evidencePath=.artifacts%2Fqa-e2e%2Fvitest%2Fqa-evidence.json&artifactPath=runner%2Fresult.json",
mediaKind: "json",
preview: '{\n "ok": true\n}',
}),
@@ -195,120 +176,10 @@ describe("evidence gallery", () => {
expect(JSON.stringify(model)).not.toContain(repoRoot);
});
it("normalizes absolute source and declared artifact paths for gallery links", async () => {
const repoRoot = await createTempRepo();
const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", "vitest");
const artifactPath = path.join(outputDir, "absolute.log");
await fs.mkdir(outputDir, { recursive: true });
await fs.writeFile(
artifactPath,
`absolute artifact ${repoRoot}\nfile://${repoRoot}/trace.log\n`,
"utf8",
);
const relativeLeakArtifactPath = `nested${repoRoot}/relative.log`;
const relativeLeakFile = path.resolve(outputDir, relativeLeakArtifactPath);
await fs.mkdir(path.dirname(relativeLeakFile), { recursive: true });
await fs.writeFile(relativeLeakFile, "relative artifact\n", "utf8");
const evidence: QaEvidenceSummaryJson = vitestArtifactEvidence({
id: "qa-lab.absolute-artifact-path",
title: "Absolute artifact path",
artifact: { kind: "log", path: artifactPath },
});
evidence.profile = `${repoRoot}/qa-profile`;
evidence.entries[0] = {
...evidence.entries[0],
coverage: [{ id: `${repoRoot}/coverage`, role: `${repoRoot}/role` }],
execution: {
...evidence.entries[0].execution!,
artifacts: [
{
...evidence.entries[0].execution!.artifacts[0],
kind: `${repoRoot}/log`,
source: `${repoRoot}/vitest`,
},
{
kind: "log",
path: relativeLeakArtifactPath,
source: "vitest",
},
],
},
test: {
...evidence.entries[0].test,
id: `${repoRoot}/qa-lab.absolute-artifact-path`,
kind: `${repoRoot}/vitest-test`,
source: { path: path.join(repoRoot, "extensions/qa-lab/src/absolute.test.ts") },
title: `Absolute artifact path at ${repoRoot}`,
},
};
await writeJson(path.join(outputDir, QA_EVIDENCE_FILENAME), evidence);
const model = await buildQaEvidenceGalleryModel({
evidencePath: outputDir,
repoRoot,
});
const artifact = model.entries[0]?.artifacts[0];
expect(artifact).toMatchObject({
exists: true,
kind: "<repo-root>/log",
path: ".artifacts/qa-e2e/vitest/absolute.log",
preview: "absolute artifact <repo-root>\nfile://<repo-root>/trace.log\n",
source: "<repo-root>/vitest",
});
expect(artifact?.href).toContain("entryIndex=0&artifactIndex=0");
const relativeArtifact = model.entries[0]?.artifacts[1];
expect(relativeArtifact).toMatchObject({
exists: true,
path: expect.stringContaining(".artifacts/qa-e2e/vitest/nested"),
preview: "relative artifact\n",
});
expect(decodeURIComponent(relativeArtifact?.href ?? "")).not.toContain(repoRoot);
expect(relativeArtifact?.href).toContain("entryIndex=0&artifactIndex=1");
expect(model.entries[0]?.sourcePath).toBe("extensions/qa-lab/src/absolute.test.ts");
expect(model.entries[0]).toMatchObject({
coverage: [{ id: "<repo-root>/coverage", role: "<repo-root>/role" }],
id: "<repo-root>/qa-lab.absolute-artifact-path",
kind: "<repo-root>/vitest-test",
title: "Absolute artifact path at <repo-root>",
});
expect(model.profile).toBe("<repo-root>/qa-profile");
expect(JSON.stringify(model)).not.toContain(repoRoot);
await expect(
resolveQaEvidenceArtifactFile({
artifactPath: "<repo-root>/.artifacts/qa-e2e/vitest/absolute.log",
evidencePath: outputDir,
repoRoot,
}),
).resolves.toBe(await fs.realpath(artifactPath));
await expect(
resolveQaEvidenceArtifactFileByIndex({
artifactIndex: 1,
entryIndex: 0,
evidencePath: outputDir,
repoRoot,
}),
).resolves.toBe(await fs.realpath(relativeLeakFile));
});
it("detects UX Matrix producer context from suite-level evidence artifacts", async () => {
const repoRoot = await createTempRepo();
const suiteDir = path.join(repoRoot, ".artifacts", "qa-e2e", "suite");
const runDir = path.join(
suiteDir,
"script",
...producerRootLeakSegments(repoRoot),
"ux-matrix-evidence-dashboard",
"run-1",
);
const expectedWebScreenshotNeedle =
process.platform === "win32"
? ".artifacts/qa-e2e/suite/script/nested"
: ".artifacts/qa-e2e/suite/script/nested<repo-root>/ux-matrix-evidence-dashboard/run-1/surfaces/web-ui/stages/first-run/screenshot.png";
const expectedCliLogNeedle =
process.platform === "win32"
? ".artifacts/qa-e2e/suite/script/nested"
: ".artifacts/qa-e2e/suite/script/nested<repo-root>/ux-matrix-evidence-dashboard/run-1/surfaces/cli/stages/error-state/logs.txt";
const runDir = path.join(suiteDir, "script", "ux-matrix-evidence-dashboard", "run-1");
await fs.mkdir(path.join(runDir, "surfaces", "web-ui", "stages", "first-run"), {
recursive: true,
});
@@ -337,24 +208,22 @@ describe("evidence gallery", () => {
"proof-gap": 1,
},
stages: [
{ id: `${repoRoot}/diagnostics`, label: "Diagnostics" },
{ id: "first-run", label: "First run" },
{ id: "error-state", label: "Error state" },
],
surfaces: [
{ id: `${repoRoot}/native`, label: "Native" },
{ id: "web-ui", label: "Web UI" },
{ id: "cli", label: "CLI" },
],
cells: [
null,
{
coverageIds: [`${repoRoot}/ui.control`],
coverageIds: ["ui.control"],
runner: {
availability: "local",
command: `${repoRoot}/openclaw.mjs qa suite --scenario ux-matrix-evidence-dashboard`,
command: "pnpm openclaw qa suite --scenario ux-matrix-evidence-dashboard",
lane: "web-ui-playwright",
workflow: `${repoRoot}/.github/workflows/ux-matrix-qa.yml#ux-matrix-local`,
workflow: ".github/workflows/ux-matrix-qa.yml#ux-matrix-local",
},
stage: "first-run",
status: "pass",
@@ -402,7 +271,7 @@ describe("evidence gallery", () => {
test: {
kind: "ux-matrix-cell",
id: "ux-matrix.web-ui.first-run",
title: `UX Matrix: web-ui / first-run at ${repoRoot}`,
title: "UX Matrix: web-ui / first-run",
source: { path: "scripts/ux-matrix/dashboard.ts" },
},
coverage: [{ id: "ui.control", role: "primary" }],
@@ -423,14 +292,7 @@ describe("evidence gallery", () => {
artifacts: [
{
kind: "screenshot",
path: path.join(
runDir,
"surfaces",
"web-ui",
"stages",
"first-run",
"screenshot.png",
),
path: ".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/surfaces/web-ui/stages/first-run/screenshot.png",
source: "ux-matrix:web-ui:first-run",
},
],
@@ -462,10 +324,7 @@ describe("evidence gallery", () => {
artifacts: [
{
kind: "log",
path: repoRelativePath(
repoRoot,
path.join(runDir, "surfaces", "cli", "stages", "error-state", "logs.txt"),
),
path: ".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/surfaces/cli/stages/error-state/logs.txt",
source: "ux-matrix:cli:error-state",
},
],
@@ -499,8 +358,8 @@ describe("evidence gallery", () => {
blocked: 1,
"proof-gap": 1,
},
stages: ["<repo-root>/diagnostics", "first-run", "error-state"],
surfaces: ["<repo-root>/native", "web-ui", "cli"],
stages: ["first-run", "error-state"],
surfaces: ["web-ui", "cli"],
},
releaseLedger: {
counts: {
@@ -513,19 +372,21 @@ describe("evidence gallery", () => {
expect(model.producerContext?.matrix?.cells).toEqual([
{
artifactKinds: ["screenshot"],
artifactPaths: [expect.stringContaining(expectedWebScreenshotNeedle)],
coverageIds: ["<repo-root>/ui.control"],
artifactPaths: [
".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/surfaces/web-ui/stages/first-run/screenshot.png",
],
coverageIds: ["ui.control"],
runner: {
availability: "local",
command: "<repo-root>/openclaw.mjs qa suite --scenario ux-matrix-evidence-dashboard",
command: "pnpm openclaw qa suite --scenario ux-matrix-evidence-dashboard",
lane: "web-ui-playwright",
workflow: "<repo-root>/.github/workflows/ux-matrix-qa.yml#ux-matrix-local",
workflow: ".github/workflows/ux-matrix-qa.yml#ux-matrix-local",
},
stage: "first-run",
status: "pass",
surface: "web-ui",
testId: "ux-matrix.web-ui.first-run",
title: "UX Matrix: web-ui / first-run at <repo-root>",
title: "UX Matrix: web-ui / first-run",
},
{
artifactKinds: [],
@@ -545,7 +406,9 @@ describe("evidence gallery", () => {
},
{
artifactKinds: ["log"],
artifactPaths: [expect.stringContaining(expectedCliLogNeedle)],
artifactPaths: [
".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/surfaces/cli/stages/error-state/logs.txt",
],
coverageIds: [],
runner: null,
stage: "error-state",
@@ -557,12 +420,9 @@ describe("evidence gallery", () => {
]);
expect(model.producerContext?.scorecard?.preview).toContain("# UX Matrix");
expect(model.producerContext?.scorecard?.href).toContain("/api/evidence/artifact?");
expect(decodeURIComponent(model.producerContext?.scorecard?.href ?? "")).not.toContain(
repoRoot,
);
expect(model.producerContext?.scorecard?.href).not.toContain(repoRoot);
expect(model.producerContext?.commands?.preview).toBe("node ux matrix\n");
expect(model.producerContext?.commands?.path).toContain("commands.txt");
expect(decodeURIComponent(model.producerContext?.commands?.href ?? "")).not.toContain(repoRoot);
expect(model.producerContext?.manifest?.preview).toContain('"runId": "run-1"');
expect(model.producerContext?.releaseLedger?.preview).toContain('"proof-gap": 1');
expect(model.producerContext?.preflight.memory?.path).toContain("preflight/memory.txt");
@@ -572,14 +432,6 @@ describe("evidence gallery", () => {
);
expect(model.producerContext?.preflight.adbDevices?.preview).toBe("List of devices\n");
expect(model.evidencePath).toBe(".artifacts/qa-e2e/suite/qa-evidence.json");
expect(JSON.stringify(model)).not.toContain(repoRoot);
await expect(
resolveQaEvidenceProducerFile({
evidencePath: suiteDir,
producerFile: "scorecard",
repoRoot,
}),
).resolves.toBe(await fs.realpath(path.join(runDir, "scorecard.md")));
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "qa-evidence-outside-"));
const outsideCommands = path.join(outsideDir, "commands.txt");
await fs.writeFile(outsideCommands, "outside secret\n", "utf8");
@@ -593,7 +445,8 @@ describe("evidence gallery", () => {
expect(JSON.stringify(symlinkModel)).not.toContain("outside secret");
await expect(
resolveQaEvidenceArtifactFile({
artifactPath: path.relative(repoRoot, path.join(runDir, "scorecard.md")),
artifactPath:
".artifacts/qa-e2e/suite/script/ux-matrix-evidence-dashboard/run-1/scorecard.md",
evidencePath: suiteDir,
repoRoot,
}),

View File

@@ -12,7 +12,7 @@ import type {
QaEvidenceProducerContext,
QaEvidenceProducerContextFile,
} from "../shared/evidence-gallery-types.js";
import { toRepoPath, toRepoRelativePath } from "./cli-paths.js";
import { toRepoRelativePath } from "./cli-paths.js";
import {
QA_EVIDENCE_FILENAME,
validateQaEvidenceSummaryJson,
@@ -31,7 +31,6 @@ export type {
const TEXT_PREVIEW_BYTES = 12 * 1024;
const ARTIFACT_VIEW_CONCURRENCY = 8;
const REPO_ROOT_ARTIFACT_PATH_PREFIX = "<repo-root>/";
const UX_MATRIX_PRODUCER_FILES = [
{ key: "commands", path: "commands.txt", previewKind: "text" },
@@ -43,7 +42,6 @@ const UX_MATRIX_PRODUCER_FILES = [
{ key: "adbDevices", path: path.join("preflight", "adb-devices.txt"), previewKind: "text" },
] as const;
type UxMatrixProducerFileKey = (typeof UX_MATRIX_PRODUCER_FILES)[number]["key"];
type QaEvidenceArtifact = NonNullable<QaEvidenceSummaryEntry["execution"]>["artifacts"][number];
export class QaEvidenceGalleryError extends Error {
@@ -86,49 +84,6 @@ function sanitizeGalleryText(
.reduce((text, entry) => text.replaceAll(entry.from, entry.to), value);
}
function displayGalleryPath(
value: string,
params: {
extraRoots?: readonly string[];
repoRoot: string;
},
) {
if (path.isAbsolute(value)) {
const absolute = path.resolve(value);
for (const root of [params.repoRoot, ...(params.extraRoots ?? [])]) {
const resolvedRoot = path.resolve(root);
if (isInside(resolvedRoot, absolute)) {
return sanitizeGalleryText(toRepoPath(path.relative(resolvedRoot, absolute)), params);
}
}
}
return sanitizeGalleryText(value, params);
}
function sanitizeGalleryPreview(
value: string | null,
params: {
extraRoots?: readonly string[];
repoRoot: string;
},
) {
return value === null ? null : sanitizeGalleryText(value, params);
}
function sanitizeGalleryStringArray(
values: Iterable<unknown>,
params: {
extraRoots?: readonly string[];
repoRoot: string;
},
) {
return readOrderedStringArray(
Array.from(values)
.filter((value): value is string => typeof value === "string")
.map((value) => sanitizeGalleryText(value, params)),
);
}
async function realpathIfExists(filePath: string): Promise<string | null> {
return fs.realpath(filePath).catch(() => null);
}
@@ -211,86 +166,11 @@ export async function resolveQaEvidenceArtifactFile(params: {
throw evidenceError("Evidence artifact is not declared by this evidence summary.", 403);
}
export async function resolveQaEvidenceArtifactFileByIndex(params: {
artifactIndex: number;
entryIndex: number;
evidencePath: string;
repoRoot: string;
}): Promise<string> {
const repoRoot = await fs.realpath(path.resolve(params.repoRoot));
const evidencePath = await resolveQaEvidenceFile({ inputPath: params.evidencePath, repoRoot });
if (
!Number.isSafeInteger(params.entryIndex) ||
params.entryIndex < 0 ||
!Number.isSafeInteger(params.artifactIndex) ||
params.artifactIndex < 0
) {
throw evidenceError("Evidence artifact index is invalid.", 400);
}
const summary = validateQaEvidenceSummaryJson(
JSON.parse(await fs.readFile(evidencePath, "utf8")) as unknown,
);
const artifact = summary.entries[params.entryIndex]?.execution?.artifacts[params.artifactIndex];
if (!artifact) {
throw evidenceError("Evidence artifact not found.", 404);
}
const artifactFile = await resolveArtifactFileWithinRoots({
artifactPath: artifact.path,
evidenceDir: path.dirname(evidencePath),
repoRoot,
});
if (!artifactFile) {
throw evidenceError("Evidence artifact not found.", 404);
}
return artifactFile;
}
export async function resolveQaEvidenceProducerFile(params: {
evidencePath: string;
producerFile: string;
repoRoot: string;
}): Promise<string> {
const repoRoot = await fs.realpath(path.resolve(params.repoRoot));
const evidencePath = await resolveQaEvidenceFile({ inputPath: params.evidencePath, repoRoot });
const producerFile = UX_MATRIX_PRODUCER_FILES.find((file) => file.key === params.producerFile);
if (!producerFile) {
throw evidenceError("Evidence producer file is unknown.", 400);
}
const summary = validateQaEvidenceSummaryJson(
JSON.parse(await fs.readFile(evidencePath, "utf8")) as unknown,
);
const producerRoot = await findUxMatrixProducerRoot({
evidencePath,
repoRoot,
summaryEntries: summary.entries,
});
if (!producerRoot) {
throw evidenceError("Evidence producer context not found.", 404);
}
const evidenceDir = path.dirname(evidencePath);
const producerPath = path.join(producerRoot, producerFile.path);
const realProducerFile = await resolveContainedFileIfExists(producerPath, [
repoRoot,
evidenceDir,
]);
if (!realProducerFile) {
throw evidenceError("Evidence producer file not found.", 404);
}
return realProducerFile;
}
function isExplicitRepoRootArtifactPath(raw: string): boolean {
const normalized = raw.split(/[\\/]+/u).join("/");
return normalized.startsWith(".artifacts/");
}
function repoRootTokenArtifactPath(raw: string): string | null {
const normalized = raw.split(/[\\/]+/u).join("/");
return normalized.startsWith(REPO_ROOT_ARTIFACT_PATH_PREFIX)
? normalized.slice(REPO_ROOT_ARTIFACT_PATH_PREFIX.length)
: null;
}
// Resolve an artifact path against pre-resolved roots without re-reading the evidence file.
// Returns null when the path is missing or escapes both roots; callers map that to an error.
async function resolveArtifactFileWithinRoots(params: {
@@ -302,13 +182,8 @@ async function resolveArtifactFileWithinRoots(params: {
if (!raw) {
return null;
}
const tokenPath = repoRootTokenArtifactPath(raw);
const candidates = tokenPath
? [path.resolve(params.repoRoot, tokenPath)]
: path.isAbsolute(raw)
? [raw]
: [path.resolve(params.evidenceDir, raw)];
if (!tokenPath && !path.isAbsolute(raw) && isExplicitRepoRootArtifactPath(raw)) {
const candidates = path.isAbsolute(raw) ? [raw] : [path.resolve(params.evidenceDir, raw)];
if (!path.isAbsolute(raw) && isExplicitRepoRootArtifactPath(raw)) {
candidates.push(path.resolve(params.repoRoot, raw));
}
for (const candidate of candidates) {
@@ -438,66 +313,38 @@ async function readJsonIfExists(
}
}
function artifactHref(
evidencePath: string,
artifact:
| {
artifactPath: string;
}
| {
artifactIndex: number;
entryIndex: number;
}
| {
producerFile: UxMatrixProducerFileKey;
},
) {
const params = new URLSearchParams({ evidencePath });
if ("artifactPath" in artifact) {
params.set("artifactPath", artifact.artifactPath);
} else if ("producerFile" in artifact) {
params.set("producerFile", artifact.producerFile);
} else {
params.set("entryIndex", String(artifact.entryIndex));
params.set("artifactIndex", String(artifact.artifactIndex));
}
function artifactHref(evidencePath: string, artifactPath: string) {
const params = new URLSearchParams({
evidencePath,
artifactPath,
});
return `/api/evidence/artifact?${params.toString()}`;
}
async function buildProducerContextFile(params: {
allowedRoots: readonly string[];
extraRoots: readonly string[];
artifactPath: string;
filePath: string;
hrefEvidencePath: string;
previewKind: "json" | "text";
producerFile: UxMatrixProducerFileKey;
repoRoot: string;
}): Promise<QaEvidenceProducerContextFile | null> {
const realFile = await resolveContainedFileIfExists(params.filePath, params.allowedRoots);
if (!realFile) {
return null;
}
const repoPath = toRepoRelativePath(params.repoRoot, params.filePath);
return {
href: artifactHref(params.hrefEvidencePath, { producerFile: params.producerFile }),
path: displayGalleryPath(params.filePath, params),
preview: await readPreview(realFile, params.previewKind)
.then((preview) =>
sanitizeGalleryPreview(preview, {
extraRoots: params.extraRoots,
repoRoot: params.repoRoot,
}),
)
.catch(() => null),
href: artifactHref(params.hrefEvidencePath, params.artifactPath),
path: repoPath,
preview: await readPreview(realFile, params.previewKind).catch(() => null),
};
}
async function buildArtifactView(params: {
allowedArtifactFiles: ReadonlySet<string>;
artifactIndex: number;
artifact: QaEvidenceArtifact;
evidenceDir: string;
entryIndex: number;
extraRoots: readonly string[];
hrefEvidencePath: string;
repoRoot: string;
}): Promise<QaEvidenceArtifactView> {
@@ -507,16 +354,6 @@ async function buildArtifactView(params: {
evidenceDir: params.evidenceDir,
repoRoot: params.repoRoot,
}).catch(() => null);
const realFileRepoPath =
realFile && isInside(params.repoRoot, realFile)
? toRepoRelativePath(params.repoRoot, realFile)
: null;
const displayPath =
(realFileRepoPath ? sanitizeGalleryText(realFileRepoPath, params) : null) ??
sanitizeGalleryText(params.artifact.path, {
extraRoots: params.extraRoots,
repoRoot: params.repoRoot,
});
if (!realFile || !params.allowedArtifactFiles.has(realFile)) {
return {
exists: false,
@@ -524,37 +361,24 @@ async function buildArtifactView(params: {
? "Evidence artifact is not declared by this evidence summary."
: "Evidence artifact not found.",
href: null,
kind: sanitizeGalleryText(params.artifact.kind, params),
kind: params.artifact.kind,
mediaKind,
path: displayPath,
path: params.artifact.path,
preview: null,
source: sanitizeGalleryText(params.artifact.source, params),
source: params.artifact.source,
};
}
return {
exists: true,
error: null,
href: artifactHref(params.hrefEvidencePath, {
artifactIndex: params.artifactIndex,
entryIndex: params.entryIndex,
}),
kind: sanitizeGalleryText(params.artifact.kind, params),
href: artifactHref(params.hrefEvidencePath, params.artifact.path),
kind: params.artifact.kind,
mediaKind,
path: displayPath,
preview: await readPreview(realFile, mediaKind)
.then((preview) =>
sanitizeGalleryPreview(preview, {
extraRoots: params.extraRoots,
repoRoot: params.repoRoot,
}),
)
.catch((error: unknown) =>
sanitizeGalleryText(`Preview unavailable: ${formatErrorMessage(error)}`, {
extraRoots: params.extraRoots,
repoRoot: params.repoRoot,
}),
),
source: sanitizeGalleryText(params.artifact.source, params),
path: params.artifact.path,
preview: await readPreview(realFile, mediaKind).catch(
(error: unknown) => `Preview unavailable: ${formatErrorMessage(error)}`,
),
source: params.artifact.source,
};
}
@@ -590,26 +414,19 @@ function readStringArray(values: Iterable<unknown>) {
return readOrderedStringArray(values).toSorted();
}
function readMatrixDimensionIds(params: {
extraRoots: readonly string[];
fallback: readonly string[];
repoRoot: string;
value: unknown;
}): string[] {
if (!Array.isArray(params.value)) {
return sanitizeGalleryStringArray(params.fallback, params);
function readMatrixDimensionIds(value: unknown, fallback: readonly string[]): string[] {
if (!Array.isArray(value)) {
return readOrderedStringArray(fallback);
}
const ids = sanitizeGalleryStringArray(
params.value.map((entry) => {
const ids = readOrderedStringArray(
value.map((entry) => {
if (typeof entry === "string") {
return entry;
}
return readString(readRecord(entry)?.id);
}),
params,
);
for (const rawFallbackId of params.fallback) {
const fallbackId = sanitizeGalleryText(rawFallbackId, params);
for (const fallbackId of fallback) {
if (!ids.includes(fallbackId)) {
ids.push(fallbackId);
}
@@ -645,9 +462,7 @@ function buildUxMatrixEvidenceEntryIndex(entries: readonly QaEvidenceSummaryEntr
}
function readMatrixCells(params: {
extraRoots: readonly string[];
matrix: Record<string, unknown> | null;
repoRoot: string;
summaryEntries: readonly QaEvidenceSummaryEntry[];
}): QaEvidenceMatrixCellView[] {
const rawCells = Array.isArray(params.matrix?.cells)
@@ -657,54 +472,34 @@ function readMatrixCells(params: {
: [];
const entriesByCell = buildUxMatrixEvidenceEntryIndex(params.summaryEntries);
return rawCells.flatMap((cell): QaEvidenceMatrixCellView[] => {
const rawSurface = readString(cell.surface);
const rawStage = readString(cell.stage);
const rawStatus = readString(cell.status) ?? "proof-gap";
if (!rawSurface || !rawStage) {
const surface = readString(cell.surface);
const stage = readString(cell.stage);
const status = readString(cell.status) ?? "proof-gap";
if (!surface || !stage) {
return [];
}
const entry =
rawStatus === "proof-gap" ? null : (entriesByCell.get(`${rawSurface}:${rawStage}`) ?? null);
status === "proof-gap" ? null : (entriesByCell.get(`${surface}:${stage}`) ?? null);
const artifacts = entry?.execution?.artifacts ?? [];
const runner = readRecord(cell.runner);
const sanitizeCellString = (value: string) =>
sanitizeGalleryText(value, {
extraRoots: params.extraRoots,
repoRoot: params.repoRoot,
});
const readRunnerString = (value: unknown) => {
const text = readString(value);
return text ? sanitizeCellString(text) : null;
};
return [
{
artifactKinds: readStringArray(
artifacts.map((artifact) => sanitizeCellString(artifact.kind)),
),
artifactPaths: artifacts.map((artifact) =>
displayGalleryPath(artifact.path, {
extraRoots: params.extraRoots,
repoRoot: params.repoRoot,
}),
),
coverageIds: readStringArray(
(Array.isArray(cell.coverageIds) ? cell.coverageIds : []).map((coverageId) =>
typeof coverageId === "string" ? sanitizeCellString(coverageId) : coverageId,
),
),
artifactKinds: readStringArray(artifacts.map((artifact) => artifact.kind)),
artifactPaths: artifacts.map((artifact) => artifact.path),
coverageIds: readStringArray(Array.isArray(cell.coverageIds) ? cell.coverageIds : []),
runner: runner
? {
availability: readRunnerString(runner.availability),
command: readRunnerString(runner.command),
lane: readRunnerString(runner.lane),
workflow: readRunnerString(runner.workflow),
availability: readString(runner.availability),
command: readString(runner.command),
lane: readString(runner.lane),
workflow: readString(runner.workflow),
}
: null,
stage: sanitizeCellString(rawStage),
status: sanitizeCellString(rawStatus),
surface: sanitizeCellString(rawSurface),
testId: entry?.test.id ? sanitizeCellString(entry.test.id) : null,
title: entry?.test.title ? sanitizeCellString(entry.test.title) : null,
stage,
status,
surface,
testId: entry?.test.id ?? null,
title: entry?.test.title ?? null,
},
];
});
@@ -761,7 +556,6 @@ async function findUxMatrixProducerRoot(params: {
async function buildProducerContext(params: {
evidencePath: string;
extraRoots: readonly string[];
hrefEvidencePath: string;
repoRoot: string;
summaryEntries: readonly QaEvidenceSummaryEntry[];
@@ -784,20 +578,16 @@ async function buildProducerContext(params: {
const manifest = await readJsonIfExists(manifestPath, allowedRoots);
const matrix = await readJsonIfExists(matrixPath, allowedRoots);
const releaseLedger = await readJsonIfExists(releaseLedgerPath, allowedRoots);
const run = readRecord(manifest?.run);
const runId = readString(run?.runId);
const runStatus = readString(run?.status);
const producerFiles = Object.fromEntries(
await Promise.all(
UX_MATRIX_PRODUCER_FILES.map(async (file) => [
file.key,
await buildProducerContextFile({
allowedRoots,
extraRoots: params.extraRoots,
artifactPath: toRepoRelativePath(repoRoot, producerPaths[file.key]),
filePath: producerPaths[file.key],
hrefEvidencePath: params.hrefEvidencePath,
previewKind: file.previewKind,
producerFile: file.key,
repoRoot,
}),
]),
@@ -807,9 +597,7 @@ async function buildProducerContext(params: {
QaEvidenceProducerContextFile | null
>;
const matrixCells = readMatrixCells({
extraRoots: params.extraRoots,
matrix,
repoRoot,
summaryEntries: params.summaryEntries,
});
return {
@@ -819,27 +607,23 @@ async function buildProducerContext(params: {
manifest && producerFiles.manifest
? {
...producerFiles.manifest,
runId: runId ? sanitizeGalleryText(runId, params) : null,
runStatus: runStatus ? sanitizeGalleryText(runStatus, params) : null,
runId: readString(readRecord(manifest.run)?.runId),
runStatus: readString(readRecord(manifest.run)?.status),
}
: null,
matrix: matrix
? {
cells: matrixCells,
counts: readCountRecord(matrix.counts),
path: displayGalleryPath(matrixPath, { extraRoots: params.extraRoots, repoRoot }),
stages: readMatrixDimensionIds({
extraRoots: params.extraRoots,
fallback: matrixCells.map((cell) => cell.stage),
repoRoot,
value: matrix.stages,
}),
surfaces: readMatrixDimensionIds({
extraRoots: params.extraRoots,
fallback: matrixCells.map((cell) => cell.surface),
repoRoot,
value: matrix.surfaces,
}),
path: toRepoRelativePath(repoRoot, matrixPath),
stages: readMatrixDimensionIds(
matrix.stages,
matrixCells.map((cell) => cell.stage),
),
surfaces: readMatrixDimensionIds(
matrix.surfaces,
matrixCells.map((cell) => cell.surface),
),
}
: null,
preflight: {
@@ -853,7 +637,7 @@ async function buildProducerContext(params: {
counts: readCountRecord(releaseLedger.counts),
}
: null,
rootPath: displayGalleryPath(rootPath, { extraRoots: params.extraRoots, repoRoot }),
rootPath: toRepoRelativePath(repoRoot, rootPath),
scorecard: producerFiles.scorecard,
};
}
@@ -907,47 +691,34 @@ export async function buildQaEvidenceGalleryModel(params: {
});
const limitArtifactView = createConcurrencyLimit(ARTIFACT_VIEW_CONCURRENCY);
const entries = await Promise.all(
summary.entries.map(async (entry, entryIndex): Promise<QaEvidenceGalleryEntryView> => {
summary.entries.map(async (entry): Promise<QaEvidenceGalleryEntryView> => {
counts[entry.result.status] += 1;
const sanitizeEntryText = (value: string) =>
sanitizeGalleryText(value, {
extraRoots: [requestedRepoRoot],
repoRoot,
});
return {
artifacts: await Promise.all(
(entry.execution?.artifacts ?? []).map((artifact, artifactIndex) =>
(entry.execution?.artifacts ?? []).map((artifact) =>
limitArtifactView(() =>
buildArtifactView({
allowedArtifactFiles,
artifact,
artifactIndex,
evidenceDir,
entryIndex,
extraRoots: [requestedRepoRoot],
hrefEvidencePath,
repoRoot,
}),
),
),
),
coverage: entry.coverage.map((coverage) => ({
id: sanitizeEntryText(coverage.id),
role: sanitizeEntryText(coverage.role),
})),
coverage: entry.coverage,
failureReason: entry.result.failure?.reason
? sanitizeEntryText(entry.result.failure.reason)
: null,
id: sanitizeEntryText(entry.test.id),
kind: sanitizeEntryText(entry.test.kind),
sourcePath: entry.test.source?.path
? displayGalleryPath(entry.test.source.path, {
? sanitizeGalleryText(entry.result.failure.reason, {
extraRoots: [requestedRepoRoot],
repoRoot,
})
: null,
id: entry.test.id,
kind: entry.test.kind,
sourcePath: entry.test.source?.path ?? null,
status: entry.result.status,
title: sanitizeEntryText(entry.test.title),
title: entry.test.title,
};
}),
);
@@ -957,12 +728,9 @@ export async function buildQaEvidenceGalleryModel(params: {
evidenceMode: summary.evidenceMode,
evidencePath: hrefEvidencePath,
generatedAt: summary.generatedAt,
profile: summary.profile
? sanitizeGalleryText(summary.profile, { extraRoots: [requestedRepoRoot], repoRoot })
: null,
profile: summary.profile ?? null,
producerContext: await buildProducerContext({
evidencePath,
extraRoots: [requestedRepoRoot],
hrefEvidencePath,
repoRoot,
summaryEntries: summary.entries,

View File

@@ -1087,27 +1087,14 @@ describe("buildQaRuntimeEnv", () => {
"OPENCLAW_QA_CONVEX_SECRET_MAINTAINER=convex-maintainer-secret",
"OPENCLAW_LIVE_CODEX_API_KEY=codex-live-secret",
"botToken=12345:AbCdEfGhIjKl",
"--botToken=12345:flag-secret",
'"driverToken":"12345:driver-secr3t"',
"sutToken='12345:sut-secr3t'",
"leaseToken=lease-12345",
'"apiKey":"secret-json-api-key"',
"clientSecret=secret-client-secret&secret-tail",
"url=http://127.0.0.1:18789/#token=abc123",
"callback=https://gateway.example.test/callback?access_token=secret-access-token&ok=1",
].join("\n"),
"utf8",
);
await writeFile(
stderrLogPath,
[
"Authorization: Bearer secret+/token=123456",
"Cookie: qa_session=secret-cookie; theme=dark",
"Set-Cookie: qa_session=secret-cookie; HttpOnly",
"x-api-key: secret-header-api-key",
].join("\n"),
"utf8",
);
await writeFile(stderrLogPath, "Authorization: Bearer secret+/token=123456", "utf8");
await mkdir(path.join(tempRoot, "state"), { recursive: true });
await writeFile(path.join(tempRoot, "state", "secret.txt"), "do-not-copy", "utf8");
@@ -1132,23 +1119,14 @@ describe("buildQaRuntimeEnv", () => {
"OPENCLAW_QA_CONVEX_SECRET_MAINTAINER=<redacted>",
"OPENCLAW_LIVE_CODEX_API_KEY=<redacted>",
"botToken=<redacted>",
"--botToken=<redacted>",
'"driverToken":"<redacted>"',
"sutToken=<redacted>",
"leaseToken=<redacted>",
'"apiKey":"<redacted>"',
"clientSecret=<redacted>",
"url=http://127.0.0.1:18789/#token=<redacted>",
"callback=https://gateway.example.test/callback?access_token=<redacted>&ok=1",
].join("\n"),
);
await expect(readFile(path.join(artifactDir, "gateway.stderr.log"), "utf8")).resolves.toBe(
[
"Authorization: Bearer <redacted>",
"Cookie: <redacted>",
"Set-Cookie: <redacted>",
"x-api-key: <redacted>",
].join("\n"),
"Authorization: Bearer <redacted>",
);
await expect(readFile(path.join(artifactDir, "README.txt"), "utf8")).resolves.toContain(
"was not copied because it may contain credentials or auth tokens",

View File

@@ -25,7 +25,7 @@ import {
resolveQaRuntimeHostVersion,
} from "./bundled-plugin-staging.js";
import { assertRepoBoundPath, ensureRepoBoundDirectory } from "./cli-paths.js";
import { QaSuiteInfraError, toQaErrorObject } from "./errors.js";
import { QaSuiteInfraError } from "./errors.js";
import { formatQaGatewayLogsForError, redactQaGatewayDebugText } from "./gateway-log-redaction.js";
import { startQaGatewayRpcClient } from "./gateway-rpc-client.js";
import { splitQaModelRef, type QaProviderMode } from "./model-selection.js";
@@ -810,7 +810,7 @@ export async function startQaGatewayChild(params: {
}
}
if (!rpcReady) {
throw toQaErrorObject(
throw toLintErrorObject(
lastRpcStartupError ?? new Error("qa gateway rpc client failed to start"),
"Non-Error thrown",
);
@@ -913,7 +913,7 @@ export async function startQaGatewayChild(params: {
}
}
if (!rpcReady) {
throw toQaErrorObject(
throw toLintErrorObject(
lastRpcStartupError ?? new Error("qa gateway rpc client failed to start"),
"Non-Error thrown",
);
@@ -1067,3 +1067,17 @@ export async function startQaGatewayChild(params: {
}
}
export { testing as __testing };
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -10,35 +10,11 @@ const QA_GATEWAY_DEBUG_SECRET_ENV_VARS = Object.freeze([
"OPENCLAW_GATEWAY_TOKEN",
]);
const QA_GATEWAY_DEBUG_SECRET_VALUE_KEYS = Object.freeze([
"accessToken",
"access_token",
"apiKey",
"api_key",
"botToken",
"clientSecret",
"client_secret",
"cookie",
"driverToken",
"sutToken",
"leaseToken",
"refreshToken",
"refresh_token",
"set-cookie",
"x-api-key",
]);
const QA_GATEWAY_DEBUG_SECRET_QUERY_KEYS = Object.freeze([
"access_token",
"api_key",
"apiKey",
"auth",
"deviceToken",
"id_token",
"key",
"password",
"refresh_token",
"token",
]);
const QA_GATEWAY_DEBUG_SECRET_HEADER_KEYS = Object.freeze(["cookie", "set-cookie", "x-api-key"]);
function redactSecretEnvKeyPattern(text: string, pattern: RegExp) {
const source = pattern.source.replace(/^\^/u, "").replace(/\$$/u, "");
@@ -50,30 +26,8 @@ function redactSecretEnvKeyPattern(text: string, pattern: RegExp) {
.replace(new RegExp(`"(${source})"\\s*:\\s*"[^"]*"`, "g"), `"$1":"<redacted>"`);
}
function redactSecretValueKey(text: string, key: string) {
const escapedKey = escapeRegExp(key);
return text
.replace(new RegExp(`([?#&]${escapedKey}=)[^&\\s]+`, "gi"), "$1<redacted>")
.replace(
new RegExp(`(^|\\s)(--${escapedKey})(\\s*[=:]\\s*)([^\\s"';,]+|"[^"]*"|'[^']*')`, "gi"),
`$1$2$3<redacted>`,
)
.replace(
new RegExp(`(^|[^\\w?#&-])(${escapedKey})(\\s*[=:]\\s*)([^\\s"';,]+|"[^"]*"|'[^']*')`, "gi"),
`$1$2$3<redacted>`,
)
.replace(new RegExp(`("${escapedKey}"\\s*:\\s*)"[^"]*"`, "gi"), `$1"<redacted>"`);
}
export function redactQaGatewayDebugText(text: string) {
let redacted = text;
for (const key of QA_GATEWAY_DEBUG_SECRET_HEADER_KEYS) {
const escapedKey = escapeRegExp(key);
redacted = redacted.replace(
new RegExp(`^(\\s*${escapedKey}\\s*:\\s*).+$`, "gim"),
"$1<redacted>",
);
}
for (const envVar of QA_GATEWAY_DEBUG_SECRET_ENV_VARS) {
const escapedEnvVar = escapeRegExp(envVar);
redacted = redacted.replace(
@@ -89,18 +43,20 @@ export function redactQaGatewayDebugText(text: string) {
redacted = redactSecretEnvKeyPattern(redacted, pattern);
}
for (const key of QA_GATEWAY_DEBUG_SECRET_VALUE_KEYS) {
redacted = redactSecretValueKey(redacted, key);
const escapedKey = escapeRegExp(key);
redacted = redacted.replace(
new RegExp(`\\b(${escapedKey})(\\s*[=:]\\s*)([^\\s"';,]+|"[^"]*"|'[^']*')`, "gi"),
`$1$2<redacted>`,
);
redacted = redacted.replace(
new RegExp(`("${escapedKey}"\\s*:\\s*)"[^"]*"`, "gi"),
`$1"<redacted>"`,
);
}
return redacted
.replaceAll(/\bsk-ant-oat01-[A-Za-z0-9_-]+\b/g, "<redacted>")
.replaceAll(/\bBearer\s+[^\s"'<>]{8,}/gi, "Bearer <redacted>")
.replaceAll(
new RegExp(
`([?#&](?:${QA_GATEWAY_DEBUG_SECRET_QUERY_KEYS.map(escapeRegExp).join("|")})=)[^&\\s]+`,
"gi",
),
"$1<redacted>",
);
.replaceAll(/([?#&]token=)[^&\s]+/gi, "$1<redacted>");
}
export function formatQaGatewayLogsForError(logs: string) {

View File

@@ -384,8 +384,7 @@ describe("qa-lab server", () => {
port: 0,
outputPath,
repoRoot,
controlUiUrl:
"https://gateway.example.test/?token=qa-token&api_key=qa-api-key&id_token=qa-id-token&panel=chat#token=fragment-token",
controlUiUrl: "http://127.0.0.1:18789/?token=qa-token&panel=chat#token=fragment-token",
embeddedGateway: "disabled",
});
cleanups.push(async () => {
@@ -404,8 +403,8 @@ describe("qa-lab server", () => {
};
expect(bootstrap.defaults.conversationId).toBe("qa-operator");
expect(bootstrap.defaults.senderId).toBe("qa-operator");
expect(bootstrap.controlUiUrl).toBe("https://gateway.example.test/?panel=chat");
expect(bootstrap.controlUiEmbeddedUrl).toBe("https://gateway.example.test/?panel=chat");
expect(bootstrap.controlUiUrl).toBe("http://127.0.0.1:18789/?panel=chat");
expect(bootstrap.controlUiEmbeddedUrl).toBe("http://127.0.0.1:18789/?panel=chat");
expect(bootstrap.kickoffTask).toContain("Lobster Invaders");
expect(bootstrap.scenarios.length).toBeGreaterThanOrEqual(10);
expect(bootstrap.scenarios.map((scenario) => scenario.id)).toContain("dm-chat-baseline");
@@ -423,20 +422,7 @@ describe("qa-lab server", () => {
).json()) as {
status: { gateway: { url: string } };
};
expect(startupStatus.status.gateway.url).toBe("https://gateway.example.test/?panel=chat");
lab.setControlUi({
controlUiUrl:
"/control-ui/?token=late-token&api_key=late-api-key&id_token=late-id-token&panel=chat#token=fragment-token",
});
const relativeBootstrap = (await (
await fetchWithRetry(`${lab.baseUrl}/api/bootstrap`)
).json()) as {
controlUiUrl: string | null;
controlUiEmbeddedUrl: string | null;
};
expect(relativeBootstrap.controlUiUrl).toBe("/control-ui/?panel=chat");
expect(relativeBootstrap.controlUiEmbeddedUrl).toBe("/control-ui/?panel=chat");
expect(startupStatus.status.gateway.url).toBe("http://127.0.0.1:18789/?panel=chat");
const messageResponse = await fetch(`${lab.baseUrl}/api/inbound/message`, {
method: "POST",

View File

@@ -19,9 +19,7 @@ import { createQaBusState, type QaBusState } from "./bus-state.js";
import {
QaEvidenceGalleryError,
buildQaEvidenceGalleryModel,
resolveQaEvidenceArtifactFileByIndex,
resolveQaEvidenceArtifactFile,
resolveQaEvidenceProducerFile,
} from "./evidence-gallery.js";
import { createQaRunnerRuntime } from "./harness-runtime.js";
import {
@@ -139,33 +137,12 @@ function createBootstrapDefaults(autoKickoffTarget?: string): QaLabBootstrapDefa
const CONTROL_UI_CREDENTIAL_QUERY_KEYS = new Set([
"access_token",
"api_key",
"apikey",
"auth",
"devicetoken",
"id_token",
"password",
"refresh_token",
"token",
]);
const CONTROL_UI_CREDENTIAL_QUERY_PATTERN =
/([?&])(?:access_token|api_?key|auth|deviceToken|id_token|password|refresh_token|token)=[^&#\s]*&?/gi;
function stripSensitiveQueryParamsFromText(rawUrl: string): string {
let sanitized = rawUrl;
for (;;) {
const next = sanitized
.replace(CONTROL_UI_CREDENTIAL_QUERY_PATTERN, (match: string, separator: string) =>
match.endsWith("&") ? separator : "",
)
.replace(/[?&]$/, "")
.replace("?&", "?");
if (next === sanitized) {
return next;
}
sanitized = next;
}
}
function stripSensitiveQueryParams(rawUrl: string): string {
try {
@@ -177,7 +154,13 @@ function stripSensitiveQueryParams(rawUrl: string): string {
}
return url.toString();
} catch {
return stripSensitiveQueryParamsFromText(rawUrl);
return rawUrl
.replace(
/([?&])(?:access_token|auth|deviceToken|password|refresh_token|token)=[^&#\s]*&?/gi,
(match: string, separator: string) => (match.endsWith("&") ? separator : ""),
)
.replace(/[?&]$/, "")
.replace("?&", "?");
}
}
@@ -470,34 +453,15 @@ export async function startQaLabServer(
) {
const evidencePath = url.searchParams.get("evidencePath")?.trim();
const artifactPath = url.searchParams.get("artifactPath")?.trim();
const producerFile = url.searchParams.get("producerFile")?.trim();
const entryIndexText = url.searchParams.get("entryIndex")?.trim();
const artifactIndexText = url.searchParams.get("artifactIndex")?.trim();
if (
!evidencePath ||
(!artifactPath && !producerFile && (!entryIndexText || !artifactIndexText))
) {
writeError(res, 400, "Missing evidencePath and artifact selector");
if (!evidencePath || !artifactPath) {
writeError(res, 400, "Missing evidencePath or artifactPath");
return;
}
const artifactFile = artifactPath
? await resolveQaEvidenceArtifactFile({
artifactPath,
evidencePath,
repoRoot,
})
: producerFile
? await resolveQaEvidenceProducerFile({
evidencePath,
producerFile,
repoRoot,
})
: await resolveQaEvidenceArtifactFileByIndex({
artifactIndex: Number(artifactIndexText),
entryIndex: Number(entryIndexText),
evidencePath,
repoRoot,
});
const artifactFile = await resolveQaEvidenceArtifactFile({
artifactPath,
evidencePath,
repoRoot,
});
const artifactStats = await fs.promises.stat(artifactFile);
res.writeHead(200, {
"content-type": detectQaEvidenceArtifactContentType(artifactFile),

View File

@@ -34,7 +34,6 @@ import {
redactQaLiveLaneIssues,
} from "../shared/live-artifacts.js";
import { startQaLiveLaneGateway } from "../shared/live-gateway.runtime.js";
import { assertLiveScenarioReply as assertDiscordScenarioReply } from "../shared/live-scenario-reply.js";
import {
collectLiveTransportStandardScenarioCoverage,
selectLiveTransportScenarios,
@@ -1479,6 +1478,22 @@ function matchesDiscordScenarioReply(params: {
);
}
function assertDiscordScenarioReply(params: {
expectedTextIncludes?: string[];
message: DiscordObservedMessage;
}) {
if (!params.message.text.trim()) {
throw new Error(`reply message ${params.message.messageId} was empty`);
}
for (const expected of params.expectedTextIncludes ?? []) {
if (!params.message.text.includes(expected)) {
throw new Error(
`reply message ${params.message.messageId} missing expected text: ${expected}`,
);
}
}
}
async function assertDiscordApplicationCommandsRegistered(params: {
applicationId: string;
expectedCommandNames: string[];

View File

@@ -1,10 +0,0 @@
export type QaInferredCredentialSource = "convex" | "env";
export function inferQaCredentialSource(
value: string | undefined,
env: NodeJS.ProcessEnv = process.env,
): QaInferredCredentialSource {
const normalized =
value?.trim().toLowerCase() || env.OPENCLAW_QA_CREDENTIAL_SOURCE?.trim().toLowerCase();
return normalized === "convex" ? "convex" : "env";
}

View File

@@ -1,21 +0,0 @@
type LiveScenarioReplyMessage = {
messageId: string | number;
text: string;
[key: string]: unknown;
};
export function assertLiveScenarioReply(params: {
expectedTextIncludes?: string[];
message: LiveScenarioReplyMessage;
}) {
if (!params.message.text.trim()) {
throw new Error(`reply message ${params.message.messageId} was empty`);
}
for (const expected of params.expectedTextIncludes ?? []) {
if (!params.message.text.includes(expected)) {
throw new Error(
`reply message ${params.message.messageId} missing expected text: ${expected}`,
);
}
}
}

View File

@@ -31,7 +31,6 @@ import {
appendQaLiveLaneIssue as appendLiveLaneIssue,
buildQaLiveLaneArtifactsError as buildLiveLaneArtifactsError,
} from "../shared/live-artifacts.js";
import { inferQaCredentialSource as inferSlackCredentialSource } from "../shared/live-credential-source.js";
import { startQaLiveLaneGateway } from "../shared/live-gateway.runtime.js";
import {
collectLiveTransportStandardScenarioCoverage,
@@ -511,6 +510,15 @@ function resolveEnvValue(env: NodeJS.ProcessEnv, key: (typeof SLACK_QA_ENV_KEYS)
return value;
}
function inferSlackCredentialSource(
value: string | undefined,
env: NodeJS.ProcessEnv = process.env,
): "convex" | "env" {
const normalized =
value?.trim().toLowerCase() || env.OPENCLAW_QA_CREDENTIAL_SOURCE?.trim().toLowerCase();
return normalized === "convex" ? "convex" : "env";
}
function normalizeSlackId(value: string, label: string) {
const normalized = value.trim();
if (!/^[A-Z][A-Z0-9]+$/.test(normalized)) {

View File

@@ -39,7 +39,6 @@ import {
redactQaLiveLaneIssues,
} from "../shared/live-artifacts.js";
import { startQaLiveLaneGateway } from "../shared/live-gateway.runtime.js";
import { assertLiveScenarioReply as assertTelegramScenarioReply } from "../shared/live-scenario-reply.js";
import type { LiveTransportCheckResult } from "../shared/live-transport-result.js";
import {
normalizeLiveTransportRttOptions,
@@ -1443,6 +1442,22 @@ function matchesTelegramScenarioReply(params: {
);
}
function assertTelegramScenarioReply(params: {
expectedTextIncludes?: string[];
message: TelegramObservedMessage;
}) {
if (!params.message.text.trim()) {
throw new Error(`reply message ${params.message.messageId} was empty`);
}
for (const expected of params.expectedTextIncludes ?? []) {
if (!params.message.text.includes(expected)) {
throw new Error(
`reply message ${params.message.messageId} missing expected text: ${expected}`,
);
}
}
}
function assertTelegramCanaryPresenceReply(message: TelegramObservedMessage) {
if (!message.senderIsBot) {
throw new Error(`canary reply message ${message.messageId} was not sent by a bot`);

View File

@@ -39,7 +39,6 @@ import {
appendQaLiveLaneIssue as appendLiveLaneIssue,
redactQaLiveLaneDetails,
} from "../shared/live-artifacts.js";
import { inferQaCredentialSource as inferWhatsAppCredentialSource } from "../shared/live-credential-source.js";
import { startQaLiveLaneGateway } from "../shared/live-gateway.runtime.js";
import {
collectLiveTransportStandardScenarioCoverage,
@@ -1402,6 +1401,15 @@ function resolveEnvValue(env: NodeJS.ProcessEnv, key: (typeof WHATSAPP_QA_ENV_KE
return value;
}
function inferWhatsAppCredentialSource(
value: string | undefined,
env: NodeJS.ProcessEnv = process.env,
): "convex" | "env" {
const normalized =
value?.trim().toLowerCase() || env.OPENCLAW_QA_CREDENTIAL_SOURCE?.trim().toLowerCase();
return normalized === "convex" ? "convex" : "env";
}
function resolveWhatsAppMetadataRedaction(env: NodeJS.ProcessEnv = process.env) {
const raw = env[QA_REDACT_PUBLIC_METADATA_ENV];
return raw === undefined ? true : isTruthyOptIn(raw);

View File

@@ -50,19 +50,22 @@ describe("qa scenario catalog", () => {
expect(
scenarioIds.filter((scenarioId) => requiredScenarioIds.includes(scenarioId)).toSorted(),
).toEqual(requiredScenarioIds);
const nativeExecutionScenarios = pack.scenarios.filter(
(scenario) => scenario.execution.kind !== "flow",
expect(
pack.scenarios
.filter((scenario) => scenario.execution?.kind !== "flow")
.map((scenario) => scenario.id)
.toSorted(),
).toStrictEqual(
[
"channel-message-flows",
"control-ui-chat-flow-playwright",
"gateway-smoke",
"package-openclaw-for-docker",
"plugin-lifecycle-probe",
"qa-otel-smoke",
"ux-matrix-evidence-dashboard",
].toSorted(),
);
expect(nativeExecutionScenarios.length).toBeGreaterThan(0);
for (const scenario of nativeExecutionScenarios) {
const execution = scenario.execution;
if (execution.kind === "flow") {
throw new Error(`expected native execution scenario: ${scenario.id}`);
}
expect(["playwright", "script", "vitest"]).toContain(execution.kind);
expect(fs.existsSync(execution.path), `${scenario.id} execution.path exists`).toBe(true);
expect(execution.flow).toBeUndefined();
}
expect(
pack.scenarios
.filter((scenario) => scenario.execution.kind === "flow")
@@ -173,21 +176,6 @@ describe("qa scenario catalog", () => {
expect(uxMatrix.coverage?.primary).toContain("qa.artifact-safety");
});
it("loads folded HTTP API script scenarios with primary taxonomy coverage", () => {
expect(readQaScenarioById("openai-compatible-chat-tools").coverage?.primary).toStrictEqual([
"gateway.openai-compatible-apis",
]);
expect(readQaScenarioById("openai-web-search-minimal").coverage?.primary).toStrictEqual([
"runtime.reasoning-and-cache-controls",
]);
expect(
readQaScenarioById("openai-web-search-native-assertions").coverage?.primary,
).toStrictEqual(["web-search.openai-native-web-search", "plugins.web-search-and-fetch"]);
expect(readQaScenarioById("openwebui-openai-compatible").coverage?.primary).toStrictEqual([
"gateway.openai-compatible-apis",
]);
});
it("loads runtime parity tier metadata for first-hour and soak lanes", () => {
const firstHour = readQaScenarioById("runtime-first-hour-20-turn");
const soak = readQaScenarioById("runtime-soak-100-turn");

View File

@@ -3,7 +3,7 @@ import { setTimeout as sleep } from "node:timers/promises";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { isRecord as isPlainObject } from "openclaw/plugin-sdk/string-coerce-runtime";
import { QaSuiteInfraError, toQaErrorObject } from "./errors.js";
import { QaSuiteInfraError } from "./errors.js";
import { applyQaMergePatch } from "./suite-merge-patch.js";
import { liveTurnTimeoutMs } from "./suite-runtime-agent-common.js";
import type { QaConfigSnapshot, QaSuiteRuntimeEnv } from "./suite-runtime-types.js";
@@ -302,7 +302,7 @@ async function runConfigMutation(params: {
continue;
}
}
throw toQaErrorObject(
throw toLintErrorObject(
lastConflict ?? new Error(`${params.action} failed after retrying config hash conflicts`),
"Non-Error thrown",
);
@@ -372,3 +372,17 @@ export {
waitForQaChannelReady,
waitForTransportReady,
};
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -12,7 +12,7 @@ import {
type QaReportScenario,
} from "openclaw/plugin-sdk/qa-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { assertQaSuiteArtifactWritten } from "./artifact-assertion.js";
import { QaSuiteArtifactError } from "./errors.js";
import { buildQaSuiteEvidenceSummary, QA_EVIDENCE_FILENAME } from "./evidence-summary.js";
import { startQaGatewayChild, type QaCliBackendAuthMode } from "./gateway-child.js";
import type {
@@ -884,6 +884,21 @@ async function writeQaSuiteArtifacts(params: {
return { evidencePath, report, reportPath, summaryPath };
}
async function assertQaSuiteArtifactWritten(
kind: "evidence" | "report" | "summary",
filePath: string,
) {
try {
await fs.access(filePath);
} catch (error) {
throw new QaSuiteArtifactError(
`${kind}_missing`,
`QA suite did not produce ${kind} artifact at ${filePath}: ${formatErrorMessage(error)}`,
{ cause: error },
);
}
}
function buildQaSuiteRuntimeMetrics(params: {
startedAt: Date;
finishedAt: Date;

View File

@@ -2,8 +2,8 @@ import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { assertQaSuiteArtifactWritten } from "./artifact-assertion.js";
import { isRepoRootRelativeRef, toRepoRelativePath } from "./cli-paths.js";
import { QaSuiteArtifactError } from "./errors.js";
import {
buildPlaywrightEvidenceSummary,
buildScriptEvidenceSummary,
@@ -542,10 +542,22 @@ async function writeTestFileEvidenceFile(params: {
}): Promise<Pick<QaTestFileScenarioRunResult, "evidencePath">> {
const evidencePath = path.join(params.outputDir, QA_EVIDENCE_FILENAME);
await fs.writeFile(evidencePath, `${JSON.stringify(params.evidence, null, 2)}\n`, "utf8");
await assertQaSuiteArtifactWritten("evidence", evidencePath);
await assertQaTestFileArtifactWritten("evidence", evidencePath);
return { evidencePath };
}
async function assertQaTestFileArtifactWritten(kind: "evidence", filePath: string) {
try {
await fs.access(filePath);
} catch (error) {
throw new QaSuiteArtifactError(
`${kind}_missing`,
`QA suite did not produce ${kind} artifact at ${filePath}: ${formatErrorMessage(error)}`,
{ cause: error },
);
}
}
export async function runQaTestFileScenarios(
params: QaTestFileScenarioRunParams,
): Promise<QaTestFileScenarioRunResult> {

View File

@@ -84,13 +84,6 @@ function evidenceState(overrides: Partial<UiState> = {}): UiState {
}
describe("QA Lab UI evidence render", () => {
it("renders capture startup commands without personal home paths", () => {
const html = renderQaLabUi(evidenceState({ activeTab: "capture" }));
expect(html).toContain("$HOME/.openclaw/debug-proxy/certs/root-ca.pem");
expect(html).not.toContain("/Users/");
});
it("maps blocked and skipped evidence statuses to styled tones", () => {
const html = renderQaLabUi(
evidenceState({
@@ -262,152 +255,4 @@ describe("QA Lab UI evidence render", () => {
expect(html).not.toContain("<video controls");
expect(html).not.toContain('data-evidence-entry-id="null"');
});
it("redacts secret-like capture payload fields in raw previews", () => {
const payload =
'{"message":"visible context","message":"duplicate context","completion_tokens":100,"cookies":["session=abc"],"apiToken":"secret-token","tokenValue":"token-value-secret","authTokens":["auth-token-secret"],"tokens":{"refresh":"refresh-token-secret"},"AWS_SECRET_ACCESS_KEY":"aws-secret","secretAccessKey":"access-secret","x-goog-api-key":"goog-secret","nested":{"password":"secret-password"}}';
const html = renderQaLabUi(
evidenceState({
activeTab: "capture",
captureDetailView: "payload",
capturePayloadDetailLayout: "raw",
captureEvents: [
{
contentType: "application/json",
dataText: payload,
direction: "outbound",
flowId: "flow-1",
host: "api.example.test",
id: 1,
kind: "request",
method: "POST",
path: "/v1/messages",
payloadPreview: payload,
protocol: "https",
provider: "mock",
ts: 1,
},
],
selectedCaptureEventKey: "1:flow-1:1:request",
}),
);
expect(html).toContain("visible context");
expect(html).toContain("duplicate context");
expect(html).toContain("completion_tokens");
expect(html).toContain("100");
expect(html).toContain("apiToken");
expect(html).toContain("nested");
expect(html).toContain("[redacted]");
expect(html).not.toContain("session=abc");
expect(html).not.toContain("secret-token");
expect(html).not.toContain("token-value-secret");
expect(html).not.toContain("auth-token-secret");
expect(html).not.toContain("refresh-token-secret");
expect(html).not.toContain("aws-secret");
expect(html).not.toContain("access-secret");
expect(html).not.toContain("goog-secret");
expect(html).not.toContain("secret-password");
});
it("redacts secret-like fields when captured JSON previews are truncated", () => {
const payload =
'{"apiToken":"secret-token","nested":{"password":"secret-password"},"message":"visible context"';
for (const capturePayloadDetailLayout of ["raw", "formatted"] as const) {
const html = renderQaLabUi(
evidenceState({
activeTab: "capture",
captureDetailView: "payload",
capturePayloadDetailLayout,
captureEvents: [
{
contentType: "application/json",
dataText: payload,
direction: "outbound",
flowId: "flow-1",
host: "api.example.test",
id: 1,
kind: "request",
method: "POST",
path: "/v1/messages",
payloadPreview: payload,
protocol: "https",
provider: "mock",
ts: 1,
},
],
selectedCaptureEventKey: "1:flow-1:1:request",
}),
);
expect(html).toContain("visible context");
expect(html).toContain("[redacted]");
expect(html).not.toContain("secret-token");
expect(html).not.toContain("secret-password");
}
});
it("redacts secret-like SSE data fields in formatted payloads", () => {
const payload = 'event: message\ndata: {"apiToken":"secret-token","message":"visible"}';
const html = renderQaLabUi(
evidenceState({
activeTab: "capture",
captureDetailView: "payload",
capturePayloadDetailLayout: "formatted",
captureEvents: [
{
contentType: "text/event-stream",
dataText: payload,
direction: "inbound",
flowId: "flow-1",
host: "api.example.test",
id: 1,
kind: "response",
path: "/v1/messages",
payloadPreview: payload,
protocol: "https",
provider: "mock",
ts: 1,
},
],
selectedCaptureEventKey: "1:flow-1:1:response",
}),
);
expect(html).toContain("visible");
expect(html).toContain("[redacted]");
expect(html).not.toContain("secret-token");
});
it("redacts secret-like fields when capture cuts inside a JSON value", () => {
const payload = '{"apiToken":"secret-token';
const html = renderQaLabUi(
evidenceState({
activeTab: "capture",
captureDetailView: "payload",
capturePayloadDetailLayout: "raw",
captureEvents: [
{
contentType: "application/json",
dataText: payload,
direction: "outbound",
flowId: "flow-1",
host: "api.example.test",
id: 1,
kind: "request",
method: "POST",
path: "/v1/messages",
payloadPreview: payload,
protocol: "https",
provider: "mock",
ts: 1,
},
],
selectedCaptureEventKey: "1:flow-1:1:request",
}),
);
expect(html).toContain("[redacted]");
expect(html).not.toContain("secret-token");
});
});

View File

@@ -531,32 +531,9 @@ function renderCaptureHeaders(raw: string | undefined, mode: UiState["captureHea
);
}
const NON_SECRET_CAPTURE_TOKEN_FIELDS = new Set([
"completiontokens",
"inputtokens",
"maxcompletiontokens",
"maxtokens",
"outputtokens",
"prompttokens",
"reasoningtokens",
"totaltokens",
]);
function isSensitiveCaptureField(label: string): boolean {
const normalized = label.toLowerCase().replace(/[^a-z0-9]+/g, "");
const tokenMarker =
!NON_SECRET_CAPTURE_TOKEN_FIELDS.has(normalized) &&
normalized !== "tokenizer" &&
normalized.includes("token");
return (
normalized.includes("authorization") ||
normalized.includes("cookie") ||
normalized.includes("apikey") ||
normalized.includes("accesskey") ||
normalized.includes("secret") ||
normalized.includes("password") ||
normalized.includes("session") ||
tokenMarker
return /authorization|proxy-authorization|cookie|set-cookie|api[-_]?key|x[-_]?api[-_]?key|token|secret|password|session/i.test(
label,
);
}
@@ -581,9 +558,6 @@ function redactCaptureValue(value: unknown, label?: string): unknown {
if (typeof value === "string") {
return redactCaptureScalar(value, label);
}
if (label && isSensitiveCaptureField(label)) {
return "[redacted]";
}
if (Array.isArray(value)) {
return value.map((entry) => redactCaptureValue(entry, label));
}
@@ -597,115 +571,6 @@ function redactCaptureValue(value: unknown, label?: string): unknown {
return out;
}
function readCaptureQuotedSpan(
value: string,
start: number,
): { closed: boolean; end: number; raw: string; text: string } {
const quote = value[start];
let text = "";
let index = start + 1;
while (index < value.length) {
const char = value[index];
if (char === "\\") {
text += value.slice(index, Math.min(index + 2, value.length));
index += 2;
continue;
}
if (char === quote) {
return {
closed: true,
end: index + 1,
raw: value.slice(start, index + 1),
text,
};
}
text += char;
index += 1;
}
return {
closed: false,
end: value.length,
raw: value.slice(start),
text,
};
}
function skipCaptureInlineWhitespace(value: string, start: number): number {
let index = start;
while (/\s/.test(value[index] ?? "")) {
index += 1;
}
return index;
}
function consumeCaptureJsonishValue(value: string, start: number): number {
const opener = value[start];
if (opener === '"' || opener === "'") {
return readCaptureQuotedSpan(value, start).end;
}
if (opener === "{" || opener === "[") {
const stack = [opener === "{" ? "}" : "]"];
let index = start + 1;
while (index < value.length && stack.length > 0) {
const char = value[index];
if (char === '"' || char === "'") {
index = readCaptureQuotedSpan(value, index).end;
continue;
}
if (char === "{" || char === "[") {
stack.push(char === "{" ? "}" : "]");
} else if (char === stack.at(-1)) {
stack.pop();
}
index += 1;
}
return index;
}
let index = start;
while (index < value.length && !/[,\n\r}\]]/.test(value[index] ?? "")) {
index += 1;
}
return index;
}
function redactCaptureJsonishSecretFields(value: string): string {
let redacted = "";
let index = 0;
while (index < value.length) {
const char = value[index];
if (char !== '"' && char !== "'") {
redacted += char;
index += 1;
continue;
}
const key = readCaptureQuotedSpan(value, index);
const colonIndex = skipCaptureInlineWhitespace(value, key.end);
if (!key.closed || value[colonIndex] !== ":" || !isSensitiveCaptureField(key.text)) {
redacted += key.raw;
index = key.end;
continue;
}
const valueStart = skipCaptureInlineWhitespace(value, colonIndex + 1);
const valueEnd = consumeCaptureJsonishValue(value, valueStart);
const valueQuote =
value[valueStart] === "'" || value[valueStart] === '"' ? value[valueStart] : '"';
redacted += `${key.raw}${value.slice(key.end, valueStart)}${valueQuote}[redacted]${valueQuote}`;
index = valueEnd;
}
return redacted;
}
function redactCaptureInlineSecretPairs(value: string): string {
return redactCaptureJsonishSecretFields(value).replace(
/\b([A-Za-z][A-Za-z0-9_-]{0,64})=([^&\s"',}\]]+)/gu,
(match: string, key: string) => (isSensitiveCaptureField(key) ? `${key}=[redacted]` : match),
);
}
function redactCapturePayloadPreview(payload: string): string {
return redactCaptureScalar(redactCaptureInlineSecretPairs(payload));
}
function formatCaptureFieldValue(value: unknown, label?: string): string {
const redacted = redactCaptureValue(value, label);
if (typeof redacted === "string") {
@@ -731,7 +596,7 @@ function renderCaptureFormPayload(payload: string): string {
}));
return rows.length > 0
? renderCaptureKeyValueGrid(rows)
: `<pre class="report-pre capture-pre">${esc(redactCapturePayloadPreview(payload))}</pre>`;
: `<pre class="report-pre capture-pre">${esc(redactCaptureScalar(payload))}</pre>`;
}
function renderCaptureSsePayload(
@@ -756,10 +621,7 @@ function renderCaptureSsePayload(
const label =
separatorIndex >= 0 ? line.slice(0, separatorIndex).trim() || "field" : "line";
const value = separatorIndex >= 0 ? line.slice(separatorIndex + 1).trim() : line;
return {
label,
value: redactCaptureScalar(redactCaptureInlineSecretPairs(value), label),
};
return { label, value: redactCaptureScalar(value, label) };
});
const eventName = rows.find((row) => row.label === "event")?.value || "message";
const dataText = rows
@@ -796,7 +658,7 @@ function renderCaptureSsePayload(
});
if (frames.length === 0) {
return {
body: `<pre class="report-pre capture-pre">${esc(redactCapturePayloadPreview(payload))}</pre>`,
body: `<pre class="report-pre capture-pre">${esc(redactCaptureScalar(payload))}</pre>`,
eventCount: 0,
visibleCount: 0,
};
@@ -891,7 +753,7 @@ function renderCapturePayload(
}
}
return {
body: `<pre class="report-pre capture-pre">${esc(redactCapturePayloadPreview(payload))}</pre>`,
body: `<pre class="report-pre capture-pre">${esc(redactCaptureScalar(payload))}</pre>`,
mode: "text",
byteLength,
looksStructured: false,
@@ -934,7 +796,7 @@ pnpm openclaw gateway --port 18789 --bind loopback`;
const qaStart = "pnpm qa:lab:ui --port 43124 --control-ui-url http://127.0.0.1:18789/";
const caInstall = "pnpm proxy:install-ca";
const caTrust =
'sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "$HOME/.openclaw/debug-proxy/certs/root-ca.pem"';
"sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain /Users/thoffman/.openclaw/debug-proxy/certs/root-ca.pem";
return `<div class="capture-startup-state">
<div class="capture-startup-title">Proxy capture is not running yet.</div>
<div class="text-dimmed text-sm capture-startup-copy">
@@ -2785,9 +2647,7 @@ function renderCaptureView(state: UiState): string {
].filter((row) => row.value.trim().length > 0)
: [];
const rawPayloadBody = selectedEvent?.dataText?.length
? `<pre class="report-pre capture-pre">${esc(
redactCapturePayloadPreview(selectedEvent.dataText),
)}</pre>`
? `<pre class="report-pre capture-pre">${esc(redactCaptureScalar(selectedEvent.dataText))}</pre>`
: '<div class="empty-state">No inline payload preview for this event.</div>';
const availableDetailViews: Array<{
value: UiState["captureDetailView"];
@@ -3818,11 +3678,7 @@ function renderCaptureView(state: UiState): string {
selectedLaneEvent.errorText
? `<div class="capture-timeline-quick-preview-error">${esc(selectedLaneEvent.errorText)}</div>`
: selectedLaneEvent.payloadPreview
? `<div class="capture-timeline-quick-preview-snippet">${esc(
redactCapturePayloadPreview(
selectedLaneEvent.payloadPreview,
),
)}</div>`
? `<div class="capture-timeline-quick-preview-snippet">${esc(selectedLaneEvent.payloadPreview)}</div>`
: ""
}
</div>`
@@ -4012,11 +3868,7 @@ function renderCaptureView(state: UiState): string {
${paired ? '<div class="capture-pair-badge">paired counterpart</div>' : ""}
${
event.payloadPreview
? `<div class="capture-event-card-preview">${esc(
redactCapturePayloadPreview(
event.payloadPreview,
),
)}</div>`
? `<div class="capture-event-card-preview">${esc(event.payloadPreview)}</div>`
: ""
}
</div>
@@ -4065,9 +3917,7 @@ function renderCaptureView(state: UiState): string {
}
${
event.payloadPreview
? `<div class="capture-event-card-preview">${esc(
redactCapturePayloadPreview(event.payloadPreview),
)}</div>`
? `<div class="capture-event-card-preview">${esc(event.payloadPreview)}</div>`
: ""
}
${event.errorText ? `<div class="capture-error" style="margin-top:8px">${esc(event.errorText)}</div>` : ""}

View File

@@ -0,0 +1,21 @@
// Qwen plugin module implements model definitions behavior.
export {
buildQwenDefaultModelDefinition,
buildQwenModelDefinition,
QWEN_CN_BASE_URL,
QWEN_DEFAULT_COST,
QWEN_DEFAULT_MODEL_ID,
QWEN_DEFAULT_MODEL_REF,
QWEN_GLOBAL_BASE_URL,
QWEN_STANDARD_CN_BASE_URL,
QWEN_STANDARD_GLOBAL_BASE_URL,
buildModelStudioDefaultModelDefinition,
buildModelStudioModelDefinition,
MODELSTUDIO_CN_BASE_URL,
MODELSTUDIO_DEFAULT_COST,
MODELSTUDIO_DEFAULT_MODEL_ID,
MODELSTUDIO_DEFAULT_MODEL_REF,
MODELSTUDIO_GLOBAL_BASE_URL,
MODELSTUDIO_STANDARD_CN_BASE_URL,
MODELSTUDIO_STANDARD_GLOBAL_BASE_URL,
} from "./models.js";

View File

@@ -182,6 +182,7 @@ export const ExecApprovalRequestParamsSchema = Type.Object(
turnSourceTo: Type.Optional(Type.Union([Type.String(), Type.Null()])),
turnSourceAccountId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
turnSourceThreadId: Type.Optional(Type.Union([Type.String(), Type.Number(), Type.Null()])),
approvalReviewerDeviceIds: Type.Optional(Type.Array(NonEmptyString)),
requireDeliveryRoute: Type.Optional(Type.Boolean()),
suppressDelivery: Type.Optional(Type.Boolean()),
timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),

View File

@@ -4,11 +4,7 @@ import { EventHub } from "./event-hub.js";
import { normalizeGatewayEvent } from "./normalize.js";
import { GatewayClientTransport, isConnectableTransport } from "./transport.js";
import type {
AgentsCreateParams,
AgentsDeleteParams,
AgentsUpdateParams,
AgentRunParams,
ApprovalDecisionParams,
ArtifactQuery,
ArtifactsDownloadResult,
ArtifactsGetResult,
@@ -29,7 +25,6 @@ import type {
TasksGetResult,
TasksListParams,
TasksListResult,
ToolsEffectiveParams,
ToolInvokeParams,
ToolInvokeResult,
} from "./types.js";
@@ -239,18 +234,6 @@ function requireArtifactQueryScope(api: string, params: unknown): ArtifactQuery
return params;
}
function hasToolsEffectiveSessionKey(params: unknown): params is ToolsEffectiveParams {
const record = asRecord(params);
return typeof record.sessionKey === "string" && record.sessionKey.trim().length > 0;
}
function requireToolsEffectiveSessionKey(params: unknown): ToolsEffectiveParams {
if (!hasToolsEffectiveSessionKey(params)) {
throw new Error("oc.tools.effective requires sessionKey");
}
return params;
}
function readChatProjection(event: OpenClawEvent): ChatProjection | undefined {
const raw = event.raw;
if (event.type !== "raw" || raw?.event !== "chat") {
@@ -732,15 +715,15 @@ export class AgentsNamespace {
return new Agent(this.client, id);
}
async create(params: AgentsCreateParams): Promise<unknown> {
async create(params: Record<string, unknown>): Promise<unknown> {
return await this.client.request("agents.create", params);
}
async update(params: AgentsUpdateParams): Promise<unknown> {
async update(params: Record<string, unknown>): Promise<unknown> {
return await this.client.request("agents.update", params);
}
async delete(params: AgentsDeleteParams): Promise<unknown> {
async delete(params: Record<string, unknown>): Promise<unknown> {
return await this.client.request("agents.delete", params);
}
}
@@ -874,8 +857,8 @@ export class ToolsNamespace extends RpcNamespace {
return await this.call("catalog", params === undefined ? {} : params);
}
async effective(params: ToolsEffectiveParams): Promise<unknown> {
return await this.call("effective", requireToolsEffectiveSessionKey(params));
async effective(params?: unknown): Promise<unknown> {
return await this.call("effective", params);
}
async invoke(name: string, params?: ToolInvokeParams): Promise<ToolInvokeResult> {
@@ -923,11 +906,8 @@ export class ApprovalsNamespace {
return await this.client.request("exec.approval.list", params === undefined ? {} : params);
}
async respond(approvalId: string, params: ApprovalDecisionParams): Promise<unknown> {
return await this.client.request("exec.approval.resolve", {
id: approvalId,
decision: params.decision,
});
async respond(approvalId: string, decision: Record<string, unknown>): Promise<unknown> {
return await this.client.request("exec.approval.resolve", { ...decision, id: approvalId });
}
}

View File

@@ -446,19 +446,14 @@ describe("OpenClaw SDK websocket e2e", () => {
const identity = expectJsonObject(await agent.identity({ sessionKey: "sdk-session" }));
expect(identity.agentId).toBe("main");
expect(identity.sessionKey).toBe("sdk-session");
const createAgent = expectJsonObject(
await oc.agents.create({ name: "SDK Agent", workspace: "/tmp/sdk-agent" }),
);
const createAgent = expectJsonObject(await oc.agents.create({ id: "sdk-agent" }));
expect(createAgent.method).toBe("agents.create");
expect(createAgent.params).toEqual({ name: "SDK Agent", workspace: "/tmp/sdk-agent" });
const updateAgent = expectJsonObject(
await oc.agents.update({ agentId: "sdk-agent", name: "Renamed SDK Agent" }),
await oc.agents.update({ id: "sdk-agent", label: "SDK Agent" }),
);
expect(updateAgent.method).toBe("agents.update");
expect(updateAgent.params).toEqual({ agentId: "sdk-agent", name: "Renamed SDK Agent" });
const deleteAgent = expectJsonObject(await oc.agents.delete({ agentId: "sdk-agent" }));
const deleteAgent = expectJsonObject(await oc.agents.delete({ id: "sdk-agent" }));
expect(deleteAgent.method).toBe("agents.delete");
expect(deleteAgent.params).toEqual({ agentId: "sdk-agent" });
const sessions = expectJsonObject(await oc.sessions.list());
expect(sessions.sessions).toEqual([{ key: "sdk-session" }]);

View File

@@ -739,23 +739,6 @@ describe("OpenClaw SDK", () => {
]);
});
it("rejects tools.effective without a session key before RPC", async () => {
type EffectiveMethod = (this: unknown, params?: unknown) => Promise<unknown>;
const transport = new FakeTransport({
"tools.effective": { tools: [] },
});
const oc = new OpenClaw({ transport });
await expect((oc.tools.effective as unknown as EffectiveMethod).call(oc.tools)).rejects.toThrow(
"oc.tools.effective requires sessionKey",
);
await expect(
(oc.tools.effective as unknown as EffectiveMethod).call(oc.tools, {}),
).rejects.toThrow("oc.tools.effective requires sessionKey");
expect(transport.calls).toEqual([]);
});
it("keeps close terminal when it races a pending connect", async () => {
const transport = new DelayedConnectTransport({
"agents.list": { agents: [] },
@@ -785,10 +768,9 @@ describe("OpenClaw SDK", () => {
const oc = new OpenClaw({ transport });
await expect(oc.approvals.list()).resolves.toEqual({ approvals: [] });
const staleDecision = { id: "stale-approval", decision: "allow-once" as const };
await expect(oc.approvals.respond("approval-123", staleDecision)).resolves.toEqual({
ok: true,
});
await expect(
oc.approvals.respond("approval-123", { id: "stale-approval", decision: "allow-once" }),
).resolves.toEqual({ ok: true });
expect(transport.calls).toEqual([
{

View File

@@ -20,11 +20,7 @@ export { EventHub, isGatewayEvent } from "./event-hub.js";
export { normalizeGatewayEvent } from "./normalize.js";
export { GatewayClientTransport, isConnectableTransport } from "./transport.js";
export type {
AgentsCreateParams,
AgentsDeleteParams,
AgentsUpdateParams,
AgentRunParams,
ApprovalDecisionParams,
ApprovalMode,
ArtifactQuery,
ArtifactSummary,
@@ -56,7 +52,6 @@ export type {
TasksGetResult,
TasksListParams,
TasksListResult,
ToolsEffectiveParams,
ToolInvokeParams,
ToolInvokeResult,
WorkspaceSelection,

View File

@@ -68,10 +68,6 @@ export type WorkspaceSelection = {
export type ApprovalMode = "ask" | "never" | "auto" | "trusted";
export type ApprovalDecisionParams = {
decision: "allow-once" | "allow-always" | "deny";
};
/** Terminal and non-terminal status values returned by Run.wait. */
export type RunStatus = "accepted" | "completed" | "failed" | "cancelled" | "timed_out";
@@ -192,11 +188,6 @@ export type SDKError = {
};
/** Parameters for direct tool invocation through the SDK. */
export type ToolsEffectiveParams = {
sessionKey: string;
agentId?: string;
};
export type ToolInvokeParams = {
args?: JsonObject;
sessionKey?: string;
@@ -333,25 +324,3 @@ export type SessionTarget = {
};
export type RunCreateParams = AgentRunParams;
export type AgentsCreateParams = {
name: string;
workspace: string;
model?: string;
emoji?: string;
avatar?: string;
};
export type AgentsUpdateParams = {
agentId: string;
name?: string;
workspace?: string;
model?: string;
emoji?: string;
avatar?: string;
};
export type AgentsDeleteParams = {
agentId: string;
deleteFiles?: boolean;
};

View File

@@ -1,29 +0,0 @@
title: OpenAI-compatible chat tools HTTP API
scenario:
id: openai-compatible-chat-tools
surface: runtime
coverage:
primary:
- gateway.openai-compatible-apis
secondary:
- runtime.hosted-tool-use
objective: Verify the OpenAI-compatible chat-completions client and Docker lane preserve strict tool-call API behavior.
successCriteria:
- The Docker lane fails missing or placeholder OpenAI auth before Docker build work starts.
- The generated config preserves strict positive gateway port and timeout values.
- The chat-completions client posts to `/v1/chat/completions` with the expected gateway token and model header.
- Tool-call-only responses are accepted, visible content beside a tool call is rejected, and response bodies remain bounded.
docsRefs:
- docs/gateway/protocol.md
- docs/help/testing.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- scripts/e2e/lib/openai-chat-tools/client.mjs
- scripts/e2e/lib/openai-chat-tools/write-config.mjs
- scripts/e2e/openai-chat-tools-docker.sh
- test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts
summary: Vitest coverage for OpenAI-compatible chat-completions tool-call API behavior.

View File

@@ -1,29 +0,0 @@
title: OpenAI web_search minimal reasoning gate
scenario:
id: openai-web-search-minimal
surface: model-provider
coverage:
primary:
- runtime.reasoning-and-cache-controls
secondary:
- web-search.openai-native-web-search
- tools.web-search
objective: Verify the OpenAI web_search minimal-reasoning E2E client distinguishes successful grounded turns from provider schema rejection.
successCriteria:
- Reject mode accepts the expected raw OpenAI schema rejection and the gateway schema wrapper.
- Reject mode fails if the agent run unexpectedly succeeds or fails for unrelated transport reasons.
- Success mode requires an `ok` agent result with the expected marker in visible reply payloads.
- Gateway ports are parsed strictly before connecting.
docsRefs:
- docs/tools/web.md
- docs/help/testing.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- scripts/e2e/lib/openai-web-search-minimal/client.mjs
- scripts/e2e/openai-web-search-minimal-docker.sh
- test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts
summary: Vitest coverage for OpenAI web_search minimal-reasoning success and rejection validation.

View File

@@ -1,30 +0,0 @@
title: OpenAI native web_search request assertions
scenario:
id: openai-web-search-native-assertions
surface: model-provider
coverage:
primary:
- web-search.openai-native-web-search
- plugins.web-search-and-fetch
secondary:
- web-search.model-and-filter-routing
- tools.web-search
objective: Verify the OpenAI web_search Docker lane assertions require native Responses web_search evidence with bounded diagnostics.
successCriteria:
- A successful request must hit `/v1/responses` with native `web_search` and non-minimal reasoning.
- Large request logs are scanned without missing later success requests.
- Failure diagnostics are bounded and do not dump stale or oversized request bodies.
- Function-shaped `web_search` is rejected as native Responses proof.
docsRefs:
- docs/tools/web.md
- docs/help/testing.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- scripts/e2e/lib/openai-web-search-minimal/assertions.mjs
- scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs
- test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts
summary: Vitest coverage for native OpenAI web_search request-log assertions.

View File

@@ -1,28 +0,0 @@
title: OpenWebUI OpenAI-compatible API probe
scenario:
id: openwebui-openai-compatible
surface: runtime
coverage:
primary:
- gateway.openai-compatible-apis
secondary:
- runtime.hosted-provider-turns
- runtime.provider-specific-model-options
objective: Verify the OpenWebUI E2E probe exercises OpenClaw through OpenWebUI's OpenAI-compatible model and chat APIs.
successCriteria:
- Probe environment limits are parsed strictly and control-plane requests time out quickly.
- Sign-in and model-list error bodies are bounded before diagnostics are emitted.
- Models mode authenticates and finds the OpenClaw model exposed by OpenWebUI.
- Chat mode posts to `/api/chat/completions`, validates the expected nonce, and fails when the reply omits it.
docsRefs:
- docs/help/testing.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- scripts/e2e/openwebui-probe.mjs
- scripts/e2e/openwebui-docker.sh
- test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts
summary: Vitest coverage for OpenWebUI model and chat-completions probe behavior.

View File

@@ -1,9 +1,51 @@
// Android Sync Versioning script supports OpenClaw repository automation.
import path from "node:path";
import { syncAndroidVersioning } from "./lib/android-version.ts";
import { parseVersionSyncArgs } from "./lib/version-script-args.ts";
export { parseVersionSyncArgs as parseArgs } from "./lib/version-script-args.ts";
type Mode = "check" | "write";
export function parseArgs(argv: string[]): { help: boolean; mode: Mode; rootDir: string } {
let help = false;
let mode: Mode = "write";
let rootDir = path.resolve(".");
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--check": {
mode = "check";
break;
}
case "--write": {
mode = "write";
break;
}
case "--root": {
rootDir = path.resolve(readOptionValue(argv, index, "--root"));
index += 1;
break;
}
case "-h":
case "--help": {
help = true;
break;
}
default: {
throw new Error(`Unknown argument: ${arg}`);
}
}
}
return { help, mode, rootDir };
}
function readOptionValue(argv: string[], index: number, flag: string): string {
const value = argv[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`Missing value for ${flag}.`);
}
return value;
}
function printUsage(): void {
process.stdout.write(
@@ -12,7 +54,7 @@ function printUsage(): void {
}
function main(argv = process.argv.slice(2)): number {
const options = parseVersionSyncArgs(argv);
const options = parseArgs(argv);
if (options.help) {
printUsage();
return 0;

View File

@@ -1,6 +1,63 @@
// Android Version script supports OpenClaw repository automation.
import path from "node:path";
import { resolveAndroidVersion } from "./lib/android-version.ts";
import { parseVersionQueryArgs } from "./lib/version-script-args.ts";
type CliOptions = {
field: string | null;
format: "json" | "shell";
help: boolean;
rootDir: string;
};
function parseArgs(argv: string[]): CliOptions {
let field: string | null = null;
let format: "json" | "shell" = "json";
let help = false;
let rootDir = path.resolve(".");
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--field": {
field = readOptionValue(argv, index, "--field");
index += 1;
break;
}
case "--json": {
format = "json";
break;
}
case "--shell": {
format = "shell";
break;
}
case "--root": {
const value = readOptionValue(argv, index, "--root");
rootDir = path.resolve(value);
index += 1;
break;
}
case "-h":
case "--help": {
help = true;
break;
}
default: {
throw new Error(`Unknown argument: ${arg}`);
}
}
}
return { field, format, help, rootDir };
}
function readOptionValue(argv: string[], index: number, flag: string): string {
const value = argv[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`Missing value for ${flag}.`);
}
return value;
}
function printUsage(): void {
process.stdout.write(
@@ -9,7 +66,7 @@ function printUsage(): void {
}
function main(argv = process.argv.slice(2)): number {
const options = parseVersionQueryArgs(argv);
const options = parseArgs(argv);
if (options.help) {
printUsage();
return 0;

View File

@@ -679,56 +679,6 @@ function pathExists(path) {
}
}
function crabboxConfigDir() {
if (process.platform === "darwin") {
return resolve(homedir(), "Library", "Application Support", "crabbox");
}
if (process.platform === "win32") {
return resolve(process.env.APPDATA || resolve(homedir(), "AppData", "Roaming"), "crabbox");
}
return resolve(process.env.XDG_CONFIG_HOME || resolve(homedir(), ".config"), "crabbox");
}
function userDisplayPath(path) {
const home = homedir();
const rel = relative(home, path);
if (rel && !rel.startsWith("..") && !isAbsolute(rel)) {
return `~/${rel}`;
}
return path;
}
function blacksmithTestboxPrivateKeyPath(id) {
return resolve(crabboxConfigDir(), "testboxes", id, "id_ed25519");
}
function enforceCrabboxOwnedBlacksmithLease(commandArgs) {
if (commandArgs[0] !== "run") {
return;
}
const id = optionValue(commandArgs, "--id");
if (!id) {
return;
}
if (!id.startsWith("tbx_")) {
return;
}
const keyPath = blacksmithTestboxPrivateKeyPath(id);
if (pathExists(keyPath)) {
return;
}
console.error(
[
`[crabbox] provider=blacksmith-testbox --id ${id} has no Crabbox SSH key at ${userDisplayPath(keyPath)}.`,
"[crabbox] create reusable Testboxes through Crabbox before reusing them: node scripts/crabbox-wrapper.mjs warmup --provider blacksmith-testbox --idle-timeout 90m",
"[crabbox] direct `blacksmith testbox warmup` leases can be used with `blacksmith testbox run`, but Crabbox cannot sync or run them by id.",
].join("\n"),
);
process.exit(2);
}
function preserveTemporaryCrabboxRuns() {
if (childCwd === repoRoot) {
return;
@@ -2443,7 +2393,6 @@ if (canonicalProvider === "blacksmith-testbox") {
console.error(
`[crabbox] provider=blacksmith-testbox ${source}; if Testbox is queued or down, ${fallback}`,
);
enforceCrabboxOwnedBlacksmithLease(normalizedArgs);
}
let childCwd = repoRoot;

View File

@@ -563,10 +563,7 @@ export function parseGatewayCliRequestFailure(error) {
} catch {
return null;
}
return payload?.ok === false ? createGatewayClientRequestError(payload.error) : null;
}
export function createGatewayClientRequestError(requestError) {
const requestError = payload?.ok === false ? payload.error : null;
if (
requestError?.type !== "gateway_request_error" ||
!isNonEmptyString(requestError.code) ||
@@ -715,10 +712,6 @@ function hasOwnPayloadField(raw, field) {
export function unwrapRpcPayload(raw) {
if (raw?.ok === false) {
const requestError = createGatewayClientRequestError(raw.error);
if (requestError) {
throw requestError;
}
throw new Error(`gateway RPC failed: ${boundedJsonPreview(raw.error ?? raw)}`);
}
if (

View File

@@ -142,39 +142,6 @@ function sleep(ms) {
});
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function redactDiagnosticText(text, extraSecrets = []) {
let redacted = text;
for (const secret of [email, password, ...extraSecrets]) {
if (!secret) {
continue;
}
redacted = redacted.replace(new RegExp(escapeRegExp(secret), "g"), "<redacted>");
redacted = redacted.replace(
new RegExp(escapeRegExp(JSON.stringify(secret).slice(1, -1)), "g"),
"<redacted>",
);
}
return redacted;
}
function cookieSecretValues(cookieHeader) {
if (!cookieHeader) {
return [];
}
return cookieHeader
.split(";")
.map((part) => {
const text = part.trim();
const separatorIndex = text.indexOf("=");
return separatorIndex === -1 ? "" : text.slice(separatorIndex + 1).trim();
})
.filter(Boolean);
}
async function fetchSignin() {
return await withRequestTimeout(
"Open WebUI signin",
@@ -188,7 +155,7 @@ async function fetchSignin() {
});
if (!response.ok) {
const body = await readBoundedResponseText(response, "Open WebUI signin", timeoutPromise);
throw new Error(`signin failed: HTTP ${response.status} ${redactDiagnosticText(body)}`);
throw new Error(`signin failed: HTTP ${response.status} ${body}`);
}
return {
cookie: getCookieHeader(response),
@@ -198,22 +165,21 @@ async function fetchSignin() {
);
}
async function fetchModels(authHeaders, attempt, diagnosticSecrets) {
async function fetchModels(authHeaders, attempt) {
return await withRequestTimeout(
`Open WebUI models attempt ${attempt}`,
controlTimeoutMs,
async (signal, timeoutPromise) => {
const response = await fetch(`${baseUrl}/api/models`, { headers: authHeaders, signal });
if (!response.ok) {
const text = await readBoundedResponseText(
response,
`Open WebUI models attempt ${attempt}`,
timeoutPromise,
);
return {
ok: false,
status: response.status,
text: redactDiagnosticText(text, diagnosticSecrets),
text: await readBoundedResponseText(
response,
`Open WebUI models attempt ${attempt}`,
timeoutPromise,
),
};
}
return {
@@ -228,7 +194,7 @@ async function fetchModels(authHeaders, attempt, diagnosticSecrets) {
);
}
async function fetchChatCompletion(authHeaders, targetModel, diagnosticSecrets) {
async function fetchChatCompletion(authHeaders, targetModel) {
return await withRequestTimeout(
"Open WebUI chat completion",
chatTimeoutMs,
@@ -251,12 +217,7 @@ async function fetchChatCompletion(authHeaders, targetModel, diagnosticSecrets)
"Open WebUI chat completion",
timeoutPromise,
);
throw new Error(
`/api/chat/completions failed: HTTP ${response.status} ${redactDiagnosticText(
body,
diagnosticSecrets,
)}`,
);
throw new Error(`/api/chat/completions failed: HTTP ${response.status} ${body}`);
}
return await readBoundedResponseJson(response, "Open WebUI chat completion", timeoutPromise);
},
@@ -284,13 +245,12 @@ const authHeaders = {
...buildAuthHeaders(token, signin.cookie),
accept: "application/json",
};
const diagnosticSecrets = [token, signin.cookie, ...cookieSecretValues(signin.cookie)];
let modelIds = [];
let targetModel = "";
let lastModelsError = "";
for (let attempt = 1; attempt <= modelAttempts; attempt += 1) {
const modelsResult = await fetchModels(authHeaders, attempt, diagnosticSecrets).catch(
const modelsResult = await fetchModels(authHeaders, attempt).catch(
/** @param {unknown} error */ (error) => {
lastModelsError = error instanceof Error ? error.message : String(error);
return undefined;
@@ -321,7 +281,7 @@ if (smokeMode === "models") {
process.exit(0);
}
const chatJson = await fetchChatCompletion(authHeaders, targetModel, diagnosticSecrets);
const chatJson = await fetchChatCompletion(authHeaders, targetModel);
const reply =
chatJson?.choices?.[0]?.message?.content ?? chatJson?.message?.content ?? chatJson?.content ?? "";
if (typeof reply !== "string" || !reply.includes(expectedNonce)) {

View File

@@ -1,9 +1,51 @@
// Ios Sync Versioning script supports OpenClaw repository automation.
import path from "node:path";
import { syncIosVersioning } from "./lib/ios-version.ts";
import { parseVersionSyncArgs } from "./lib/version-script-args.ts";
export { parseVersionSyncArgs as parseArgs } from "./lib/version-script-args.ts";
type Mode = "check" | "write";
export function parseArgs(argv: string[]): { help: boolean; mode: Mode; rootDir: string } {
let help = false;
let mode: Mode = "write";
let rootDir = path.resolve(".");
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--check": {
mode = "check";
break;
}
case "--write": {
mode = "write";
break;
}
case "--root": {
rootDir = path.resolve(readOptionValue(argv, index, "--root"));
index += 1;
break;
}
case "-h":
case "--help": {
help = true;
break;
}
default: {
throw new Error(`Unknown argument: ${arg}`);
}
}
}
return { help, mode, rootDir };
}
function readOptionValue(argv: string[], index: number, flag: string): string {
const value = argv[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`Missing value for ${flag}.`);
}
return value;
}
function printUsage(): void {
process.stdout.write(
@@ -12,7 +54,7 @@ function printUsage(): void {
}
function main(argv = process.argv.slice(2)): number {
const options = parseVersionSyncArgs(argv);
const options = parseArgs(argv);
if (options.help) {
printUsage();
return 0;

View File

@@ -1,6 +1,63 @@
// Ios Version script supports OpenClaw repository automation.
import path from "node:path";
import { resolveIosVersion } from "./lib/ios-version.ts";
import { parseVersionQueryArgs } from "./lib/version-script-args.ts";
type CliOptions = {
field: string | null;
format: "json" | "shell";
help: boolean;
rootDir: string;
};
function parseArgs(argv: string[]): CliOptions {
let field: string | null = null;
let format: "json" | "shell" = "json";
let help = false;
let rootDir = path.resolve(".");
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--field": {
field = readOptionValue(argv, index, "--field");
index += 1;
break;
}
case "--json": {
format = "json";
break;
}
case "--shell": {
format = "shell";
break;
}
case "--root": {
const value = readOptionValue(argv, index, "--root");
rootDir = path.resolve(value);
index += 1;
break;
}
case "-h":
case "--help": {
help = true;
break;
}
default: {
throw new Error(`Unknown argument: ${arg}`);
}
}
}
return { field, format, help, rootDir };
}
function readOptionValue(argv: string[], index: number, flag: string): string {
const value = argv[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`Missing value for ${flag}.`);
}
return value;
}
function printUsage(): void {
process.stdout.write(
@@ -9,7 +66,7 @@ function printUsage(): void {
}
function main(argv = process.argv.slice(2)): number {
const options = parseVersionQueryArgs(argv);
const options = parseArgs(argv);
if (options.help) {
printUsage();
return 0;

View File

@@ -1,100 +0,0 @@
import path from "node:path";
export type VersionScriptFormat = "json" | "shell";
export type VersionQueryCliOptions = {
field: string | null;
format: VersionScriptFormat;
help: boolean;
rootDir: string;
};
export type VersionSyncMode = "check" | "write";
export type VersionSyncCliOptions = {
help: boolean;
mode: VersionSyncMode;
rootDir: string;
};
export function parseVersionQueryArgs(argv: string[]): VersionQueryCliOptions {
let field: string | null = null;
let format: VersionScriptFormat = "json";
let help = false;
let rootDir = path.resolve(".");
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--field": {
field = readOptionValue(argv, index, "--field");
index += 1;
break;
}
case "--json": {
format = "json";
break;
}
case "--shell": {
format = "shell";
break;
}
case "--root": {
const value = readOptionValue(argv, index, "--root");
rootDir = path.resolve(value);
index += 1;
break;
}
case "-h":
case "--help": {
help = true;
break;
}
default: {
throw new Error(`Unknown argument: ${arg}`);
}
}
}
return { field, format, help, rootDir };
}
export function parseVersionSyncArgs(argv: string[]): VersionSyncCliOptions {
let help = false;
let mode: VersionSyncMode = "write";
let rootDir = path.resolve(".");
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case "--check": {
mode = "check";
break;
}
case "--write": {
mode = "write";
break;
}
case "--root": {
rootDir = path.resolve(readOptionValue(argv, index, "--root"));
index += 1;
break;
}
case "-h":
case "--help": {
help = true;
break;
}
default: {
throw new Error(`Unknown argument: ${arg}`);
}
}
}
return { help, mode, rootDir };
}
function readOptionValue(argv: string[], index: number, flag: string): string {
const value = argv[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`Missing value for ${flag}.`);
}
return value;
}

View File

@@ -1072,70 +1072,28 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
"src/image-generation/openai-compatible-image-provider.test.ts",
],
],
[
"scripts/e2e/lib/openai-chat-tools/client.mjs",
["test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts"],
],
[
"scripts/e2e/lib/openai-chat-tools/scenario.sh",
["test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts"],
],
[
"scripts/e2e/lib/openai-chat-tools/write-config.mjs",
["test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts"],
],
[
"scripts/e2e/openai-chat-tools-docker.sh",
[
"test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts",
"test/scripts/docker-e2e-plan.test.ts",
],
],
[
"scripts/e2e/lib/openai-web-search-minimal/assertions.mjs",
["test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts"],
],
[
"scripts/e2e/lib/openai-web-search-minimal/client.mjs",
["test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts"],
],
[
"scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs",
[
"test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts",
"test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts",
],
],
[
"scripts/e2e/lib/openai-web-search-minimal/scenario.sh",
[
"test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts",
"test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts",
],
["test/scripts/openai-chat-tools-client.test.ts", "test/scripts/docker-e2e-plan.test.ts"],
],
[
"scripts/e2e/openai-web-search-minimal-docker.sh",
[
"test/scripts/docker-build-helper.test.ts",
"test/scripts/docker-e2e-plan.test.ts",
"test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts",
"test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts",
"test/scripts/openai-web-search-minimal-client.test.ts",
"test/scripts/openai-web-search-minimal-assertions.test.ts",
],
],
[
"scripts/e2e/lib/openwebui/http-probe.mjs",
["test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts"],
],
[
"scripts/e2e/openwebui-docker.sh",
[
"test/scripts/docker-build-helper.test.ts",
"test/scripts/docker-e2e-plan.test.ts",
"test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts",
"test/scripts/openwebui-probe.test.ts",
"test/scripts/fixture-config.test.ts",
],
],
["scripts/e2e/openwebui-probe.mjs", ["test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts"]],
[
"scripts/e2e/plugin-binding-command-escape-docker.sh",
[

View File

@@ -453,6 +453,8 @@ export function createOpenClawCodingTools(options?: {
oneShotCliRun?: boolean;
/** Stable run identifier for this agent invocation. */
runId?: string;
/** Device-scoped operator session allowed to review approvals initiated by this run. */
approvalReviewerDeviceId?: string;
/** Diagnostic trace context for hook/log correlation during this run. */
trace?: DiagnosticTraceContext;
/** What initiated this run (for trigger-specific tool restrictions). */
@@ -870,6 +872,7 @@ export function createOpenClawCodingTools(options?: {
currentChannelId: options?.currentChannelId,
currentThreadTs: options?.currentThreadTs,
accountId: options?.agentAccountId,
approvalReviewerDeviceId: options?.approvalReviewerDeviceId,
backgroundMs: options?.exec?.backgroundMs ?? execConfig.backgroundMs,
timeoutSec: options?.exec?.timeoutSec ?? execConfig.timeoutSec,
approvalRunningNoticeMs:

View File

@@ -58,6 +58,7 @@ function restoreProcessPlatformForTest(): void {
}
type ApprovalRequestPayload = {
approvalReviewerDeviceIds?: string[];
commandSpans?: Array<{ startIndex: number; endIndex: number }>;
};
@@ -159,6 +160,23 @@ describe("exec approval requests", () => {
]);
});
it("passes approval reviewer devices into host approval registration payloads", async () => {
vi.mocked(callGatewayTool).mockResolvedValue({ id: "approval-id", expiresAtMs: 1234 });
await registerExecApprovalRequestForHost({
approvalId: "approval-id",
command: "echo hi",
approvalReviewerDeviceIds: ["device-ios-reviewer"],
workdir: "/tmp/project",
host: "node",
security: "allowlist",
ask: "always",
});
const payload = requireApprovalRequestPayload(0);
expect(payload?.approvalReviewerDeviceIds).toEqual(["device-ios-reviewer"]);
});
it("does not generate command spans by default", async () => {
vi.mocked(callGatewayTool).mockResolvedValue({ id: "approval-id", expiresAtMs: 1234 });

View File

@@ -64,6 +64,7 @@ type RequestExecApprovalDecisionParams = {
turnSourceTo?: string;
turnSourceAccountId?: string;
turnSourceThreadId?: string | number;
approvalReviewerDeviceIds?: string[];
requireDeliveryRoute?: boolean;
suppressDelivery?: boolean;
};
@@ -99,6 +100,7 @@ function buildExecApprovalRequestToolParams(
turnSourceTo: params.turnSourceTo,
turnSourceAccountId: params.turnSourceAccountId,
turnSourceThreadId: params.turnSourceThreadId,
approvalReviewerDeviceIds: params.approvalReviewerDeviceIds,
requireDeliveryRoute: params.requireDeliveryRoute,
suppressDelivery: params.suppressDelivery,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
@@ -210,6 +212,7 @@ type HostExecApprovalParams = {
turnSourceTo?: string;
turnSourceAccountId?: string;
turnSourceThreadId?: string | number;
approvalReviewerDeviceIds?: string[];
requireDeliveryRoute?: boolean;
suppressDelivery?: boolean;
};
@@ -318,6 +321,7 @@ async function buildHostApprovalDecisionParams(
resolvedPath: params.resolvedPath,
requireDeliveryRoute: params.requireDeliveryRoute,
suppressDelivery: params.suppressDelivery,
approvalReviewerDeviceIds: params.approvalReviewerDeviceIds,
...buildExecApprovalTurnSourceContext(params),
};
}

View File

@@ -95,6 +95,7 @@ type ProcessGatewayAllowlistParams = {
/** Session-store template, so the direct/denied followup can detect a rebind. */
sessionStore?: string;
bashElevated?: ExecElevatedDefaults;
approvalReviewerDeviceId?: string;
turnSourceChannel?: string;
turnSourceTo?: string;
turnSourceAccountId?: string;
@@ -695,6 +696,9 @@ export async function processGatewayAllowlist(
agentId: params.agentId,
sessionKey: params.sessionKey,
}),
approvalReviewerDeviceIds: params.approvalReviewerDeviceId
? [params.approvalReviewerDeviceId]
: undefined,
resolvedPath: resolveApprovalAuditTrustPath(
allowlistEval.segments[0]?.resolution ?? null,
params.workdir,

View File

@@ -178,6 +178,9 @@ export async function executeNodeHostCommand(
agentId: prepared.agentId,
sessionKey: prepared.sessionKey,
}),
approvalReviewerDeviceIds: params.approvalReviewerDeviceId
? [params.approvalReviewerDeviceId]
: undefined,
...(options.requireDeliveryRoute !== undefined
? { requireDeliveryRoute: options.requireDeliveryRoute }
: {}),

View File

@@ -21,6 +21,7 @@ export type ExecuteNodeHostCommandParams = {
/** Session-store template, so the direct/denied followup can detect a rebind. */
sessionStore?: string;
bashElevated?: ExecElevatedDefaults;
approvalReviewerDeviceId?: string;
turnSourceChannel?: string;
turnSourceTo?: string;
turnSourceAccountId?: string;

View File

@@ -72,6 +72,7 @@ export type ExecToolDefaults = {
currentChannelId?: string;
currentThreadTs?: string;
accountId?: string;
approvalReviewerDeviceId?: string;
notifyOnExit?: boolean;
notifyOnExitEmptySuccess?: boolean;
cwd?: string;

View File

@@ -1657,6 +1657,7 @@ export function createExecTool(
sessionId: defaults?.sessionId,
sessionStore: defaults?.sessionStore,
bashElevated: elevatedDefaults,
approvalReviewerDeviceId: defaults?.approvalReviewerDeviceId,
turnSourceChannel: defaults?.messageProvider,
turnSourceTo: defaults?.currentChannelId,
turnSourceAccountId: defaults?.accountId,
@@ -1707,6 +1708,7 @@ export function createExecTool(
sessionId: defaults?.sessionId,
sessionStore: defaults?.sessionStore,
bashElevated: elevatedDefaults,
approvalReviewerDeviceId: defaults?.approvalReviewerDeviceId,
turnSourceChannel: defaults?.messageProvider,
turnSourceTo: defaults?.currentChannelId,
turnSourceAccountId: defaults?.accountId,

View File

@@ -108,6 +108,8 @@ export type RunCliAgentParams = {
senderId?: string | null;
/** Trusted sender identity bit for channel action auth. */
senderIsOwner?: boolean;
/** Device-scoped operator session allowed to review approvals initiated by this run. */
approvalReviewerDeviceId?: string;
/** Runtime tool allow-list. CLI harnesses fail closed when this is set. */
toolsAllow?: string[];
disableTools?: boolean;

View File

@@ -693,6 +693,7 @@ export function runAgentAttempt(params: {
currentChannelId: params.runContext.currentChannelId,
currentThreadTs: params.runContext.currentThreadTs,
currentInboundAudio: params.runContext.currentInboundAudio,
approvalReviewerDeviceId: params.opts.approvalReviewerDeviceId,
agentAccountId: params.runContext.accountId,
senderId: params.runContext.senderId,
senderIsOwner: params.opts.senderIsOwner,
@@ -787,6 +788,7 @@ export function runAgentAttempt(params: {
fastMode: params.fastMode,
verboseLevel: params.resolvedVerboseLevel,
bashElevated: params.opts.bashElevated,
approvalReviewerDeviceId: params.opts.approvalReviewerDeviceId,
timeoutMs: params.timeoutMs,
runId: params.runId,
lifecycleGeneration: params.lifecycleGeneration,

View File

@@ -90,6 +90,8 @@ export type AgentCommandOpts = {
accountId?: string;
/** Context for embedded run routing (channel/account/thread). */
runContext?: AgentRunContext;
/** Device-scoped operator session allowed to review approvals initiated by this run. */
approvalReviewerDeviceId?: string;
/** Internal trusted exec approval follow-up elevated defaults. */
bashElevated?: ExecElevatedDefaults;
/** Trusted sender identity bit for command/channel-action auth; defaults true for local CLI calls. */

View File

@@ -1926,6 +1926,7 @@ async function runEmbeddedAgentInternal(
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
approvalReviewerDeviceId: params.approvalReviewerDeviceId,
currentChannelId: params.currentChannelId,
currentMessagingTarget: params.currentMessagingTarget,
currentThreadTs: params.currentThreadTs,

View File

@@ -1296,6 +1296,7 @@ export async function runEmbeddedAttempt(
: undefined,
sessionId: params.sessionId,
runId: params.runId,
approvalReviewerDeviceId: params.approvalReviewerDeviceId,
oneShotCliRun: params.oneShotCliRun,
toolSearchCatalogRef,
agentDir,

View File

@@ -83,6 +83,8 @@ export type RunEmbeddedAgentParams = {
senderE164?: string | null;
/** Trusted sender identity bit for command/channel-action auth. */
senderIsOwner?: boolean;
/** Device-scoped operator session allowed to review approvals initiated by this run. */
approvalReviewerDeviceId?: string;
/** Current channel ID for auto-threading (Slack). */
currentChannelId?: string;
/** Routable target for the current conversation when it differs from the native channel ID. */

View File

@@ -2393,6 +2393,7 @@ export async function runAgentTurnWithFallback(params: {
agentAccountId: params.followupRun.run.agentAccountId,
senderId: params.followupRun.run.senderId,
senderIsOwner: params.followupRun.run.senderIsOwner,
approvalReviewerDeviceId: params.followupRun.run.approvalReviewerDeviceId,
toolsAllow: params.opts?.toolsAllow,
disableTools: params.opts?.disableTools,
abortSignal: runAbortSignal,

View File

@@ -85,6 +85,7 @@ export function buildEmbeddedRunBaseParams(params: {
ownerNumbers: params.run.ownerNumbers,
inputProvenance: params.run.inputProvenance,
senderIsOwner: params.run.senderIsOwner,
approvalReviewerDeviceId: params.run.approvalReviewerDeviceId,
enforceFinalTag: resolveEnforceFinalTagWithResolver(
params.run,
params.provider,

View File

@@ -1323,6 +1323,7 @@ export async function runPreparedReply(
senderIsOwner: command.senderIsOwner,
traceAuthorized:
command.senderIsOwner || (ctx.GatewayClientScopes ?? []).includes("operator.admin"),
approvalReviewerDeviceId: normalizeOptionalString(ctx.ApprovalReviewerDeviceId),
sessionFile: preparedSessionState.sessionFile,
workspaceDir,
cwd: normalizeOptionalString(sessionEntry?.spawnedCwd),

View File

@@ -106,6 +106,7 @@ export type FollowupRun = {
senderE164?: string;
senderIsOwner?: boolean;
traceAuthorized?: boolean;
approvalReviewerDeviceId?: string;
sessionFile: string;
workspaceDir: string;
/** Task working directory for runtime execution. Defaults to workspaceDir. */

View File

@@ -285,6 +285,8 @@ export type MsgContext = {
AcpDispatchTailAfterReset?: boolean;
/** Gateway client scopes when the message originates from the gateway. */
GatewayClientScopes?: string[];
/** Gateway device id allowed to review approvals initiated by this turn. */
ApprovalReviewerDeviceId?: string;
/** Thread identifier (Telegram topic id or Matrix thread event id). */
MessageThreadId?: string | number;
/** Provider-native thread target for reply delivery without making the session thread-scoped. */

View File

@@ -2,8 +2,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import { afterEach, describe, expect, it } from "vitest";
import {
formatCompletionReloadCommand,
installCompletion,
@@ -13,6 +12,22 @@ import {
} from "./completion-runtime.js";
describe("completion-runtime", () => {
const originalHome = process.env.HOME;
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
afterEach(() => {
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
if (originalStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = originalStateDir;
}
});
it("formats PowerShell reload commands with single-quoted paths", () => {
expect(formatCompletionReloadCommand("powershell", "C:\\Users\\Ada\\profile.ps1")).toBe(
". 'C:\\Users\\Ada\\profile.ps1'",
@@ -71,18 +86,19 @@ describe("completion-runtime", () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-completion-home-"));
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-completion-state-bob's-"));
process.env.HOME = homeDir;
process.env.OPENCLAW_STATE_DIR = stateDir;
try {
await withEnvAsync({ HOME: homeDir, OPENCLAW_STATE_DIR: stateDir }, async () => {
const cachePath = resolveCompletionCachePath("powershell", "openclaw");
await fs.mkdir(path.dirname(cachePath), { recursive: true });
await fs.writeFile(cachePath, "# powershell completion\n", "utf-8");
const cachePath = resolveCompletionCachePath("powershell", "openclaw");
await fs.mkdir(path.dirname(cachePath), { recursive: true });
await fs.writeFile(cachePath, "# powershell completion\n", "utf-8");
await installCompletion("powershell", true, "openclaw");
await installCompletion("powershell", true, "openclaw");
const profilePath = resolveCompletionProfilePath("powershell");
const profile = await fs.readFile(profilePath, "utf-8");
expect(profile).toBe(`# OpenClaw Completion\n. '${cachePath.replace(/'/g, "''")}'\n`);
});
const profilePath = resolveCompletionProfilePath("powershell");
const profile = await fs.readFile(profilePath, "utf-8");
expect(profile).toBe(`# OpenClaw Completion\n. '${cachePath.replace(/'/g, "''")}'\n`);
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
await fs.rm(stateDir, { recursive: true, force: true });
@@ -93,12 +109,13 @@ describe("completion-runtime", () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-completion-home-"));
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-completion-state-"));
process.env.HOME = homeDir;
process.env.OPENCLAW_STATE_DIR = stateDir;
try {
await withEnvAsync({ HOME: homeDir, OPENCLAW_STATE_DIR: stateDir }, async () => {
await expect(installCompletion("zsh", true, "openclaw")).rejects.toThrow(
"Completion cache not found",
);
});
await expect(installCompletion("zsh", true, "openclaw")).rejects.toThrow(
"Completion cache not found",
);
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
await fs.rm(stateDir, { recursive: true, force: true });

View File

@@ -7,7 +7,7 @@ import type { StaleOpenClawUpdateLaunchdJob } from "../../daemon/launchd.js";
import { createMockGatewayService } from "../../daemon/service.test-helpers.js";
import type { PortConnections } from "../../infra/ports.js";
import type { GatewayRestartHandoff } from "../../infra/restart-handoff.js";
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js";
import { captureEnv } from "../../test-utils/env.js";
import { VERSION } from "../../version.js";
import type { GatewayRestartSnapshot } from "./restart-health.js";
import { gatherDaemonStatus } from "./status.gather.js";
@@ -252,12 +252,12 @@ describe("gatherDaemonStatus", () => {
"DAEMON_GATEWAY_TOKEN",
"DAEMON_GATEWAY_PASSWORD",
]);
setTestEnvValue("OPENCLAW_STATE_DIR", "/tmp/openclaw-cli");
setTestEnvValue("OPENCLAW_CONFIG_PATH", "/tmp/openclaw-cli/openclaw.json");
deleteTestEnvValue("OPENCLAW_GATEWAY_TOKEN");
deleteTestEnvValue("OPENCLAW_GATEWAY_PASSWORD");
deleteTestEnvValue("DAEMON_GATEWAY_TOKEN");
deleteTestEnvValue("DAEMON_GATEWAY_PASSWORD");
process.env.OPENCLAW_STATE_DIR = "/tmp/openclaw-cli";
process.env.OPENCLAW_CONFIG_PATH = "/tmp/openclaw-cli/openclaw.json";
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
delete process.env.DAEMON_GATEWAY_TOKEN;
delete process.env.DAEMON_GATEWAY_PASSWORD;
callGatewayStatusProbe.mockClear();
resolveGatewayProbeAuthSafeWithSecretInputsCalls.mockClear();
createConfigIOCalls.mockClear();
@@ -665,8 +665,8 @@ describe("gatherDaemonStatus", () => {
},
}),
);
setTestEnvValue("OPENCLAW_STATE_DIR", tmp);
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
process.env.OPENCLAW_STATE_DIR = tmp;
process.env.OPENCLAW_CONFIG_PATH = configPath;
serviceReadCommand.mockResolvedValueOnce({
programArguments: ["/bin/node", "cli", "gateway", "--port", "19001"],
environment: {
@@ -707,8 +707,8 @@ describe("gatherDaemonStatus", () => {
},
}),
);
setTestEnvValue("OPENCLAW_STATE_DIR", tmp);
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
process.env.OPENCLAW_STATE_DIR = tmp;
process.env.OPENCLAW_CONFIG_PATH = configPath;
cliLoadedConfig = {
gateway: {
bind: "loopback",
@@ -756,7 +756,7 @@ describe("gatherDaemonStatus", () => {
},
},
};
setTestEnvValue("DAEMON_GATEWAY_PASSWORD", "daemon-secretref-password"); // pragma: allowlist secret
process.env.DAEMON_GATEWAY_PASSWORD = "daemon-secretref-password"; // pragma: allowlist secret
await gatherDaemonStatus({
rpc: {},
@@ -785,7 +785,7 @@ describe("gatherDaemonStatus", () => {
},
},
};
setTestEnvValue("DAEMON_GATEWAY_TOKEN", "daemon-secretref-token");
process.env.DAEMON_GATEWAY_TOKEN = "daemon-secretref-token";
await gatherDaemonStatus({
rpc: {},
@@ -1009,8 +1009,8 @@ describe("gatherDaemonStatus", () => {
},
},
};
setTestEnvValue("OPENCLAW_GATEWAY_TOKEN", "env-token");
setTestEnvValue("OPENCLAW_GATEWAY_PASSWORD", "env-password"); // pragma: allowlist secret
process.env.OPENCLAW_GATEWAY_TOKEN = "env-token";
process.env.OPENCLAW_GATEWAY_PASSWORD = "env-password"; // pragma: allowlist secret
await gatherDaemonStatus({
rpc: {},

View File

@@ -1,6 +1,5 @@
// Run-main profile env tests cover profile environment handling in the CLI entrypoint.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
const fileState = vi.hoisted(() => ({
hasCliDotEnv: false,
@@ -76,26 +75,24 @@ vi.mock("./container-target.js", async () => {
import { runCli } from "./run-main.js";
describe("runCli profile env bootstrap", () => {
const envSnapshot = captureEnv([
"OPENCLAW_PROFILE",
"OPENCLAW_STATE_DIR",
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_CONTAINER",
"OPENCLAW_GATEWAY_PORT",
"OPENCLAW_GATEWAY_URL",
"OPENCLAW_GATEWAY_TOKEN",
"OPENCLAW_GATEWAY_PASSWORD",
]);
const originalProfile = process.env.OPENCLAW_PROFILE;
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
const originalConfigPath = process.env.OPENCLAW_CONFIG_PATH;
const originalContainer = process.env.OPENCLAW_CONTAINER;
const originalGatewayPort = process.env.OPENCLAW_GATEWAY_PORT;
const originalGatewayUrl = process.env.OPENCLAW_GATEWAY_URL;
const originalGatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN;
const originalGatewayPassword = process.env.OPENCLAW_GATEWAY_PASSWORD;
beforeEach(() => {
deleteTestEnvValue("OPENCLAW_PROFILE");
deleteTestEnvValue("OPENCLAW_STATE_DIR");
deleteTestEnvValue("OPENCLAW_CONFIG_PATH");
deleteTestEnvValue("OPENCLAW_CONTAINER");
deleteTestEnvValue("OPENCLAW_GATEWAY_PORT");
deleteTestEnvValue("OPENCLAW_GATEWAY_URL");
deleteTestEnvValue("OPENCLAW_GATEWAY_TOKEN");
deleteTestEnvValue("OPENCLAW_GATEWAY_PASSWORD");
delete process.env.OPENCLAW_PROFILE;
delete process.env.OPENCLAW_STATE_DIR;
delete process.env.OPENCLAW_CONFIG_PATH;
delete process.env.OPENCLAW_CONTAINER;
delete process.env.OPENCLAW_GATEWAY_PORT;
delete process.env.OPENCLAW_GATEWAY_URL;
delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
dotenvState.state.profileAtDotenvLoad = undefined;
dotenvState.state.containerAtDotenvLoad = undefined;
dotenvState.loadDotEnv.mockClear();
@@ -104,7 +101,46 @@ describe("runCli profile env bootstrap", () => {
});
afterEach(() => {
envSnapshot.restore();
if (originalProfile === undefined) {
delete process.env.OPENCLAW_PROFILE;
} else {
process.env.OPENCLAW_PROFILE = originalProfile;
}
if (originalContainer === undefined) {
delete process.env.OPENCLAW_CONTAINER;
} else {
process.env.OPENCLAW_CONTAINER = originalContainer;
}
if (originalStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = originalStateDir;
}
if (originalConfigPath === undefined) {
delete process.env.OPENCLAW_CONFIG_PATH;
} else {
process.env.OPENCLAW_CONFIG_PATH = originalConfigPath;
}
if (originalGatewayPort === undefined) {
delete process.env.OPENCLAW_GATEWAY_PORT;
} else {
process.env.OPENCLAW_GATEWAY_PORT = originalGatewayPort;
}
if (originalGatewayUrl === undefined) {
delete process.env.OPENCLAW_GATEWAY_URL;
} else {
process.env.OPENCLAW_GATEWAY_URL = originalGatewayUrl;
}
if (originalGatewayToken === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = originalGatewayToken;
}
if (originalGatewayPassword === undefined) {
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
} else {
process.env.OPENCLAW_GATEWAY_PASSWORD = originalGatewayPassword;
}
});
it("applies --profile before dotenv loading", async () => {
@@ -158,7 +194,7 @@ describe("runCli profile env bootstrap", () => {
});
it("allows container mode when OPENCLAW_PROFILE is already set in env", async () => {
setTestEnvValue("OPENCLAW_PROFILE", "work");
process.env.OPENCLAW_PROFILE = "work";
await expect(
runCli(["node", "openclaw", "--container", "demo", "status"]),
@@ -171,7 +207,7 @@ describe("runCli profile env bootstrap", () => {
["OPENCLAW_GATEWAY_TOKEN", "demo-token"],
["OPENCLAW_GATEWAY_PASSWORD", "demo-password"],
])("allows container mode when %s is set in env", async (key, value) => {
setTestEnvValue(key, value);
process.env[key] = value;
await expect(
runCli(["node", "openclaw", "--container", "demo", "status"]),
@@ -179,7 +215,7 @@ describe("runCli profile env bootstrap", () => {
});
it("allows container mode when only OPENCLAW_STATE_DIR is set in env", async () => {
setTestEnvValue("OPENCLAW_STATE_DIR", "/tmp/openclaw-host-state");
process.env.OPENCLAW_STATE_DIR = "/tmp/openclaw-host-state";
await expect(
runCli(["node", "openclaw", "--container", "demo", "status"]),
@@ -187,7 +223,7 @@ describe("runCli profile env bootstrap", () => {
});
it("allows container mode when only OPENCLAW_CONFIG_PATH is set in env", async () => {
setTestEnvValue("OPENCLAW_CONFIG_PATH", "/tmp/openclaw-host-state/openclaw.json");
process.env.OPENCLAW_CONFIG_PATH = "/tmp/openclaw-host-state/openclaw.json";
await expect(
runCli(["node", "openclaw", "--container", "demo", "status"]),

View File

@@ -4,7 +4,6 @@ import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js";
import * as backupShared from "./backup-shared.js";
import {
@@ -104,16 +103,15 @@ describe("backup commands", () => {
async function withInvalidWorkspaceBackupConfig<T>(fn: (runtime: RuntimeEnv) => Promise<T>) {
const stateDir = path.join(tempHome.home, ".openclaw");
const configPath = path.join(tempHome.home, "custom-config.json");
process.env.OPENCLAW_CONFIG_PATH = configPath;
await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8");
await fs.writeFile(configPath, '{"agents": { defaults: { workspace: ', "utf8");
const envSnapshot = captureEnv(["OPENCLAW_CONFIG_PATH"]);
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
const runtime = createBackupTestRuntime();
try {
return await fn(runtime);
} finally {
envSnapshot.restore();
delete process.env.OPENCLAW_CONFIG_PATH;
}
}
@@ -235,9 +233,8 @@ describe("backup commands", () => {
let capturedManifest: CapturedBackupManifest | null = null;
let capturedEntryPaths: string[] = [];
let capturedOnWriteEntry: ((entry: { path: string }) => void) | null = null;
const envSnapshot = captureEnv(["OPENCLAW_CONFIG_PATH"]);
try {
setTestEnvValue("OPENCLAW_CONFIG_PATH", configPath);
process.env.OPENCLAW_CONFIG_PATH = configPath;
await fs.writeFile(
configPath,
JSON.stringify({
@@ -354,7 +351,7 @@ describe("backup commands", () => {
),
);
} finally {
envSnapshot.restore();
delete process.env.OPENCLAW_CONFIG_PATH;
await fs.rm(externalWorkspace, { recursive: true, force: true });
await fs.rm(backupDir, { recursive: true, force: true });
}

View File

@@ -3,7 +3,6 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
import {
checkShellCompletionStatus,
shellCompletionStatusToHealthFindings,
@@ -11,11 +10,21 @@ import {
type ShellCompletionStatus,
} from "./doctor-completion.js";
const originalEnv = captureEnv(["HOME", "OPENCLAW_STATE_DIR", "SHELL"]);
const originalEnv = {
HOME: process.env.HOME,
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
SHELL: process.env.SHELL,
};
const tempDirs: string[] = [];
afterEach(async () => {
originalEnv.restore();
for (const [name, value] of Object.entries(originalEnv)) {
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}
for (const dir of tempDirs.splice(0)) {
await fs.rm(dir, { recursive: true, force: true });
}
@@ -37,9 +46,9 @@ describe("shell completion health mapping", () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-completion-home-"));
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-completion-state-"));
tempDirs.push(homeDir, stateDir);
setTestEnvValue("HOME", homeDir);
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
setTestEnvValue("SHELL", "/bin/zsh");
process.env.HOME = homeDir;
process.env.OPENCLAW_STATE_DIR = stateDir;
process.env.SHELL = "/bin/zsh";
const current = await checkShellCompletionStatus("openclaw", { shell: "fish" });

View File

@@ -5,7 +5,6 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
import { withEnvAsync } from "../test-utils/env.js";
import { withMockedPlatform } from "../test-utils/vitest-spies.js";
import {
formatControlUiSshHint,
@@ -100,6 +99,12 @@ describe("handleReset", () => {
`openclaw-workspace-attestation:v1\n${new Date().toISOString()}\n`,
);
vi.stubEnv("HOME", homeDir);
vi.stubEnv("OPENCLAW_HOME", homeDir);
vi.stubEnv("OPENCLAW_PROFILE", "work");
vi.stubEnv("OPENCLAW_STATE_DIR", profileStateDir);
vi.stubEnv("OPENCLAW_CONFIG_PATH", profileConfigPath);
const runtime = { log: vi.fn() } as unknown as RuntimeEnv;
const expectedTrashedPaths = [
profileConfigPath,
@@ -111,16 +116,7 @@ describe("handleReset", () => {
const expectedDefaultCredentialsDir = expectedTrashSourcePath(defaultCredentialsDir);
try {
await withEnvAsync(
{
HOME: homeDir,
OPENCLAW_HOME: homeDir,
OPENCLAW_PROFILE: "work",
OPENCLAW_STATE_DIR: profileStateDir,
OPENCLAW_CONFIG_PATH: profileConfigPath,
},
async () => await handleReset("full", workspaceDir, runtime),
);
await handleReset("full", workspaceDir, runtime);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
@@ -145,20 +141,17 @@ describe("handleReset", () => {
fs.writeFileSync(profileConfigPath, "{}\n");
fs.writeFileSync(workspaceAttestationPath, "external data\n");
vi.stubEnv("HOME", homeDir);
vi.stubEnv("OPENCLAW_HOME", homeDir);
vi.stubEnv("OPENCLAW_PROFILE", "work");
vi.stubEnv("OPENCLAW_STATE_DIR", profileStateDir);
vi.stubEnv("OPENCLAW_CONFIG_PATH", profileConfigPath);
const runtime = { log: vi.fn() } as unknown as RuntimeEnv;
const unownedAttestationTrashPath = expectedTrashSourcePath(workspaceAttestationPath);
try {
await withEnvAsync(
{
HOME: homeDir,
OPENCLAW_HOME: homeDir,
OPENCLAW_PROFILE: "work",
OPENCLAW_STATE_DIR: profileStateDir,
OPENCLAW_CONFIG_PATH: profileConfigPath,
},
async () => await handleReset("full", workspaceDir, runtime),
);
await handleReset("full", workspaceDir, runtime);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
@@ -185,22 +178,17 @@ describe("handleReset", () => {
fs.writeFileSync(workspaceAttestationPath, "external data\n", { mode: 0o000 });
fs.chmodSync(workspaceAttestationPath, 0o000);
vi.stubEnv("HOME", homeDir);
vi.stubEnv("OPENCLAW_HOME", homeDir);
vi.stubEnv("OPENCLAW_PROFILE", "work");
vi.stubEnv("OPENCLAW_STATE_DIR", profileStateDir);
vi.stubEnv("OPENCLAW_CONFIG_PATH", profileConfigPath);
const runtime = { log: vi.fn() } as unknown as RuntimeEnv;
const unreadableAttestationTrashPath = expectedTrashSourcePath(workspaceAttestationPath);
try {
await withEnvAsync(
{
HOME: homeDir,
OPENCLAW_HOME: homeDir,
OPENCLAW_PROFILE: "work",
OPENCLAW_STATE_DIR: profileStateDir,
OPENCLAW_CONFIG_PATH: profileConfigPath,
},
async () => {
await expect(handleReset("full", workspaceDir, runtime)).resolves.toBeUndefined();
},
);
await expect(handleReset("full", workspaceDir, runtime)).resolves.toBeUndefined();
} finally {
fs.chmodSync(workspaceAttestationPath, 0o600);
fs.rmSync(homeDir, { recursive: true, force: true });

View File

@@ -1,7 +1,6 @@
// Launchd tests cover macOS service plist generation and command handling.
import { PassThrough } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
import { GATEWAY_SERVICE_KIND, GATEWAY_SERVICE_MARKER } from "./constants.js";
import {
LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS,
@@ -94,9 +93,9 @@ async function withProcessEnv<T>(
previous.set(key, process.env[key]);
const value = overrides[key];
if (value === undefined) {
deleteTestEnvValue(key);
delete process.env[key];
} else {
setTestEnvValue(key, value);
process.env[key] = value;
}
}
try {
@@ -104,9 +103,9 @@ async function withProcessEnv<T>(
} finally {
for (const [key, value] of previous) {
if (value === undefined) {
deleteTestEnvValue(key);
delete process.env[key];
} else {
setTestEnvValue(key, value);
process.env[key] = value;
}
}
}

View File

@@ -42,6 +42,7 @@ export type ExecApprovalRecord<TPayload = ExecApprovalRequestPayload> = {
requestedByDeviceId?: string | null;
requestedByClientId?: string | null;
requestedByDeviceTokenAuth?: boolean;
approvalReviewerDeviceIds?: string[];
resolvedAtMs?: number;
decision?: ExecApprovalDecision;
consumedDecision?: ExecApprovalDecision;

View File

@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { GATEWAY_CLIENT_IDS } from "../../../packages/gateway-protocol/src/client-info.js";
import { ExecApprovalManager } from "../exec-approval-manager.js";
import {
bindApprovalReviewerDeviceIds,
handleApprovalResolve,
handleApprovalWaitDecision,
handlePendingApprovalRequest,
@@ -165,6 +166,64 @@ describe("handlePendingApprovalRequest", () => {
).toBe(false);
});
it("allows approval-scoped reviewer devices to see approvals requested by the backend runtime", () => {
const manager = new ExecApprovalManager();
const record = manager.create(
{
command: "echo ok",
},
60_000,
"approval-reviewer-device-visible",
);
record.requestedByDeviceId = "device-gateway-runtime";
record.requestedByConnId = "conn-gateway-runtime";
record.requestedByClientId = GATEWAY_CLIENT_IDS.GATEWAY_CLIENT;
bindApprovalReviewerDeviceIds({
record,
deviceIds: [" device-mobile ", "device-mobile"],
});
expect(record.approvalReviewerDeviceIds).toEqual(["device-mobile"]);
expect(
isApprovalRecordVisibleToClient({
record,
client: createApprovalClient({
connId: "conn-mobile",
clientId: GATEWAY_CLIENT_IDS.IOS_APP,
deviceId: "device-mobile",
scopes: ["operator.approvals"],
}),
}),
).toBe(true);
});
it("does not allow reviewer devices without approval scope to see approvals", () => {
const manager = new ExecApprovalManager();
const record = manager.create(
{
command: "echo ok",
},
60_000,
"approval-reviewer-device-scope-hidden",
);
record.requestedByDeviceId = "device-gateway-runtime";
record.requestedByConnId = "conn-gateway-runtime";
record.requestedByClientId = GATEWAY_CLIENT_IDS.GATEWAY_CLIENT;
bindApprovalReviewerDeviceIds({ record, deviceIds: ["device-mobile"] });
expect(
isApprovalRecordVisibleToClient({
record,
client: createApprovalClient({
connId: "conn-mobile",
clientId: GATEWAY_CLIENT_IDS.IOS_APP,
deviceId: "device-mobile",
scopes: ["operator.read"],
}),
}),
).toBe(false);
});
it("allows gateway-client approval runtimes to see requester-bound approvals", () => {
const manager = new ExecApprovalManager();
const record = manager.create(
@@ -363,7 +422,7 @@ describe("handlePendingApprovalRequest", () => {
]),
),
hasExecApprovalClients: vi.fn(() => {
throw new Error("expected targeted approval client lookup");
throw new Error("expected visibility-filtered approval client lookup");
}),
} as unknown as GatewayRequestContext,
clientConnId: "conn-requester",
@@ -391,6 +450,75 @@ describe("handlePendingApprovalRequest", () => {
await requestPromise;
});
it("routes backend-runtime approval events to the authorized approval reviewer device", async () => {
const manager = new ExecApprovalManager();
const record = manager.create(
{
command: "echo ok",
},
60_000,
"approval-reviewer-device-event",
);
record.requestedByDeviceId = "device-gateway-runtime";
record.requestedByConnId = "conn-gateway-runtime";
record.requestedByClientId = GATEWAY_CLIENT_IDS.GATEWAY_CLIENT;
bindApprovalReviewerDeviceIds({ record, deviceIds: ["device-mobile"] });
const decisionPromise = manager.register(record, 60_000);
const respond = vi.fn();
const broadcast = vi.fn();
const broadcastToConnIds = vi.fn();
const visibleConnIds = new Set(["conn-mobile-approval"]);
const requestPromise = handlePendingApprovalRequest({
manager,
record,
decisionPromise,
respond,
context: {
broadcast,
broadcastToConnIds,
getApprovalClientConnIds: vi.fn(
createApprovalClientLookup([
createApprovalClient({
connId: "conn-mobile-approval",
clientId: GATEWAY_CLIENT_IDS.IOS_APP,
deviceId: "device-mobile",
}),
createApprovalClient({
connId: "conn-other-approval",
clientId: GATEWAY_CLIENT_IDS.IOS_APP,
deviceId: "device-other",
}),
]),
),
hasExecApprovalClients: vi.fn(() => {
throw new Error("expected visibility-filtered approval client lookup");
}),
} as unknown as GatewayRequestContext,
clientConnId: "conn-gateway-runtime",
requestEventName: "exec.approval.requested",
requestEvent: {
id: record.id,
request: record.request,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
},
twoPhase: true,
deliverRequest: () => false,
});
await Promise.resolve();
expect(broadcast).not.toHaveBeenCalled();
expect(broadcastToConnIds).toHaveBeenCalledWith(
"exec.approval.requested",
expect.objectContaining({ id: "approval-reviewer-device-event" }),
visibleConnIds,
{ dropIfSlow: true },
);
expect(manager.resolve(record.id, "allow-once")).toBe(true);
await requestPromise;
});
it("targets requester-bound approval events to gateway-client approval runtimes", async () => {
const manager = new ExecApprovalManager();
const record = manager.create(
@@ -431,7 +559,7 @@ describe("handlePendingApprovalRequest", () => {
]),
),
hasExecApprovalClients: vi.fn(() => {
throw new Error("expected targeted approval client lookup");
throw new Error("expected visibility-filtered approval client lookup");
}),
} as unknown as GatewayRequestContext,
clientConnId: "conn-owner",
@@ -498,7 +626,7 @@ describe("handlePendingApprovalRequest", () => {
]),
),
hasExecApprovalClients: vi.fn(() => {
throw new Error("expected targeted approval client lookup");
throw new Error("expected visibility-filtered approval client lookup");
}),
} as unknown as GatewayRequestContext,
clientConnId: "conn-gateway",
@@ -621,7 +749,7 @@ describe("handlePendingApprovalRequest", () => {
]),
),
hasExecApprovalClients: vi.fn(() => {
throw new Error("expected targeted approval client lookup");
throw new Error("expected visibility-filtered approval client lookup");
}),
} as unknown as GatewayRequestContext,
clientConnId: "conn-control-ui",
@@ -696,7 +824,7 @@ describe("handlePendingApprovalRequest", () => {
]),
),
hasExecApprovalClients: vi.fn(() => {
throw new Error("expected targeted approval client lookup");
throw new Error("expected visibility-filtered approval client lookup");
}),
} as unknown as GatewayRequestContext,
clientConnId: "conn-gateway",
@@ -771,7 +899,7 @@ describe("handlePendingApprovalRequest", () => {
]),
),
hasExecApprovalClients: vi.fn(() => {
throw new Error("expected targeted approval client lookup");
throw new Error("expected visibility-filtered approval client lookup");
}),
} as unknown as GatewayRequestContext,
clientConnId: "conn-requester",

View File

@@ -118,6 +118,17 @@ function normalizeApprovalIdentity(value: string | null | undefined): string | n
return normalizeOptionalString(value) ?? null;
}
function normalizeApprovalIdentities(values: readonly string[] | null | undefined): string[] {
const normalized = new Set<string>();
for (const value of values ?? []) {
const identity = normalizeApprovalIdentity(value);
if (identity) {
normalized.add(identity);
}
}
return [...normalized];
}
/** Checks whether a client can observe or resolve an approval record. */
export function isApprovalRecordVisibleToClient<TPayload>(params: {
record: ExecApprovalRecord<TPayload>;
@@ -135,6 +146,14 @@ export function isApprovalRecordVisibleToClient<TPayload>(params: {
return true;
}
const approvalReviewerDeviceIds = normalizeApprovalIdentities(
params.record.approvalReviewerDeviceIds,
);
const clientDeviceId = normalizeApprovalIdentity(params.client?.connect?.device?.id);
if (hasApprovalsScope && clientDeviceId && approvalReviewerDeviceIds.includes(clientDeviceId)) {
return true;
}
// Device identity is the strongest requester binding; fall back to the
// live connection only for older callers that have not attached a device.
if (requestedByDeviceId) {
@@ -187,6 +206,16 @@ export function bindApprovalRequesterMetadata<TPayload>(params: {
params.record.requestedByDeviceTokenAuth = params.client?.isDeviceTokenAuth === true;
}
export function bindApprovalReviewerDeviceIds<TPayload>(params: {
record: ExecApprovalRecord<TPayload>;
deviceIds?: readonly string[] | null;
}): void {
const deviceIds = normalizeApprovalIdentities(params.deviceIds);
if (deviceIds.length > 0) {
params.record.approvalReviewerDeviceIds = deviceIds;
}
}
/** Registers an approval record and converts manager registration errors to gateway errors. */
export function registerPendingApprovalRecord<TPayload>(params: {
manager: ExecApprovalManager<TPayload>;

View File

@@ -3681,6 +3681,7 @@ export const chatHandlers: GatewayRequestHandlers = {
body: commandBody,
},
MessageSid: clientRunId,
ApprovalReviewerDeviceId: normalizeOptionalText(client?.connect?.device?.id),
...(!isOperatorUiClient(clientInfo)
? {
SenderId: clientInfo?.id,

View File

@@ -36,6 +36,7 @@ import {
handleApprovalWaitDecision,
handlePendingApprovalRequest,
bindApprovalRequesterMetadata,
bindApprovalReviewerDeviceIds,
buildRequestedApprovalEvent,
handleApprovalResolve,
isApprovalRecordVisibleToClient,
@@ -181,6 +182,7 @@ export function createExecApprovalHandlers(
turnSourceTo?: string;
turnSourceAccountId?: string;
turnSourceThreadId?: string | number;
approvalReviewerDeviceIds?: string[];
requireDeliveryRoute?: boolean;
suppressDelivery?: boolean;
timeoutMs?: number;
@@ -336,6 +338,12 @@ export function createExecApprovalHandlers(
};
const record = manager.create(request, timeoutMs, explicitId);
bindApprovalRequesterMetadata({ record, client });
if (client?.internal?.approvalRuntime === true) {
bindApprovalReviewerDeviceIds({
record,
deviceIds: p.approvalReviewerDeviceIds,
});
}
// Use register() to synchronously add to pending map before sending any response.
// This ensures the approval ID is valid immediately after the "accepted" response.
const decisionPromise = registerPendingApprovalRecord({

View File

@@ -8,6 +8,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { GATEWAY_CLIENT_IDS } from "../../../packages/gateway-protocol/src/client-info.js";
import { validateExecApprovalRequestParams } from "../../../packages/gateway-protocol/src/index.js";
import { STREAM_ERROR_FALLBACK_TEXT } from "../../agents/stream-message-shared.js";
import { HEARTBEAT_PROMPT } from "../../auto-reply/heartbeat.js";
@@ -2366,6 +2367,24 @@ describe("exec approval handlers", () => {
timeoutMs: 2000,
} as const;
function createExecApprovalClient(params: {
connId: string;
clientId: string;
deviceId?: string;
scopes?: string[];
approvalRuntime?: boolean;
}): ExecApprovalRequestArgs["client"] {
return {
connId: params.connId,
connect: {
client: { id: params.clientId },
device: params.deviceId ? { id: params.deviceId } : undefined,
scopes: params.scopes,
},
...(params.approvalRuntime ? { internal: { approvalRuntime: true } } : {}),
} as unknown as ExecApprovalRequestArgs["client"];
}
function toExecApprovalRequestContext(context: {
broadcast: (event: string, payload: unknown) => void;
hasExecApprovalClients?: () => boolean;
@@ -2871,6 +2890,138 @@ describe("exec approval handlers", () => {
expect(otherRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
});
it("ignores approval reviewer devices from non-runtime approval request clients", async () => {
const { manager, handlers, respond, context } = createExecApprovalFixture();
const requesterClient = createExecApprovalClient({
connId: "conn-gateway-client",
clientId: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT,
deviceId: "device-gateway-runtime",
scopes: ["operator.approvals"],
});
const reviewerClient = createExecApprovalClient({
connId: "conn-ios-reviewer",
clientId: GATEWAY_CLIENT_IDS.IOS_APP,
deviceId: "device-ios-reviewer",
scopes: ["operator.approvals"],
});
const requestPromise = requestExecApproval({
handlers,
respond,
context,
client: requesterClient,
params: {
id: "approval-reviewer-untrusted",
twoPhase: true,
approvalReviewerDeviceIds: ["device-ios-reviewer"],
},
});
await vi.waitFor(() => {
expect(respond.mock.calls.some((call) => call[1]?.status === "accepted")).toBe(true);
});
expect(
manager.getSnapshot("approval-reviewer-untrusted")?.approvalReviewerDeviceIds,
).toBeUndefined();
const listRespond = vi.fn();
await listExecApprovals({
handlers,
respond: listRespond,
client: reviewerClient,
});
expect(mockCallArg(listRespond)).toBe(true);
expect(mockCallArg(listRespond, 0, 1)).toEqual([]);
const getRespond = vi.fn();
await getExecApproval({
handlers,
id: "approval-reviewer-untrusted",
respond: getRespond,
client: reviewerClient,
});
expect(mockCallArg(getRespond)).toBe(false);
expectRecordFields(mockCallArg(getRespond, 0, 2), {
code: "INVALID_REQUEST",
message: "unknown or expired approval id",
});
expect(manager.resolve("approval-reviewer-untrusted", "deny")).toBe(true);
await requestPromise;
});
it("allows the internal approval runtime to bind the initiating mobile approval reviewer device", async () => {
const { manager, handlers, respond, context } = createExecApprovalFixture();
const requesterClient = createExecApprovalClient({
connId: "conn-gateway-runtime",
clientId: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT,
deviceId: "device-gateway-runtime",
scopes: ["operator.approvals"],
approvalRuntime: true,
});
const reviewerClient = createExecApprovalClient({
connId: "conn-ios-reviewer",
clientId: GATEWAY_CLIENT_IDS.IOS_APP,
deviceId: "device-ios-reviewer",
scopes: ["operator.approvals"],
});
const requestPromise = requestExecApproval({
handlers,
respond,
context,
client: requesterClient,
params: {
id: "approval-reviewer-runtime",
twoPhase: true,
approvalReviewerDeviceIds: ["device-ios-reviewer"],
},
});
await vi.waitFor(() => {
expect(respond.mock.calls.some((call) => call[1]?.status === "accepted")).toBe(true);
});
expect(manager.getSnapshot("approval-reviewer-runtime")?.approvalReviewerDeviceIds).toEqual([
"device-ios-reviewer",
]);
const listRespond = vi.fn();
await listExecApprovals({
handlers,
respond: listRespond,
client: reviewerClient,
});
expect(mockCallArg(listRespond)).toBe(true);
const approvals = mockCallArg(listRespond, 0, 1) as Array<Record<string, unknown>>;
expect(approvals.map((entry) => entry.id)).toEqual(["approval-reviewer-runtime"]);
const getRespond = vi.fn();
await getExecApproval({
handlers,
id: "approval-reviewer-runtime",
respond: getRespond,
client: reviewerClient,
});
expect(mockCallArg(getRespond)).toBe(true);
expectRecordFields(mockCallArg(getRespond, 0, 1), {
id: "approval-reviewer-runtime",
commandText: "echo ok",
});
const resolveRespond = vi.fn();
await resolveExecApproval({
handlers,
id: "approval-reviewer-runtime",
respond: resolveRespond,
context,
client: reviewerClient,
});
await requestPromise;
expect(resolveRespond).toHaveBeenCalledWith(true, { ok: true }, undefined);
expect(manager.getSnapshot("approval-reviewer-runtime")?.decision).toBe("allow-once");
});
it("returns not found for stale exec.approval.get ids", async () => {
const { handlers, respond, context } = createExecApprovalFixture();

View File

@@ -2,7 +2,6 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
import { makeTempDir } from "./exec-approvals-test-helpers.js";
const requestJsonlSocketMock = vi.hoisted(() => vi.fn());
@@ -34,7 +33,8 @@ let resolveExecApprovalsTranscriptPath: ExecApprovalsModule["resolveExecApproval
let saveExecApprovals: ExecApprovalsModule["saveExecApprovals"];
const tempDirs: string[] = [];
const testEnvSnapshot = captureEnv(["OPENCLAW_HOME", "OPENCLAW_STATE_DIR"]);
const originalOpenClawHome = process.env.OPENCLAW_HOME;
const originalOpenClawStateDir = process.env.OPENCLAW_STATE_DIR;
beforeAll(async () => {
({
@@ -64,7 +64,16 @@ beforeEach(() => {
afterEach(() => {
vi.restoreAllMocks();
testEnvSnapshot.restore();
if (originalOpenClawHome === undefined) {
delete process.env.OPENCLAW_HOME;
} else {
process.env.OPENCLAW_HOME = originalOpenClawHome;
}
if (originalOpenClawStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = originalOpenClawStateDir;
}
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
@@ -73,8 +82,8 @@ afterEach(() => {
function createHomeDir(): string {
const dir = makeTempDir();
tempDirs.push(dir);
setTestEnvValue("OPENCLAW_HOME", dir);
deleteTestEnvValue("OPENCLAW_STATE_DIR");
process.env.OPENCLAW_HOME = dir;
delete process.env.OPENCLAW_STATE_DIR;
return dir;
}
@@ -135,7 +144,7 @@ describe("exec approvals store helpers", () => {
it("uses OPENCLAW_STATE_DIR for default file and socket paths", () => {
const dir = createHomeDir();
const stateDir = path.join(dir, "custom-state");
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
process.env.OPENCLAW_STATE_DIR = stateDir;
expect(path.normalize(resolveExecApprovalsPath())).toBe(
path.normalize(stateApprovalsFilePath(stateDir)),
@@ -173,7 +182,7 @@ describe("exec approvals store helpers", () => {
})}\n`,
"utf8",
);
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
process.env.OPENCLAW_STATE_DIR = stateDir;
const resolved = resolveExecApprovals("main", {
security: "full",
@@ -648,7 +657,7 @@ describe("exec approvals store helpers", () => {
const linkedHome = `${realHome}-link`;
tempDirs.push(realHome, linkedHome);
fs.symlinkSync(realHome, linkedHome, "dir");
setTestEnvValue("OPENCLAW_HOME", linkedHome);
process.env.OPENCLAW_HOME = linkedHome;
saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} });
@@ -665,7 +674,7 @@ describe("exec approvals store helpers", () => {
fs.mkdirSync(linkedStateTarget, { recursive: true });
fs.symlinkSync(realHome, linkedHome, "dir");
fs.symlinkSync(linkedStateTarget, path.join(realHome, ".openclaw"), "dir");
setTestEnvValue("OPENCLAW_HOME", linkedHome);
process.env.OPENCLAW_HOME = linkedHome;
expect(() =>
saveExecApprovals({ version: 1, defaults: { security: "full" }, agents: {} }),

View File

@@ -4,7 +4,6 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
import {
clearCurrentPluginMetadataSnapshot,
resolvePluginMetadataControlPlaneFingerprint,
@@ -20,16 +19,56 @@ import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.js";
import { createEmptyPluginRegistry } from "./registry-empty.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "./runtime.js";
const ORIGINAL_ENV = {
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
OPENCLAW_HOME: process.env.OPENCLAW_HOME,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS,
OPENCLAW_BUNDLED_PLUGINS_DIR: process.env.OPENCLAW_BUNDLED_PLUGINS_DIR,
} as const;
const tempDirs: string[] = [];
const testEnvSnapshot = captureEnv([
"OPENCLAW_STATE_DIR",
"OPENCLAW_HOME",
"OPENCLAW_DISABLE_BUNDLED_PLUGINS",
"OPENCLAW_BUNDLED_PLUGINS_DIR",
]);
function restoreOpenClawStateDirEnv(): void {
const value = ORIGINAL_ENV.OPENCLAW_STATE_DIR;
if (value === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = value;
}
}
function restoreOpenClawHomeEnv(): void {
const value = ORIGINAL_ENV.OPENCLAW_HOME;
if (value === undefined) {
delete process.env.OPENCLAW_HOME;
} else {
process.env.OPENCLAW_HOME = value;
}
}
function restoreOpenClawDisableBundledPluginsEnv(): void {
const value = ORIGINAL_ENV.OPENCLAW_DISABLE_BUNDLED_PLUGINS;
if (value === undefined) {
delete process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS;
} else {
process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = value;
}
}
function restoreOpenClawBundledPluginsDirEnv(): void {
const value = ORIGINAL_ENV.OPENCLAW_BUNDLED_PLUGINS_DIR;
if (value === undefined) {
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
} else {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = value;
}
}
function restoreEnv(): void {
testEnvSnapshot.restore();
restoreOpenClawStateDirEnv();
restoreOpenClawHomeEnv();
restoreOpenClawDisableBundledPluginsEnv();
restoreOpenClawBundledPluginsDirEnv();
}
function makeTempDir(): string {
@@ -304,10 +343,10 @@ describe("manifest model id normalization", () => {
writeInstallIndex({ stateDir: stateDirA, pluginDir: pluginDirA });
writeNormalizerManifest({ pluginDir: pluginDirA, prefix: "alpha" });
setTestEnvValue("OPENCLAW_STATE_DIR", stateDirA);
deleteTestEnvValue("OPENCLAW_HOME");
setTestEnvValue("OPENCLAW_DISABLE_BUNDLED_PLUGINS", "1");
deleteTestEnvValue("OPENCLAW_BUNDLED_PLUGINS_DIR");
process.env.OPENCLAW_STATE_DIR = stateDirA;
process.env.OPENCLAW_HOME = undefined;
process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = "1";
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = undefined;
expect(normalizeDemoModel()).toBe("alpha/demo-model");
@@ -319,7 +358,7 @@ describe("manifest model id normalization", () => {
writeInstallIndex({ stateDir: stateDirB, pluginDir: pluginDirB });
writeNormalizerManifest({ pluginDir: pluginDirB, prefix: "charlie" });
setTestEnvValue("OPENCLAW_STATE_DIR", stateDirB);
process.env.OPENCLAW_STATE_DIR = stateDirB;
clearPluginMetadataLifecycleCaches();
expect(normalizeDemoModel()).toBe("charlie/demo-model");
});
@@ -331,10 +370,10 @@ describe("manifest model id normalization", () => {
writeInstallIndex({ stateDir, pluginDir });
writeNormalizerManifest({ pluginDir, prefix: "alpha" });
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
deleteTestEnvValue("OPENCLAW_HOME");
setTestEnvValue("OPENCLAW_DISABLE_BUNDLED_PLUGINS", "1");
deleteTestEnvValue("OPENCLAW_BUNDLED_PLUGINS_DIR");
process.env.OPENCLAW_STATE_DIR = stateDir;
process.env.OPENCLAW_HOME = undefined;
process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = "1";
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = undefined;
const readFileSyncSpy = vi.spyOn(fs, "readFileSync");

View File

@@ -885,6 +885,7 @@ describe("test-projects args", () => {
"test/scripts/ios-team-id.test.ts",
"test/scripts/ios-version.test.ts",
"test/scripts/kitchen-sink-rpc-walk.test.ts",
"test/scripts/openai-chat-tools-client.test.ts",
"test/scripts/report-test-temp-creations.test.ts",
"test/scripts/test-projects.test.ts",
"test/test-env.test.ts",
@@ -911,7 +912,6 @@ describe("test-projects args", () => {
config: "test/vitest/vitest.e2e.config.ts",
forwardedArgs: [
"test/e2e/qa-lab/plugins/plugin-lifecycle-probe.e2e.test.ts",
"test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts",
"test/openclaw-launcher.e2e.test.ts",
],
includePatterns: null,

View File

@@ -1,12 +1,15 @@
// Compact skill path tests cover short path formatting for skill prompt payloads.
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withEnv } from "../../test-utils/env.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createCanonicalFixtureSkill } from "../test-support/test-helpers.js";
import { testing as workspaceSkillsTesting, buildWorkspaceSkillsPrompt } from "./workspace.js";
describe("compactSkillPaths", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
function buildPromptForFixtureSkill(params: {
workspaceRoot: string;
skillDir: string;
@@ -60,21 +63,17 @@ describe("compactSkillPaths", () => {
const skillDir = path.join(stateDir, "skills", "world-cup-soccer-openclaw-skill");
const skillFile = path.join(skillDir, "SKILL.md");
const prompt = withEnv(
{
HOME: osHome,
OPENCLAW_HOME: osHome,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
},
() =>
buildPromptForFixtureSkill({
workspaceRoot: path.join(root, "workspace"),
skillDir,
name: "world-cup-soccer-openclaw-skill",
description: "World Cup standings lookup",
}),
);
vi.stubEnv("HOME", osHome);
vi.stubEnv("OPENCLAW_HOME", osHome);
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(stateDir, "openclaw.json"));
const prompt = buildPromptForFixtureSkill({
workspaceRoot: path.join(root, "workspace"),
skillDir,
name: "world-cup-soccer-openclaw-skill",
description: "World Cup standings lookup",
});
expect(prompt).toContain(`<location>${skillFile}</location>`);
expect(prompt).not.toContain("~/.openclaw/skills/world-cup-soccer-openclaw-skill/SKILL.md");
@@ -87,21 +86,17 @@ describe("compactSkillPaths", () => {
const skillDir = path.join(stateDir, "plugin-skills", "calendar-plugin-skill");
const skillFile = path.join(skillDir, "SKILL.md");
const prompt = withEnv(
{
HOME: osHome,
OPENCLAW_HOME: osHome,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_CONFIG_PATH: path.join(stateDir, "openclaw.json"),
},
() =>
buildPromptForFixtureSkill({
workspaceRoot: path.join(root, "workspace"),
skillDir,
name: "calendar-plugin-skill",
description: "Calendar plugin skill",
}),
);
vi.stubEnv("HOME", osHome);
vi.stubEnv("OPENCLAW_HOME", osHome);
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(stateDir, "openclaw.json"));
const prompt = buildPromptForFixtureSkill({
workspaceRoot: path.join(root, "workspace"),
skillDir,
name: "calendar-plugin-skill",
description: "Calendar plugin skill",
});
expect(prompt).toContain(`<location>${skillFile}</location>`);
expect(prompt).not.toContain("~/.openclaw/plugin-skills/calendar-plugin-skill/SKILL.md");
@@ -112,20 +107,16 @@ describe("compactSkillPaths", () => {
const stateDir = path.join(home, ".openclaw");
const skillDir = path.join(stateDir, "skills", "home-managed-skill");
const prompt = withEnv(
{
HOME: home,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_HOME: undefined,
},
() =>
buildPromptForFixtureSkill({
workspaceRoot: path.join(home, "workspace"),
skillDir,
name: "home-managed-skill",
description: "Home managed skill",
}),
);
vi.stubEnv("HOME", home);
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.stubEnv("OPENCLAW_HOME", undefined);
const prompt = buildPromptForFixtureSkill({
workspaceRoot: path.join(home, "workspace"),
skillDir,
name: "home-managed-skill",
description: "Home managed skill",
});
expect(prompt).toContain("<location>~/.openclaw/skills/home-managed-skill/SKILL.md</location>");
expect(prompt).not.toContain(`<location>${path.join(skillDir, "SKILL.md")}</location>`);

View File

@@ -1,7 +1,6 @@
// Home environment test support isolates HOME-style paths for skill tests.
import os from "node:os";
import { vi } from "vitest";
import { deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js";
/** Process home env snapshot used by skill loader tests. */
export type SkillsHomeEnvSnapshot = {
@@ -16,28 +15,32 @@ export function setMockSkillsHomeEnv(fakeHome: string): SkillsHomeEnvSnapshot {
previousOpenClawHome: process.env.OPENCLAW_HOME,
previousUserProfile: process.env.USERPROFILE,
};
setTestEnvValue("HOME", fakeHome);
deleteTestEnvValue("OPENCLAW_HOME");
deleteTestEnvValue("USERPROFILE");
process.env.HOME = fakeHome;
delete process.env.OPENCLAW_HOME;
delete process.env.USERPROFILE;
vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
return snapshot;
}
function restoreEnvValue(key: string, value: string | undefined): void {
if (value === undefined) {
deleteTestEnvValue(key);
} else {
setTestEnvValue(key, value);
}
}
export async function restoreMockSkillsHomeEnv(
snapshot: SkillsHomeEnvSnapshot,
cleanup?: () => Promise<void> | void,
) {
vi.restoreAllMocks();
restoreEnvValue("HOME", snapshot.previousHome);
restoreEnvValue("OPENCLAW_HOME", snapshot.previousOpenClawHome);
restoreEnvValue("USERPROFILE", snapshot.previousUserProfile);
if (snapshot.previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = snapshot.previousHome;
}
if (snapshot.previousOpenClawHome === undefined) {
delete process.env.OPENCLAW_HOME;
} else {
process.env.OPENCLAW_HOME = snapshot.previousOpenClawHome;
}
if (snapshot.previousUserProfile === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = snapshot.previousUserProfile;
}
await cleanup?.();
}

View File

@@ -355,25 +355,6 @@ function normalizeShellLineEndings(value: string): string {
return value.replace(/\r\n/g, "\n");
}
function testCrabboxConfigDir(home: string): string {
if (process.platform === "darwin") {
return path.join(home, "Library", "Application Support", "crabbox");
}
if (process.platform === "win32") {
return path.join(home, "AppData", "Roaming", "crabbox");
}
return path.join(home, ".config", "crabbox");
}
function testHomeEnv(home: string): Record<string, string> {
return {
APPDATA: path.join(home, "AppData", "Roaming"),
HOME: home,
USERPROFILE: home,
XDG_CONFIG_HOME: path.join(home, ".config"),
};
}
function expectGroupedShellCommand(remoteCommand: string, command: string): void {
expect(remoteCommand).toContain(`&& { ${command}`);
if (process.platform !== "win32") {
@@ -509,70 +490,6 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
expect(parseFakeCrabboxOutput(result).args).toContain("blacksmith-testbox");
});
it("rejects reused Blacksmith Testboxes that were not created by Crabbox", () => {
const home = mkdtempSync(path.join(tmpdir(), "openclaw-crabbox-home-"));
tempDirs.push(home);
const result = runWrapper(
"provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n",
["run", "--provider", "blacksmith-testbox", "--id", "tbx_direct", "--", "echo ok"],
{ env: testHomeEnv(home) },
);
expect(result.status).toBe(2);
expect(result.stdout).toBe("");
expect(result.stderr).toContain("provider=blacksmith-testbox --id tbx_direct");
expect(result.stderr).toContain("has no Crabbox SSH key");
expect(result.stderr).toContain("direct `blacksmith testbox warmup` leases");
});
it("allows reused Blacksmith Testboxes when the Crabbox SSH key exists", () => {
const home = mkdtempSync(path.join(tmpdir(), "openclaw-crabbox-home-"));
tempDirs.push(home);
const keyPath = path.join(testCrabboxConfigDir(home), "testboxes", "tbx_owned", "id_ed25519");
mkdirSync(path.dirname(keyPath), { recursive: true });
writeFileSync(keyPath, "fake test key\n", "utf8");
const result = runWrapper(
"provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n",
["run", "--provider", "blacksmith-testbox", "--id", "tbx_owned", "--", "echo ok"],
{ env: testHomeEnv(home) },
);
expect(result.status).toBe(0);
expect(parseFakeCrabboxOutput(result).args).toEqual([
"run",
"--provider",
"blacksmith-testbox",
"--id",
"tbx_owned",
"--",
"echo ok",
]);
});
it("lets Crabbox resolve reusable Testbox slugs", () => {
const home = mkdtempSync(path.join(tmpdir(), "openclaw-crabbox-home-"));
tempDirs.push(home);
const result = runWrapper(
"provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n",
["run", "--provider", "blacksmith-testbox", "--id", "blue-hermit", "--", "echo ok"],
{ env: testHomeEnv(home) },
);
expect(result.status).toBe(0);
expect(parseFakeCrabboxOutput(result).args).toEqual([
"run",
"--provider",
"blacksmith-testbox",
"--id",
"blue-hermit",
"--",
"echo ok",
]);
});
it("only forces the short local-container Docker work root on Linux", () => {
const result = runWrapper(
"provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n",

View File

@@ -860,31 +860,6 @@ describe("kitchen-sink RPC payload unwrapping", () => {
});
});
it("preserves gateway request error metadata from built RPC calls", () => {
const error = captureSyncError(() =>
unwrapRpcPayload({
ok: false,
error: {
type: "gateway_request_error",
code: "INVALID_REQUEST",
message: "unauthorized role: operator",
details: { method: "skills.bins" },
retryable: false,
retryAfterMs: 250,
},
}),
);
expect(error).toMatchObject({
name: "GatewayClientRequestError",
message: "unauthorized role: operator",
gatewayCode: "INVALID_REQUEST",
details: { method: "skills.bins" },
retryable: false,
retryAfterMs: 250,
});
});
it("bounds failed RPC payload diagnostics", () => {
const error = captureSyncError(() =>
unwrapRpcPayload({
@@ -1008,19 +983,6 @@ describe("kitchen-sink RPC command catalog assertions", () => {
});
}),
).resolves.toBeUndefined();
await expect(
assertOperatorRpcDenied({ method: "skills.bins", params: {} }, async () =>
unwrapRpcPayload({
ok: false,
error: {
type: "gateway_request_error",
code: "INVALID_REQUEST",
message: "unauthorized role: operator",
retryable: false,
},
}),
),
).resolves.toBeUndefined();
await expect(
assertOperatorRpcDenied({ method: "skills.bins", params: {} }, async () => {
throw new Error(

View File

@@ -1,12 +1,12 @@
// OpenAI-compatible chat tools tests cover QA Lab HTTP tool-call evidence.
// Openai Chat Tools Client tests cover openai chat tools client script behavior.
import { spawn, spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createServer, type Server } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
import { createBoundedChildOutput } from "../../../helpers/bounded-child-output.js";
import { cleanupTempDirs, makeTempDir } from "../../../helpers/temp-dir.js";
import { createBoundedChildOutput } from "../helpers/bounded-child-output.js";
import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js";
const clientPath = path.resolve("scripts/e2e/lib/openai-chat-tools/client.mjs");
const dockerRunnerPath = path.resolve("scripts/e2e/openai-chat-tools-docker.sh");
@@ -14,6 +14,7 @@ const writeConfigPath = path.resolve("scripts/e2e/lib/openai-chat-tools/write-co
interface ClientResult {
error?: Error;
signal: NodeJS.Signals | null;
status: number | null;
stderr: string;
stdout: string;
@@ -68,12 +69,13 @@ function runClient(
}, timeout);
child.on("error", (error) => {
clearTimeout(timer);
resolve({ error, status: null, stderr: stderr.text(), stdout: stdout.text() });
resolve({ error, signal: null, status: null, stderr: stderr.text(), stdout: stdout.text() });
});
child.on("exit", (status) => {
child.on("exit", (status, signal) => {
clearTimeout(timer);
resolve({
error: timedOut ? new Error(`client timed out after ${timeout}ms`) : undefined,
signal,
status,
stderr: stderr.text(),
stdout: stdout.text(),

View File

@@ -1,4 +1,4 @@
// OpenAI web-search minimal assertion tests cover QA Lab native web_search evidence.
// Openai Web Search Minimal Assertions tests cover openai web search minimal assertions script behavior.
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";

View File

@@ -1,6 +1,6 @@
// OpenAI web-search minimal tests cover QA Lab hosted provider schema evidence.
// Openai Web Search Minimal Client tests cover openai web search minimal client script behavior.
import { describe, expect, it } from "vitest";
import { testing } from "../../../../scripts/e2e/lib/openai-web-search-minimal/client.mjs";
import { testing } from "../../scripts/e2e/lib/openai-web-search-minimal/client.mjs";
describe("scripts/e2e/lib/openai-web-search-minimal/client.mjs", () => {
it("accepts only the expected raw schema rejection in reject mode", () => {

View File

@@ -1,16 +1,17 @@
// OpenWebUI probe tests cover QA Lab OpenAI-compatible API evidence.
// Openwebui Probe tests cover openwebui probe script behavior.
import { spawn } from "node:child_process";
import { readFileSync } from "node:fs";
import { createServer, type IncomingMessage, type Server as HttpServer } from "node:http";
import { createServer as createTcpServer, type Server as TcpServer, type Socket } from "node:net";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { createBoundedChildOutput } from "../../../helpers/bounded-child-output.js";
import { createBoundedChildOutput } from "../helpers/bounded-child-output.js";
const probePath = path.resolve("scripts/e2e/openwebui-probe.mjs");
interface ProbeResult {
error?: Error;
signal: NodeJS.Signals | null;
status: number | null;
stderr: string;
stdout: string;
@@ -66,12 +67,13 @@ function runProbe(baseUrl: string, env: Record<string, string> = {}, timeout = 3
}, timeout);
child.on("error", (error) => {
clearTimeout(timer);
resolve({ error, status: null, stderr: stderr.text(), stdout: stdout.text() });
resolve({ error, signal: null, status: null, stderr: stderr.text(), stdout: stdout.text() });
});
child.on("exit", (status) => {
child.on("exit", (status, signal) => {
clearTimeout(timer);
resolve({
error: timedOut ? new Error(`probe timed out after ${timeout}ms`) : undefined,
signal,
status,
stderr: stderr.text(),
stdout: stdout.text(),
@@ -219,38 +221,6 @@ describe("scripts/e2e/openwebui-probe.mjs", () => {
}
});
it("redacts admin credentials from sign-in error bodies", async () => {
const adminEmail = "openwebui-e2e" + "@example.com";
const server = createServer((request, response) => {
if (request.url === "/api/v1/auths/signin") {
response.writeHead(401, { "content-type": "application/json" });
response.end(
JSON.stringify({
error: "invalid credentials",
email: adminEmail,
password: 'pa"ss',
}),
);
return;
}
response.writeHead(404).end();
});
const baseUrl = await listen(server);
try {
const result = await runProbe(baseUrl, { OPENWEBUI_ADMIN_PASSWORD: 'pa"ss' });
expect(result.error).toBeUndefined();
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("signin failed: HTTP 401");
expect(result.stderr).toContain("<redacted>");
expect(result.stderr).not.toContain(adminEmail);
expect(result.stderr).not.toContain('pa"ss');
expect(result.stderr).not.toContain('pa\\"ss');
} finally {
server.close();
}
});
it("bounds model-list error response bodies", async () => {
const server = createServer((request, response) => {
if (request.url === "/api/v1/auths/signin") {
@@ -285,43 +255,6 @@ describe("scripts/e2e/openwebui-probe.mjs", () => {
}
});
it("redacts auth material from model-list error bodies", async () => {
const server = createServer((request, response) => {
if (request.url === "/api/v1/auths/signin") {
response.writeHead(200, {
"content-type": "application/json",
"set-cookie": "openwebui-session=model=secret=cookie; Path=/",
});
response.end(JSON.stringify({ token: "model-secret-token" }));
return;
}
if (request.url === "/api/models") {
response.writeHead(502, { "content-type": "application/json" });
response.end(
JSON.stringify({
error: "upstream rejected Authorization Bearer model-secret-token",
cookieValue: "model=secret=cookie",
}),
);
return;
}
response.writeHead(404).end();
});
const baseUrl = await listen(server);
try {
const result = await runProbe(baseUrl);
expect(result.error).toBeUndefined();
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("HTTP 502");
expect(result.stderr).toContain("<redacted>");
expect(result.stderr).not.toContain("model-secret-token");
expect(result.stderr).not.toContain("model=secret=cookie");
} finally {
server.close();
}
});
it("does not sleep after the final model-list attempt", async () => {
const server = createServer((request, response) => {
if (request.url === "/api/v1/auths/signin") {

View File

@@ -294,7 +294,7 @@ describe("scripts/test-projects changed-target routing", () => {
it("routes nested scripts through conventional owner tests", () => {
expect(resolveChangedTestTargetPlan(["scripts/e2e/openwebui-probe.mjs"])).toEqual({
mode: "targets",
targets: ["test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts"],
targets: ["test/scripts/openwebui-probe.test.ts"],
});
expect(resolveChangedTestTargetPlan(["scripts/lib/docker-e2e-plan.mjs"])).toEqual({
mode: "targets",
@@ -374,24 +374,21 @@ describe("scripts/test-projects changed-target routing", () => {
],
[
"scripts/e2e/lib/openai-chat-tools/write-config.mjs",
["test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts"],
["test/scripts/openai-chat-tools-client.test.ts"],
],
[
"scripts/e2e/lib/openai-chat-tools/scenario.sh",
["test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts"],
["test/scripts/openai-chat-tools-client.test.ts"],
],
[
"scripts/e2e/openai-chat-tools-docker.sh",
[
"test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts",
"test/scripts/docker-e2e-plan.test.ts",
],
["test/scripts/openai-chat-tools-client.test.ts", "test/scripts/docker-e2e-plan.test.ts"],
],
[
"scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs",
[
"test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts",
"test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts",
"test/scripts/openai-web-search-minimal-client.test.ts",
"test/scripts/openai-web-search-minimal-assertions.test.ts",
],
],
[
@@ -399,8 +396,8 @@ describe("scripts/test-projects changed-target routing", () => {
[
"test/scripts/docker-build-helper.test.ts",
"test/scripts/docker-e2e-plan.test.ts",
"test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts",
"test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts",
"test/scripts/openai-web-search-minimal-client.test.ts",
"test/scripts/openai-web-search-minimal-assertions.test.ts",
],
],
[
@@ -408,14 +405,11 @@ describe("scripts/test-projects changed-target routing", () => {
[
"test/scripts/docker-build-helper.test.ts",
"test/scripts/docker-e2e-plan.test.ts",
"test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts",
"test/scripts/openwebui-probe.test.ts",
"test/scripts/fixture-config.test.ts",
],
],
[
"scripts/e2e/lib/openwebui/http-probe.mjs",
["test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts"],
],
["scripts/e2e/lib/openwebui/http-probe.mjs", ["test/scripts/openwebui-probe.test.ts"]],
[
"scripts/e2e/lib/plugins/npm-registry-server.mjs",
["test/scripts/plugins-assertions.test.ts"],