From c14a960dccd96abea6fbec6b10cecd62081e644c Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Thu, 20 Aug 2026 20:12:30 -0700 Subject: [PATCH] fix(qa): route Crabline Discord through child env --- extensions/qa-lab/index.ts | 2 - extensions/qa-lab/openclaw.plugin.json | 3 +- ...line-discord-provider-endpoint-artifact.ts | 2 - ...crabline-discord-provider-endpoint.test.ts | 83 ------------------- .../src/crabline-discord-provider-endpoint.ts | 52 ------------ .../src/crabline-discord-transport.test.ts | 36 ++++---- extensions/qa-lab/src/crabline-transport.ts | 32 +++---- .../discord-crabline-roundtrip.e2e.test.ts | 8 +- extensions/qa-lab/src/gateway-child.ts | 13 +-- extensions/qa-lab/src/qa-transport.ts | 1 - 10 files changed, 32 insertions(+), 200 deletions(-) delete mode 100644 extensions/qa-lab/src/crabline-discord-provider-endpoint-artifact.ts delete mode 100644 extensions/qa-lab/src/crabline-discord-provider-endpoint.test.ts delete mode 100644 extensions/qa-lab/src/crabline-discord-provider-endpoint.ts diff --git a/extensions/qa-lab/index.ts b/extensions/qa-lab/index.ts index d70044862b64..27bf9122d780 100644 --- a/extensions/qa-lab/index.ts +++ b/extensions/qa-lab/index.ts @@ -4,7 +4,6 @@ import { setTimeout as sleep } from "node:timers/promises"; import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { jsonResult } from "openclaw/plugin-sdk/tool-results"; import { registerQaLabCli } from "./src/cli.js"; -import { registerCrablineDiscordProviderEndpoint } from "./src/crabline-discord-provider-endpoint.js"; import { createQaLabWebSearchProvider } from "./src/qa-web-search-provider.js"; import { createStaticSshWorkerProvider } from "./src/static-ssh-worker-provider.js"; @@ -19,7 +18,6 @@ export default definePluginEntry({ name: "QA Lab", description: "Private QA automation harness and debugger UI", register(api) { - registerCrablineDiscordProviderEndpoint(api); api.registerTool( { name: "qa_restart_wait", diff --git a/extensions/qa-lab/openclaw.plugin.json b/extensions/qa-lab/openclaw.plugin.json index 7d6aafa61b0f..475eb0d8a0f5 100644 --- a/extensions/qa-lab/openclaw.plugin.json +++ b/extensions/qa-lab/openclaw.plugin.json @@ -9,8 +9,7 @@ } ], "activation": { - "onStartup": false, - "onConfigPaths": ["plugins.entries.qa-lab"] + "onStartup": false }, "contracts": { "webSearchProviders": ["qa-lab-search"], diff --git a/extensions/qa-lab/src/crabline-discord-provider-endpoint-artifact.ts b/extensions/qa-lab/src/crabline-discord-provider-endpoint-artifact.ts deleted file mode 100644 index bcdff79b12a8..000000000000 --- a/extensions/qa-lab/src/crabline-discord-provider-endpoint-artifact.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT = - "crabline-discord-provider-endpoint.json"; diff --git a/extensions/qa-lab/src/crabline-discord-provider-endpoint.test.ts b/extensions/qa-lab/src/crabline-discord-provider-endpoint.test.ts deleted file mode 100644 index 4657fe7c9b36..000000000000 --- a/extensions/qa-lab/src/crabline-discord-provider-endpoint.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -const setDiscordProviderEndpointDescriptor = vi.hoisted(() => vi.fn()); - -vi.mock("@openclaw/discord/test-api.js", () => ({ - setDiscordProviderEndpointDescriptor, -})); - -import { CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT } from "./crabline-discord-provider-endpoint-artifact.js"; -import { registerCrablineDiscordProviderEndpoint } from "./crabline-discord-provider-endpoint.js"; - -const QA_TEMP_ROOT_ENV = "OPENCLAW_QA_TEMP_ROOT"; -const originalTempRoot = process.env[QA_TEMP_ROOT_ENV]; - -afterEach(() => { - if (originalTempRoot === undefined) { - delete process.env[QA_TEMP_ROOT_ENV]; - } else { - process.env[QA_TEMP_ROOT_ENV] = originalTempRoot; - } - setDiscordProviderEndpointDescriptor.mockReset(); -}); - -describe("Crabline Discord child provider endpoint", () => { - it("loads the QA-owned artifact once before startup without a teardown mutation", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-qa-discord-endpoint-")); - process.env[QA_TEMP_ROOT_ENV] = tempRoot; - const descriptor = { - restApiBaseUrl: "http://127.0.0.1:43123/api/v10", - gatewayBotUrl: "http://127.0.0.1:43123/api/v10/gateway/bot", - gatewayOrigin: "ws://127.0.0.1:43123", - }; - fs.writeFileSync( - path.join(tempRoot, CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT), - `${JSON.stringify(descriptor)}\n`, - { mode: 0o600 }, - ); - const registerRuntimeLifecycle = vi.fn(); - - try { - registerCrablineDiscordProviderEndpoint( - createTestPluginApi({ registrationMode: "full", registerRuntimeLifecycle }), - ); - - expect(setDiscordProviderEndpointDescriptor).toHaveBeenCalledWith(descriptor); - expect(setDiscordProviderEndpointDescriptor).toHaveBeenCalledOnce(); - expect(registerRuntimeLifecycle).not.toHaveBeenCalled(); - } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); - } - }); - - it("does not load the artifact during non-runtime registration", () => { - process.env[QA_TEMP_ROOT_ENV] = "/does/not/exist"; - - registerCrablineDiscordProviderEndpoint(createTestPluginApi({ registrationMode: "discovery" })); - - expect(setDiscordProviderEndpointDescriptor).not.toHaveBeenCalled(); - }); - - it("rejects an artifact with fields outside the endpoint contract", () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-qa-discord-endpoint-")); - process.env[QA_TEMP_ROOT_ENV] = tempRoot; - fs.writeFileSync( - path.join(tempRoot, CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT), - JSON.stringify({ apiRoot: "http://127.0.0.1:43123/api", version: 1 }), - { mode: 0o600 }, - ); - - try { - expect(() => - registerCrablineDiscordProviderEndpoint(createTestPluginApi({ registrationMode: "full" })), - ).toThrow("Crabline Discord provider endpoint artifact is invalid"); - expect(setDiscordProviderEndpointDescriptor).not.toHaveBeenCalled(); - } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); - } - }); -}); diff --git a/extensions/qa-lab/src/crabline-discord-provider-endpoint.ts b/extensions/qa-lab/src/crabline-discord-provider-endpoint.ts deleted file mode 100644 index 40dc8168b9ca..000000000000 --- a/extensions/qa-lab/src/crabline-discord-provider-endpoint.ts +++ /dev/null @@ -1,52 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { - setDiscordProviderEndpointDescriptor, - type DiscordProviderEndpointDescriptor, -} from "@openclaw/discord/test-api.js"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT } from "./crabline-discord-provider-endpoint-artifact.js"; - -const QA_TEMP_ROOT_ENV = "OPENCLAW_QA_TEMP_ROOT"; -const DESCRIPTOR_KEYS = ["gatewayBotUrl", "gatewayOrigin", "restApiBaseUrl"]; - -function readDescriptor(value: unknown): DiscordProviderEndpointDescriptor { - if ( - !isRecord(value) || - Object.keys(value).toSorted().join("\0") !== DESCRIPTOR_KEYS.join("\0") || - typeof value.restApiBaseUrl !== "string" || - typeof value.gatewayBotUrl !== "string" || - typeof value.gatewayOrigin !== "string" - ) { - throw new Error("Crabline Discord provider endpoint artifact is invalid"); - } - return { - restApiBaseUrl: value.restApiBaseUrl, - gatewayBotUrl: value.gatewayBotUrl, - gatewayOrigin: value.gatewayOrigin, - }; -} - -export function registerCrablineDiscordProviderEndpoint(api: OpenClawPluginApi): void { - if (api.registrationMode !== "full") { - return; - } - const tempRoot = process.env[QA_TEMP_ROOT_ENV]?.trim(); - if (!tempRoot) { - return; - } - const artifactPath = path.join(tempRoot, CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT); - let serialized: string; - try { - serialized = fs.readFileSync(artifactPath, "utf8"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return; - } - throw error; - } - const descriptor = readDescriptor(JSON.parse(serialized)); - // Install before channel startup; Discord seals this process-lifetime bootstrap on activation. - setDiscordProviderEndpointDescriptor(descriptor); -} diff --git a/extensions/qa-lab/src/crabline-discord-transport.test.ts b/extensions/qa-lab/src/crabline-discord-transport.test.ts index 9114298a2f65..3d14a7d1f3b5 100644 --- a/extensions/qa-lab/src/crabline-discord-transport.test.ts +++ b/extensions/qa-lab/src/crabline-discord-transport.test.ts @@ -1,11 +1,8 @@ // QA Lab tests cover the Discord-specific Crabline provider lifecycle. -import fs from "node:fs/promises"; -import path from "node:path"; import type { OpenClawCrablineChannelDriverSelection } from "@openclaw/crabline"; import { withTempDir } from "openclaw/plugin-sdk/test-env"; import { describe, expect, it } from "vitest"; import { createQaBusState } from "./bus-state.js"; -import { CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT } from "./crabline-discord-provider-endpoint-artifact.js"; import { createQaCrablineTransportAdapter } from "./crabline-transport.js"; const DISCORD_SELECTION = { @@ -24,11 +21,8 @@ describe("Crabline Discord transport", () => { selection: DISCORD_SELECTION, state: createQaBusState(), }); - const gatewayTempRoot = path.join(outputDir, "gateway-temp"); - await fs.mkdir(gatewayTempRoot); - try { - expect(transport.requiredPluginIds).toEqual(["qa-lab", "discord"]); + expect(transport.requiredPluginIds).toEqual(["discord"]); const config = transport.createGatewayConfig({ baseUrl: "http://127.0.0.1:1" }); const discord = config.channels?.discord as | { @@ -50,15 +44,21 @@ describe("Crabline Discord transport", () => { token: expect.any(String), }); - await transport.stageGatewayRuntime?.({ tempRoot: gatewayTempRoot }); - const descriptorPath = path.join( - gatewayTempRoot, - CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT, - ); - const descriptor = JSON.parse(await fs.readFile(descriptorPath, "utf8")) as { - gatewayBotUrl: string; - gatewayOrigin: string; - restApiBaseUrl: string; + const runtimeEnv = transport.createRuntimeEnvPatch?.() ?? {}; + expect(runtimeEnv).toMatchObject({ + DISCORD_BOT_TOKEN: expect.any(String), + DISCORD_GATEWAY_BOT_URL: expect.stringMatching( + /^http:\/\/127\.0\.0\.1:\d+\/api\/v10\/gateway\/bot$/u, + ), + DISCORD_GATEWAY_ORIGIN: expect.stringMatching(/^ws:\/\/127\.0\.0\.1:\d+$/u), + DISCORD_REST_API_BASE_URL: expect.stringMatching( + /^http:\/\/127\.0\.0\.1:\d+\/api\/v10$/u, + ), + }); + const descriptor = { + gatewayBotUrl: runtimeEnv.DISCORD_GATEWAY_BOT_URL, + gatewayOrigin: runtimeEnv.DISCORD_GATEWAY_ORIGIN, + restApiBaseUrl: runtimeEnv.DISCORD_REST_API_BASE_URL, }; expect(descriptor).toEqual({ gatewayBotUrl: expect.stringMatching( @@ -67,10 +67,6 @@ describe("Crabline Discord transport", () => { gatewayOrigin: expect.stringMatching(/^ws:\/\/127\.0\.0\.1:\d+$/u), restApiBaseUrl: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/api\/v10$/u), }); - if (process.platform !== "win32") { - expect((await fs.stat(descriptorPath)).mode & 0o077).toBe(0); - } - await expect( transport.sendInbound({ conversation: { id: "discord-crabline-primary", kind: "group" }, diff --git a/extensions/qa-lab/src/crabline-transport.ts b/extensions/qa-lab/src/crabline-transport.ts index 06113ffb0adf..154c5bf0525b 100644 --- a/extensions/qa-lab/src/crabline-transport.ts +++ b/extensions/qa-lab/src/crabline-transport.ts @@ -15,7 +15,6 @@ import { readStringValue, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { createQaBusState, type QaBusState } from "./bus-state.js"; -import { CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT } from "./crabline-discord-provider-endpoint-artifact.js"; import { createCrablineProviderDelivery, createCrablineProviderInboundInput, @@ -364,10 +363,7 @@ class QaCrablineTransport extends QaStateBackedTransportAdapter { id: CRABLINE_TRANSPORT_ID, label: `crabline local ${params.selection.channel}`, accountId: params.adapter.accountId, - requiredPluginIds: - params.selection.channel === "discord" - ? ["qa-lab", ...params.adapter.requiredPluginIds] - : params.adapter.requiredPluginIds, + requiredPluginIds: params.adapter.requiredPluginIds, state: params.state, }); this.#adapter = params.adapter; @@ -487,26 +483,18 @@ class QaCrablineTransport extends QaStateBackedTransportAdapter { return delivery; }; - createRuntimeEnvPatch = () => this.#adapter.createProviderReadinessEnv({}); - - stageGatewayRuntime = async ({ tempRoot }: { tempRoot: string }) => { + createRuntimeEnvPatch = () => { + const env = this.#adapter.createProviderReadinessEnv({}); if (this.#adapter.manifest.provider !== "discord") { - return; + return env; } const gatewayUrl = new URL(this.#adapter.manifest.endpoints.gatewayUrl); - await fs.writeFile( - path.join(tempRoot, CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT), - `${JSON.stringify( - { - restApiBaseUrl: `${this.#adapter.manifest.endpoints.apiRoot}/v10`, - gatewayBotUrl: this.#adapter.manifest.endpoints.gatewayBotUrl, - gatewayOrigin: gatewayUrl.origin, - }, - null, - 2, - )}\n`, - { encoding: "utf8", flag: "wx", mode: 0o600 }, - ); + return { + ...env, + DISCORD_REST_API_BASE_URL: `${this.#adapter.manifest.endpoints.apiRoot}/v10`, + DISCORD_GATEWAY_BOT_URL: this.#adapter.manifest.endpoints.gatewayBotUrl, + DISCORD_GATEWAY_ORIGIN: gatewayUrl.origin, + }; }; handleAction = async (_params: { diff --git a/extensions/qa-lab/src/discord-crabline-roundtrip.e2e.test.ts b/extensions/qa-lab/src/discord-crabline-roundtrip.e2e.test.ts index 849f00b4b12e..6fac0ece4c8a 100644 --- a/extensions/qa-lab/src/discord-crabline-roundtrip.e2e.test.ts +++ b/extensions/qa-lab/src/discord-crabline-roundtrip.e2e.test.ts @@ -3,7 +3,6 @@ import path from "node:path"; import { resolveOpenClawCrablineChannelDriverSelection } from "@openclaw/crabline"; import { readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime"; import { describe, expect, it } from "vitest"; -import { CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT } from "./crabline-discord-provider-endpoint-artifact.js"; import { runQaSuite } from "./suite-launch.runtime.js"; const RUN_DISCORD_CRABLINE_E2E = process.env.OPENCLAW_QA_DISCORD_CRABLINE_E2E === "1"; @@ -133,11 +132,8 @@ describe("Discord Crabline real-plugin roundtrip", () => { ).toBe(true); // Suite return occurs only after the child Gateway and its WebSocket are stopped, then the - // Discord HTTP server and recorder are closed. The QA-owned descriptor lives in the removed - // child temp root and must never escape into durable suite artifacts. - await expect( - fs.access(path.join(suite.result.outputDir, CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT)), - ).rejects.toMatchObject({ code: "ENOENT" }); + // Discord HTTP server and recorder are closed. Endpoint routing stays in the child env and + // never becomes a durable suite artifact. await expect(fs.readFile(recorderPath, "utf8")).resolves.toContain(EXPECTED_MARKER); }, 180_000, diff --git a/extensions/qa-lab/src/gateway-child.ts b/extensions/qa-lab/src/gateway-child.ts index 063070c8308f..20abeb1680c1 100644 --- a/extensions/qa-lab/src/gateway-child.ts +++ b/extensions/qa-lab/src/gateway-child.ts @@ -100,14 +100,11 @@ export type QaGatewayChildListeningContext = { runtimeEnv: NodeJS.ProcessEnv; }; -function createQaGatewayEmptyTransport(): Pick< - QaTransportAdapter, - "requiredPluginIds" | "createGatewayConfig" | "stageGatewayRuntime" -> { +function createQaGatewayEmptyTransport() { return { requiredPluginIds: [] as const, createGatewayConfig: () => ({}), - }; + } satisfies Pick; } function appendQaGatewayTempRoot(details: string, tempRoot: string) { @@ -225,10 +222,7 @@ export async function startQaGatewayChild(params: { command?: QaGatewayChildCommand; useRepoCli?: boolean; providerBaseUrl?: string; - transport?: Pick< - QaTransportAdapter, - "requiredPluginIds" | "createGatewayConfig" | "stageGatewayRuntime" - >; + transport?: Pick; transportBaseUrl: string; controlUiAllowedOrigins?: string[]; providerMode?: QaProviderMode; @@ -284,7 +278,6 @@ export async function startQaGatewayChild(params: { const packagedAuthConfigPath = path.join(stateDir, "qa-auth-bootstrap", "openclaw.json"); const gatewayToken = `qa-suite-${randomUUID()}`; const transport = params.transport ?? createQaGatewayEmptyTransport(); - await transport.stageGatewayRuntime?.({ tempRoot }); await seedQaAgentWorkspace({ workspaceDir, repoRoot: params.repoRoot, diff --git a/extensions/qa-lab/src/qa-transport.ts b/extensions/qa-lab/src/qa-transport.ts index bdeffa038a13..3aba2e13995f 100644 --- a/extensions/qa-lab/src/qa-transport.ts +++ b/extensions/qa-lab/src/qa-transport.ts @@ -315,7 +315,6 @@ export type QaTransportAdapter = Omit< timeoutMs?: number, intervalMs?: number, ) => Promise; - stageGatewayRuntime?: (params: { tempRoot: string }) => Promise; }; export abstract class QaStateBackedTransportAdapter implements QaTransportAdapter {