fix(qa): route Crabline Discord through child env

This commit is contained in:
Dallin Romney
2026-08-20 20:12:30 -07:00
parent 03349b090e
commit c14a960dcc
10 changed files with 32 additions and 200 deletions
-2
View File
@@ -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",
+1 -2
View File
@@ -9,8 +9,7 @@
}
],
"activation": {
"onStartup": false,
"onConfigPaths": ["plugins.entries.qa-lab"]
"onStartup": false
},
"contracts": {
"webSearchProviders": ["qa-lab-search"],
@@ -1,2 +0,0 @@
export const CRABLINE_DISCORD_PROVIDER_ENDPOINT_ARTIFACT =
"crabline-discord-provider-endpoint.json";
@@ -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 });
}
});
});
@@ -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);
}
@@ -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" },
+10 -22
View File
@@ -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: {
@@ -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,
+3 -10
View File
@@ -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<QaTransportAdapter, "requiredPluginIds" | "createGatewayConfig">;
}
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<QaTransportAdapter, "requiredPluginIds" | "createGatewayConfig">;
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,
-1
View File
@@ -315,7 +315,6 @@ export type QaTransportAdapter = Omit<
timeoutMs?: number,
intervalMs?: number,
) => Promise<T>;
stageGatewayRuntime?: (params: { tempRoot: string }) => Promise<void>;
};
export abstract class QaStateBackedTransportAdapter implements QaTransportAdapter {