mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
fix(qa): route Crabline Discord through child env
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user