fix(qa): stabilize docker gateway bootstrap

This commit is contained in:
Peter Steinberger
2026-04-05 22:37:15 +01:00
parent b5fc435bd5
commit eb6d0ce2c2
14 changed files with 149 additions and 12 deletions

View File

@@ -1,3 +1,4 @@
// Keep this barrel helper-only so plugin-sdk facades do not pull the full
// channel plugin (and its runtime state) into tests or other shared surfaces.
export { mattermostPlugin } from "./src/channel.js";
export { isMattermostSenderAllowed } from "./src/mattermost/monitor-auth.js";

View File

@@ -18,7 +18,7 @@ export default defineBundledChannelEntry({
description: "Mattermost channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./runtime-api.js",
specifier: "./api.js",
exportName: "mattermostPlugin",
},
runtime: {

View File

@@ -57,7 +57,6 @@ export {
resolveDmGroupAccessWithLists,
resolveEffectiveAllowFromLists,
} from "openclaw/plugin-sdk/channel-policy";
export { mattermostPlugin } from "./src/channel.js";
export { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";

View File

@@ -3,7 +3,7 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./runtime-api.js",
specifier: "./api.js",
exportName: "mattermostPlugin",
},
});

View File

@@ -61,6 +61,7 @@ describe("qa docker harness", () => {
);
expect(compose).toContain("OPENCLAW_CONFIG_PATH: /tmp/openclaw/openclaw.json");
expect(compose).toContain("OPENCLAW_STATE_DIR: /tmp/openclaw/state");
expect(compose).toContain('OPENCLAW_NO_RESPAWN: "1"');
const envExample = await readFile(path.join(outputDir, ".env.example"), "utf8");
expect(envExample).toContain("OPENCLAW_GATEWAY_TOKEN=qa-token");
@@ -80,6 +81,9 @@ describe("qa docker harness", () => {
"utf8",
);
expect(kickoff).toContain("Lobster Invaders");
const readme = await readFile(path.join(outputDir, "README.md"), "utf8");
expect(readme).toContain("in-process restarts inside Docker");
});
it("builds the reusable QA image with bundled QA extensions", async () => {

View File

@@ -117,6 +117,7 @@ ${imageBlock} pull_policy: never
environment:
OPENCLAW_CONFIG_PATH: /tmp/openclaw/openclaw.json
OPENCLAW_STATE_DIR: /tmp/openclaw/state
OPENCLAW_NO_RESPAWN: "1"
OPENCLAW_SKIP_GMAIL_WATCHER: "1"
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1"
OPENCLAW_SKIP_CANVAS_HOST: "1"
@@ -202,6 +203,8 @@ Gateway:
- Mock OpenAI: internal \`http://qa-mock-openai:44080/v1\`
This scaffold uses localhost Control UI insecure-auth compatibility for QA only.
The gateway runs with in-process restarts inside Docker so restart actions do not
kill the container by detaching a replacement child.
`;
}

View File

@@ -44,4 +44,44 @@ describe("qa mock openai server", () => {
expect(body).toContain('"type":"response.output_item.added"');
expect(body).toContain('"name":"read"');
});
it("prefers path-like refs over generic quoted keys in prompts", async () => {
const server = await startQaMockOpenAiServer({
host: "127.0.0.1",
port: 0,
});
cleanups.push(async () => {
await server.stop();
});
const response = await fetch(`${server.baseUrl}/v1/responses`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
stream: true,
input: [
{
role: "user",
content: [
{
type: "input_text",
text: 'Please inspect "message_id" metadata first, then read `./QA_KICKOFF_TASK.md`.',
},
],
},
],
}),
});
expect(response.status).toBe(200);
const body = await response.text();
expect(body).toContain('"arguments":"{\\"path\\":\\"QA_KICKOFF_TASK.md\\"}"');
const debugResponse = await fetch(`${server.baseUrl}/debug/last-request`);
expect(debugResponse.status).toBe(200);
expect(await debugResponse.json()).toMatchObject({
prompt: 'Please inspect "message_id" metadata first, then read `./QA_KICKOFF_TASK.md`.',
});
});
});

View File

