mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-20 22:12:53 +08:00
Compare commits
3 Commits
qa-fold-ht
...
fix/mobile
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85afa3bce2 | ||
|
|
cd2ad47743 | ||
|
|
802d2c21cb |
@@ -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]
|
||||
|
||||
@@ -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 "],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>` : ""}
|
||||
|
||||
21
extensions/qwen/model-definitions.ts
Normal file
21
extensions/qwen/model-definitions.ts
Normal 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";
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }]);
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -72,6 +72,7 @@ export type ExecToolDefaults = {
|
||||
currentChannelId?: string;
|
||||
currentThreadTs?: string;
|
||||
accountId?: string;
|
||||
approvalReviewerDeviceId?: string;
|
||||
notifyOnExit?: boolean;
|
||||
notifyOnExitEmptySuccess?: boolean;
|
||||
cwd?: string;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1296,6 +1296,7 @@ export async function runEmbeddedAttempt(
|
||||
: undefined,
|
||||
sessionId: params.sessionId,
|
||||
runId: params.runId,
|
||||
approvalReviewerDeviceId: params.approvalReviewerDeviceId,
|
||||
oneShotCliRun: params.oneShotCliRun,
|
||||
toolSearchCatalogRef,
|
||||
agentDir,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -3681,6 +3681,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
body: commandBody,
|
||||
},
|
||||
MessageSid: clientRunId,
|
||||
ApprovalReviewerDeviceId: normalizeOptionalText(client?.connect?.device?.id),
|
||||
...(!isOperatorUiClient(clientInfo)
|
||||
? {
|
||||
SenderId: clientInfo?.id,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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: {} }),
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>`);
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
@@ -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";
|
||||
@@ -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", () => {
|
||||
@@ -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") {
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user