diff --git a/qa/scenarios/observability/gateway-stability-runtime.yaml b/qa/scenarios/observability/gateway-stability-runtime.yaml new file mode 100644 index 000000000000..8c0ba79ee6e5 --- /dev/null +++ b/qa/scenarios/observability/gateway-stability-runtime.yaml @@ -0,0 +1,37 @@ +title: Gateway stability runtime and bundle + +scenario: + id: gateway-stability-runtime + surface: observability + category: observability.diagnostic-collection + coverage: + primary: + - observability.bounded-in-process-stability-recorder + - observability.openclaw-gateway-stability + - observability.openclaw-gateway-stability-bundle + objective: Prove the live Gateway stability RPC, bounded recorder, persisted bundle filtering, and export-from-bundle CLI paths as one operator workflow. + successCriteria: + - A real child CLI reads the live Gateway diagnostics.stability RPC through authenticated WebSocket transport. + - The in-process recorder retains only its fixed capacity and reports dropped records after sustained diagnostic load. + - The CLI reads and filters the newest persisted stability bundle without retaining private event identifiers. + - Export-from-bundle writes a shareable zip containing the sanitized stability snapshot. + docsRefs: + - docs/gateway/diagnostics.md + - docs/cli/gateway.md + - docs/gateway/protocol.md + - docs/concepts/qa-e2e-automation.md + codeRefs: + - src/cli/gateway-cli/register.ts + - src/gateway/server-methods/diagnostics.ts + - src/logging/diagnostic-stability.ts + - src/logging/diagnostic-stability-bundle.ts + - test/e2e/qa-lab/runtime/gateway-stability-runtime.ts + - test/e2e/qa-lab/runtime/gateway-stability-runtime.test.ts + execution: + kind: script + path: test/e2e/qa-lab/runtime/gateway-stability-runtime.ts + summary: Exercise live stability RPC, bounded retention, persisted bundle filtering, and support export through the real child CLI. + args: + - --artifact-base + - ${outputDir} + timeoutMs: 240000 diff --git a/qa/scenarios/observability/gateway-support-export.yaml b/qa/scenarios/observability/gateway-support-export.yaml new file mode 100644 index 000000000000..5f66e2b511ee --- /dev/null +++ b/qa/scenarios/observability/gateway-support-export.yaml @@ -0,0 +1,37 @@ +title: Gateway support diagnostics export + +scenario: + id: gateway-support-export + surface: observability + category: observability.diagnostic-collection + coverage: + primary: + - cli.diagnostics-export + - cli.support-safe-redaction + - observability.openclaw-gateway-diagnostics-export + - observability.support-zip-redaction + - observability.support-zip-artifact-safety + objective: Prove the real Gateway diagnostics CLI writes a private, payload-free support zip with bounded log collection and sanitized runtime snapshots. + successCriteria: + - A real child CLI exports diagnostics while connected to an isolated live Gateway. + - The JSON result reports the requested zip path, nonzero size, and a payload-free privacy manifest. + - The zip contains sanitized config, logs, status, health, diagnostics, and summary entries without Gateway or hook credentials. + - The artifact uses private file permissions and every archive entry stays inside the zip root. + docsRefs: + - docs/gateway/diagnostics.md + - docs/cli/gateway.md + - docs/concepts/qa-e2e-automation.md + codeRefs: + - src/cli/gateway-cli/register.ts + - src/logging/diagnostic-support-export.ts + - src/logging/diagnostic-support-bundle.ts + - test/e2e/qa-lab/runtime/gateway-support-export-runtime.ts + - test/e2e/qa-lab/runtime/gateway-support-export-runtime.test.ts + execution: + kind: script + path: test/e2e/qa-lab/runtime/gateway-support-export-runtime.ts + summary: Run the real child CLI against a live isolated Gateway and inspect the resulting support zip privacy and artifact-safety contract. + args: + - --artifact-base + - ${outputDir} + timeoutMs: 240000 diff --git a/test/e2e/qa-lab/runtime/gateway-stability-runtime.test.ts b/test/e2e/qa-lab/runtime/gateway-stability-runtime.test.ts new file mode 100644 index 000000000000..15be4bb5c7e7 --- /dev/null +++ b/test/e2e/qa-lab/runtime/gateway-stability-runtime.test.ts @@ -0,0 +1,40 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../../helpers/temp-dir.js"; +import { runGatewayStabilityRuntime } from "./gateway-stability-runtime.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +describe("gateway stability runtime evidence", () => { + it( + "proves live RPC, bounded retention, bundle filtering, and export", + { timeout: 240_000 }, + async () => { + const artifactBase = tempDirs.make("gateway-stability-runtime-"); + const evidence = await runGatewayStabilityRuntime({ + artifactBase, + repoRoot: process.cwd(), + }); + + const result = evidence.entries[0]?.result; + expect(result?.status, result?.failure?.reason).toBe("pass"); + const summary = JSON.parse( + await fs.readFile(path.join(artifactBase, "gateway-stability-summary.json"), "utf8"), + ) as { + liveRpcCapacity: number; + retainedCount: number; + droppedCount: number; + filteredEvents: number; + bundleReason: string; + supportBytes: number; + }; + expect(summary.liveRpcCapacity).toBe(1000); + expect(summary.retainedCount).toBe(1000); + expect(summary.droppedCount).toBeGreaterThan(0); + expect(summary.filteredEvents).toBe(3); + expect(summary.bundleReason).toBe("qa_gateway_stability"); + expect(summary.supportBytes).toBeGreaterThan(0); + }, + ); +}); diff --git a/test/e2e/qa-lab/runtime/gateway-stability-runtime.ts b/test/e2e/qa-lab/runtime/gateway-stability-runtime.ts new file mode 100644 index 000000000000..749fdfbc97d3 --- /dev/null +++ b/test/e2e/qa-lab/runtime/gateway-stability-runtime.ts @@ -0,0 +1,334 @@ +// QA evidence for live stability RPC, bounded retention, bundles, and support export. +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import JSZip from "jszip"; +import { + emitDiagnosticEvent, + resetDiagnosticEventsForTest, +} from "../../../../src/infra/diagnostic-events.js"; +import { formatErrorMessage } from "../../../../src/infra/errors.js"; +import { + resetDiagnosticStabilityBundleForTest, + writeDiagnosticStabilityBundleSync, +} from "../../../../src/logging/diagnostic-stability-bundle.js"; +import { + getDiagnosticStabilitySnapshot, + resetDiagnosticStabilityRecorderForTest, + startDiagnosticStabilityRecorder, + stopDiagnosticStabilityRecorder, +} from "../../../../src/logging/diagnostic-stability.js"; +import { + createOpenClawTestInstance, + type OpenClawTestInstance, +} from "../../../helpers/openclaw-test-instance.js"; +import { createQaScriptEvidenceWriter } from "./script-evidence.js"; + +const SOURCE_PATH = "test/e2e/qa-lab/runtime/gateway-stability-runtime.ts"; +const SCENARIO_ID = "gateway-stability-runtime"; +const SYNTHETIC_EVENT_COUNT = 1_205; +const PRIVATE_CHAT_ID = "qa-private-stability-chat"; + +export type GatewayStabilityRuntimeOptions = { + artifactBase: string; + repoRoot: string; +}; + +type StabilitySnapshot = { + capacity: number; + count: number; + dropped: number; + events: Array>; +}; + +type StabilityBundleCliResult = { + bundle: { + reason: string; + snapshot: StabilitySnapshot; + }; +}; + +type SupportExportCliResult = { + path: string; + bytes: number; + manifest: { + privacy: { + payloadFree: boolean; + rawLogsIncluded: boolean; + }; + }; +}; + +type GatewayStabilitySummary = { + liveRpcCapacity: number; + liveRpcCount: number; + retainedCount: number; + droppedCount: number; + filteredEvents: number; + bundleReason: string; + supportArchive: string; + supportBytes: number; +}; + +function parseOptions(argv: string[], repoRoot = process.cwd()): GatewayStabilityRuntimeOptions { + let artifactBase: string | undefined; + for (let index = 0; index < argv.length; index += 1) { + const option = argv[index]; + const value = argv[index + 1]; + if (option !== "--artifact-base") { + throw new Error(`unknown argument: ${option}`); + } + if (!value || value.startsWith("--")) { + throw new Error("--artifact-base requires a value"); + } + artifactBase = value; + index += 1; + } + if (!artifactBase) { + throw new Error("--artifact-base is required"); + } + return { artifactBase: path.resolve(repoRoot, artifactBase), repoRoot }; +} + +function parseCliJson( + label: string, + result: Awaited>, +): T { + if (result.code !== 0) { + throw new Error( + `${label} failed with exit ${String(result.code)}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + } + try { + return JSON.parse(result.stdout) as T; + } catch (error) { + throw new Error( + `${label} returned invalid JSON: ${formatErrorMessage(error)}\n${result.stdout}`, + { cause: error }, + ); + } +} + +async function readZipEntries(file: string): Promise> { + const zip = await JSZip.loadAsync(await fs.readFile(file)); + const entries: Record = {}; + for (const [name, entry] of Object.entries(zip.files)) { + if (!entry.dir) { + entries[name] = await entry.async("string"); + } + } + return entries; +} + +function createWriter(options: GatewayStabilityRuntimeOptions) { + return createQaScriptEvidenceWriter({ + artifactBase: options.artifactBase, + logFileName: "gateway-stability-runtime.log", + primaryModel: "gateway/diagnostics-stability", + providerMode: "mock-openai", + repoRoot: options.repoRoot, + target: { + id: SCENARIO_ID, + title: "Gateway stability runtime and bundle", + sourcePath: SOURCE_PATH, + docsRefs: ["docs/gateway/diagnostics.md", "docs/cli/gateway.md"], + codeRefs: [ + SOURCE_PATH, + "src/gateway/server-methods/diagnostics.ts", + "src/logging/diagnostic-stability.ts", + "src/logging/diagnostic-stability-bundle.ts", + "src/cli/gateway-cli/register.ts", + ], + }, + }); +} + +function resetStabilityState(): void { + stopDiagnosticStabilityRecorder(); + resetDiagnosticStabilityRecorderForTest(); + resetDiagnosticEventsForTest(); + resetDiagnosticStabilityBundleForTest(); +} + +function writeBoundedStabilityBundle(stateDir: string) { + resetStabilityState(); + startDiagnosticStabilityRecorder(); + for (let index = 0; index < SYNTHETIC_EVENT_COUNT; index += 1) { + emitDiagnosticEvent({ + type: "webhook.received", + channel: "gateway", + updateType: "qa-stability", + chatId: `${PRIVATE_CHAT_ID}-${index}`, + }); + } + const snapshot = getDiagnosticStabilitySnapshot({ limit: 1000 }); + assert.equal(snapshot.capacity, 1000); + assert.equal(snapshot.count, 1000); + assert.equal(snapshot.events.length, 1000); + assert.equal(snapshot.dropped, SYNTHETIC_EVENT_COUNT - snapshot.capacity); + assert.equal(JSON.stringify(snapshot).includes(PRIVATE_CHAT_ID), false); + + const result = writeDiagnosticStabilityBundleSync({ + reason: "qa_gateway_stability", + stateDir, + }); + if (result.status !== "written") { + throw new Error(`expected stability bundle write, got ${result.status}`); + } + stopDiagnosticStabilityRecorder(); + resetDiagnosticStabilityRecorderForTest(); + resetDiagnosticEventsForTest(); + return { path: result.path, snapshot }; +} + +export async function runGatewayStabilityRuntime(options: GatewayStabilityRuntimeOptions) { + await fs.mkdir(options.artifactBase, { recursive: true }); + const writer = createWriter(options); + const startedAt = Date.now(); + let instance: OpenClawTestInstance | undefined; + try { + instance = await createOpenClawTestInstance({ + name: "qa-gateway-stability", + config: { + diagnostics: { enabled: true }, + }, + }); + await instance.startGateway(); + + const liveSnapshot = parseCliJson( + "gateway stability live RPC", + await instance.cli( + [ + "gateway", + "stability", + "--limit", + "10", + "--url", + instance.url, + "--token", + instance.gatewayToken, + "--timeout", + "10000", + "--json", + ], + { timeoutMs: 120_000 }, + ), + ); + assert.equal(liveSnapshot.capacity, 1000); + assert.ok(liveSnapshot.count <= liveSnapshot.capacity); + assert.ok(liveSnapshot.events.length <= 10); + + const bounded = writeBoundedStabilityBundle(instance.stateDir); + const persistedArtifact = path.join(options.artifactBase, "gateway-stability-bundle.json"); + await fs.copyFile(bounded.path, persistedArtifact); + + const bundleResult = parseCliJson( + "gateway stability persisted bundle", + await instance.cli( + [ + "gateway", + "stability", + "--bundle", + "latest", + "--limit", + "3", + "--type", + "webhook.received", + "--json", + ], + { timeoutMs: 120_000 }, + ), + ); + assert.equal(bundleResult.bundle.reason, "qa_gateway_stability"); + assert.equal(bundleResult.bundle.snapshot.capacity, 1000); + assert.equal(bundleResult.bundle.snapshot.count, 1000); + assert.equal(bundleResult.bundle.snapshot.events.length, 3); + assert.equal(JSON.stringify(bundleResult).includes(PRIVATE_CHAT_ID), false); + + const supportArchive = path.join(options.artifactBase, "gateway-stability-support.zip"); + const exportResult = parseCliJson( + "gateway stability export", + await instance.cli( + [ + "gateway", + "stability", + "--bundle", + "latest", + "--export", + "--output", + supportArchive, + "--url", + instance.url, + "--token", + instance.gatewayToken, + "--timeout", + "10000", + "--json", + ], + { timeoutMs: 120_000 }, + ), + ); + assert.equal(path.resolve(exportResult.path), path.resolve(supportArchive)); + assert.ok(exportResult.bytes > 0); + assert.equal(exportResult.manifest.privacy.payloadFree, true); + assert.equal(exportResult.manifest.privacy.rawLogsIncluded, false); + + const archiveEntries = await readZipEntries(supportArchive); + assert.ok(archiveEntries["stability/latest.json"]); + const archiveText = Object.values(archiveEntries).join("\n"); + assert.equal(archiveText.includes(PRIVATE_CHAT_ID), false); + assert.equal(archiveText.includes(instance.gatewayToken), false); + assert.equal(archiveText.includes(instance.stateDir), false); + + const summary: GatewayStabilitySummary = { + liveRpcCapacity: liveSnapshot.capacity, + liveRpcCount: liveSnapshot.count, + retainedCount: bounded.snapshot.count, + droppedCount: bounded.snapshot.dropped, + filteredEvents: bundleResult.bundle.snapshot.events.length, + bundleReason: bundleResult.bundle.reason, + supportArchive: path.basename(supportArchive), + supportBytes: exportResult.bytes, + }; + const summaryPath = path.join(options.artifactBase, "gateway-stability-summary.json"); + await fs.writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8"); + writer.appendLog( + `gateway-stability: live=${liveSnapshot.count}/${liveSnapshot.capacity} retained=${bounded.snapshot.count} dropped=${bounded.snapshot.dropped}\n`, + ); + return await writer.write({ + artifacts: [ + { kind: "summary", filePath: summaryPath }, + { kind: "summary", filePath: persistedArtifact }, + { kind: "archive", filePath: supportArchive }, + ], + details: `live RPC capacity=${liveSnapshot.capacity}; retained=${bounded.snapshot.count}; dropped=${bounded.snapshot.dropped}; bundle export passed`, + durationMs: Math.max(1, Date.now() - startedAt), + status: "pass", + }); + } catch (error) { + const details = formatErrorMessage(error); + writer.appendLog(`gateway-stability: ${details}\n`); + return await writer.write({ + details, + durationMs: Math.max(1, Date.now() - startedAt), + status: "fail", + }); + } finally { + resetStabilityState(); + await instance?.cleanup(); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + runGatewayStabilityRuntime(parseOptions(process.argv.slice(2))) + .then((evidence) => { + const status = evidence.entries[0]?.result.status; + process.stdout.write(`gateway-stability-runtime: ${status}\n`); + process.exitCode = status === "pass" ? 0 : 1; + }) + .catch((error: unknown) => { + process.stderr.write(`gateway-stability-runtime: ${formatErrorMessage(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/test/e2e/qa-lab/runtime/gateway-support-export-runtime.test.ts b/test/e2e/qa-lab/runtime/gateway-support-export-runtime.test.ts new file mode 100644 index 000000000000..c2414cc4afbb --- /dev/null +++ b/test/e2e/qa-lab/runtime/gateway-support-export-runtime.test.ts @@ -0,0 +1,37 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../../helpers/temp-dir.js"; +import { runGatewaySupportExportRuntime } from "./gateway-support-export-runtime.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +describe("gateway support export runtime evidence", () => { + it("runs the real CLI and writes a private payload-free zip", { timeout: 240_000 }, async () => { + const artifactBase = tempDirs.make("gateway-support-export-"); + const evidence = await runGatewaySupportExportRuntime({ + artifactBase, + repoRoot: process.cwd(), + }); + + const result = evidence.entries[0]?.result; + expect(result?.status, result?.failure?.reason).toBe("pass"); + const summary = JSON.parse( + await fs.readFile(path.join(artifactBase, "gateway-support-export-summary.json"), "utf8"), + ) as { + bytes: number; + entries: string[]; + payloadFree: boolean; + privateMode: boolean; + rawLogsIncluded: boolean; + }; + expect(summary).toMatchObject({ + payloadFree: true, + privateMode: true, + rawLogsIncluded: false, + }); + expect(summary.bytes).toBeGreaterThan(0); + expect(summary.entries).toContain("health/gateway-health.json"); + expect(summary.entries).toContain("status/gateway-status.json"); + }); +}); diff --git a/test/e2e/qa-lab/runtime/gateway-support-export-runtime.ts b/test/e2e/qa-lab/runtime/gateway-support-export-runtime.ts new file mode 100644 index 000000000000..1ed5d98e1377 --- /dev/null +++ b/test/e2e/qa-lab/runtime/gateway-support-export-runtime.ts @@ -0,0 +1,257 @@ +// QA evidence for the real Gateway support-export CLI and zip privacy boundary. +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import JSZip from "jszip"; +import { formatErrorMessage } from "../../../../src/infra/errors.js"; +import { + createOpenClawTestInstance, + type OpenClawTestInstance, +} from "../../../helpers/openclaw-test-instance.js"; +import { createQaScriptEvidenceWriter } from "./script-evidence.js"; + +const SOURCE_PATH = "test/e2e/qa-lab/runtime/gateway-support-export-runtime.ts"; +const SCENARIO_ID = "gateway-support-export"; + +export type GatewaySupportExportRuntimeOptions = { + artifactBase: string; + repoRoot: string; +}; + +type DiagnosticsExportResult = { + path: string; + bytes: number; + manifest: { + contents: Array<{ path: string; bytes: number; mediaType: string }>; + privacy: { + payloadFree: boolean; + rawLogsIncluded: boolean; + notes: string[]; + }; + }; +}; + +type GatewaySupportExportSummary = { + archive: string; + bytes: number; + entries: string[]; + payloadFree: boolean; + privateMode: boolean; + rawLogsIncluded: boolean; +}; + +function parseOptions( + argv: string[], + repoRoot = process.cwd(), +): GatewaySupportExportRuntimeOptions { + let artifactBase: string | undefined; + for (let index = 0; index < argv.length; index += 1) { + const option = argv[index]; + const value = argv[index + 1]; + if (option !== "--artifact-base") { + throw new Error(`unknown argument: ${option}`); + } + if (!value || value.startsWith("--")) { + throw new Error("--artifact-base requires a value"); + } + artifactBase = value; + index += 1; + } + if (!artifactBase) { + throw new Error("--artifact-base is required"); + } + return { artifactBase: path.resolve(repoRoot, artifactBase), repoRoot }; +} + +function parseCliJson( + label: string, + result: Awaited>, +): T { + if (result.code !== 0) { + throw new Error( + `${label} failed with exit ${String(result.code)}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + } + try { + return JSON.parse(result.stdout) as T; + } catch (error) { + throw new Error( + `${label} returned invalid JSON: ${formatErrorMessage(error)}\n${result.stdout}`, + { cause: error }, + ); + } +} + +async function readZipEntries(file: string): Promise> { + const zip = await JSZip.loadAsync(await fs.readFile(file)); + const entries: Record = {}; + for (const [name, entry] of Object.entries(zip.files)) { + if (!entry.dir) { + entries[name] = await entry.async("string"); + } + } + return entries; +} + +function assertSafeArchiveEntries(entries: readonly string[]): void { + for (const entry of entries) { + assert.ok(entry.length > 0, "support zip entry must not be empty"); + assert.equal(path.posix.isAbsolute(entry), false, `absolute support zip entry: ${entry}`); + assert.equal( + entry.split("/").some((part) => part === "" || part === "." || part === ".."), + false, + `unsafe support zip entry: ${entry}`, + ); + } +} + +function createWriter(options: GatewaySupportExportRuntimeOptions) { + return createQaScriptEvidenceWriter({ + artifactBase: options.artifactBase, + logFileName: "gateway-support-export.log", + primaryModel: "gateway/diagnostics-export", + providerMode: "mock-openai", + repoRoot: options.repoRoot, + target: { + id: SCENARIO_ID, + title: "Gateway support diagnostics export", + sourcePath: SOURCE_PATH, + docsRefs: ["docs/gateway/diagnostics.md", "docs/cli/gateway.md"], + codeRefs: [ + SOURCE_PATH, + "src/cli/gateway-cli/register.ts", + "src/logging/diagnostic-support-export.ts", + "src/logging/diagnostic-support-bundle.ts", + ], + }, + }); +} + +export async function runGatewaySupportExportRuntime(options: GatewaySupportExportRuntimeOptions) { + await fs.mkdir(options.artifactBase, { recursive: true }); + const writer = createWriter(options); + const startedAt = Date.now(); + let instance: OpenClawTestInstance | undefined; + try { + instance = await createOpenClawTestInstance({ + name: "qa-gateway-support-export", + config: { + diagnostics: { enabled: true }, + }, + env: { + OPENCLAW_TEST_FILE_LOG: "1", + }, + }); + await instance.startGateway(); + + const outputPath = path.join(options.artifactBase, "gateway-support-export.zip"); + const result = parseCliJson( + "gateway diagnostics export", + await instance.cli( + [ + "gateway", + "diagnostics", + "export", + "--output", + outputPath, + "--log-lines", + "7", + "--log-bytes", + "4096", + "--url", + instance.url, + "--token", + instance.gatewayToken, + "--timeout", + "10000", + "--json", + ], + { timeoutMs: 120_000 }, + ), + ); + + assert.equal(path.resolve(result.path), path.resolve(outputPath)); + assert.ok(result.bytes > 0, "diagnostics export must be nonempty"); + assert.equal(result.manifest.privacy.payloadFree, true); + assert.equal(result.manifest.privacy.rawLogsIncluded, false); + + const entries = await readZipEntries(outputPath); + const entryNames = Object.keys(entries).toSorted(); + assertSafeArchiveEntries(entryNames); + for (const required of [ + "config/sanitized.json", + "config/shape.json", + "diagnostics.json", + "health/gateway-health.json", + "logs/openclaw-sanitized.jsonl", + "manifest.json", + "status/gateway-status.json", + "summary.md", + ]) { + assert.ok(entryNames.includes(required), `support zip missing ${required}`); + } + + const combined = Object.values(entries).join("\n"); + for (const [label, privateValue] of [ + ["Gateway token", instance.gatewayToken], + ["hook token", instance.hookToken], + ["home directory", instance.homeDir], + ["state directory", instance.stateDir], + ] as const) { + assert.equal(combined.includes(privateValue), false, `support zip leaked ${label}`); + } + assert.match(combined, /payload[- ]free/iu); + assert.match(combined, /sanitized/iu); + + const mode = (await fs.stat(outputPath)).mode & 0o777; + const privateMode = process.platform === "win32" || mode === 0o600; + assert.equal(privateMode, true, `support zip mode must be 0600, got ${mode.toString(8)}`); + + const summary: GatewaySupportExportSummary = { + archive: path.basename(outputPath), + bytes: result.bytes, + entries: entryNames, + payloadFree: result.manifest.privacy.payloadFree, + privateMode, + rawLogsIncluded: result.manifest.privacy.rawLogsIncluded, + }; + const summaryPath = path.join(options.artifactBase, "gateway-support-export-summary.json"); + await fs.writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8"); + writer.appendLog( + `gateway-support-export: files=${entryNames.length} bytes=${result.bytes} privateMode=${String(privateMode)}\n`, + ); + return await writer.write({ + artifacts: [ + { kind: "summary", filePath: summaryPath }, + { kind: "archive", filePath: outputPath }, + ], + details: `real CLI wrote ${entryNames.length} sanitized files in a private payload-free zip`, + durationMs: Math.max(1, Date.now() - startedAt), + status: "pass", + }); + } catch (error) { + const details = formatErrorMessage(error); + writer.appendLog(`gateway-support-export: ${details}\n`); + return await writer.write({ + details, + durationMs: Math.max(1, Date.now() - startedAt), + status: "fail", + }); + } finally { + await instance?.cleanup(); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + runGatewaySupportExportRuntime(parseOptions(process.argv.slice(2))) + .then((evidence) => { + const status = evidence.entries[0]?.result.status; + process.stdout.write(`gateway-support-export: ${status}\n`); + process.exitCode = status === "pass" ? 0 : 1; + }) + .catch((error: unknown) => { + process.stderr.write(`gateway-support-export: ${formatErrorMessage(error)}\n`); + process.exitCode = 1; + }); +}