@@ -20,6 +20,13 @@ type StreamEvent =
};
};
type MockOpenAiRequestSnapshot = {
raw: string;
body: Record<string, unknown>;
prompt: string;
toolOutput: string;
};
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
@@ -84,11 +91,41 @@ function extractToolOutput(input: ResponsesInputItem[]) {
return "";
}
function readTargetFromPrompt(prompt: string) {
const quoted = /"([^"]+)"/.exec(prompt)?.[1]?.trim();
if (quoted) {
return quoted;
function normalizePromptPathCandidate(candidate: string) {
const trimmed = candidate.trim().replace(/^`+|`+$/g, "");
if (!trimmed) {
return null;
}
const normalized = trimmed.replace(/^\.\//, "");
if (
normalized.includes("/") ||
/\.(?:md|json|ts|tsx|js|mjs|cjs|txt|yaml|yml)$/i.test(normalized)
) {
return normalized;
}
return null;
}
function readTargetFromPrompt(prompt: string) {
const backtickedMatches = Array.from(prompt.matchAll(/`([^`]+)`/g))
.map((match) => normalizePromptPathCandidate(match[1] ?? ""))
.filter((value): value is string => !!value);
if (backtickedMatches.length > 0) {
return backtickedMatches[0];
}
const quotedMatches = Array.from(prompt.matchAll(/"([^"]+)"/g))
.map((match) => normalizePromptPathCandidate(match[1] ?? ""))
.filter((value): value is string => !!value);
if (quotedMatches.length > 0) {
return quotedMatches[0];
}
const repoScoped = /\b(?:repo\/[^\s`",)]+|QA_[A-Z_]+\.md)\b/.exec(prompt)?.[0]?.trim();
if (repoScoped) {
return repoScoped;
}
if (/\bdocs?\b/i.test(prompt)) {
return "repo/docs/help/testing.md";
}
@@ -203,6 +240,7 @@ function buildResponsesPayload(input: ResponsesInputItem[]) {
export async function startQaMockOpenAiServer(params?: { host?: string; port?: number }) {
const host = params?.host ?? "127.0.0.1";
let lastRequest: MockOpenAiRequestSnapshot | null = null;
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (req.method === "GET" && (url.pathname === "/healthz" || url.pathname === "/readyz")) {
@@ -218,10 +256,20 @@ export async function startQaMockOpenAiServer(params?: { host?: string; port?: n
});
return;
}
if (req.method === "GET" && url.pathname === "/debug/last-request") {
writeJson(res, 200, lastRequest ?? { ok: false, error: "no request recorded" });
return;
}
if (req.method === "POST" && url.pathname === "/v1/responses") {
const raw = await readBody(req);
const body = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
const input = Array.isArray(body.input) ? (body.input as ResponsesInputItem[]) : [];
lastRequest = {
raw,
body,
prompt: extractLastUserText(input),
toolOutput: extractToolOutput(input),
};
const events = buildResponsesPayload(input);
if (body.stream === false) {
const completion = events.at(-1);

View File

@@ -1 +1,2 @@
export { zaloPlugin } from "./src/channel.js";
export * from "./setup-api.js";

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import entry from "./index.js";
import setupEntry from "./setup-entry.js";
describe("zalo bundled entries", () => {
it("loads the channel plugin without a runtime-barrel cycle", () => {
const plugin = entry.loadChannelPlugin();
expect(plugin.id).toBe("zalo");
});
it("loads the setup plugin without a runtime-barrel cycle", () => {
const plugin = setupEntry.loadSetupPlugin();
expect(plugin.id).toBe("zalo");
});
});

View File

@@ -6,7 +6,7 @@ export default defineBundledChannelEntry({
description: "Zalo channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./runtime-api.js",
specifier: "./api.js",
exportName: "zaloPlugin",
},
runtime: {

View File

@@ -1,7 +1,6 @@
// Private runtime barrel for the bundled Zalo extension.
// Keep this barrel thin and aligned with the local extension surface.
export { zaloPlugin } from "./src/channel.js";
// Keep this barrel thin and free of local plugin self-imports so the bundled
// entry loader can resolve the channel plugin without re-entering this module.
export * from "./api.js";
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
export type { OpenClawConfig, GroupPolicy } from "openclaw/plugin-sdk/config-runtime";

View File

@@ -3,7 +3,7 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./runtime-api.js",
specifier: "./api.js",
exportName: "zaloPlugin",
},
});