Skip to content

Commit 154d127

Browse files
authored
fix(qa): repair package Telegram harness boundaries (#108499)
* fix(qa): keep packaged entry off private transport * fix(qa): lazy-load private transport runtime * fix(qa): pass relative package artifact path * fix(qa): isolate packaged bus protocol * fix(qa): mount package scenario catalog
1 parent 08ecf63 commit 154d127

14 files changed

Lines changed: 245 additions & 17 deletions

extensions/qa-lab/index.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
const qaChannelLoads = vi.hoisted(() => vi.fn());
4+
const qaChannelProtocolLoads = vi.hoisted(() => vi.fn());
5+
6+
vi.mock("openclaw/plugin-sdk/qa-channel", () => {
7+
qaChannelLoads();
8+
throw new Error("QA Lab entrypoint loaded the private QA channel");
9+
});
10+
11+
vi.mock("openclaw/plugin-sdk/qa-channel-protocol", () => {
12+
qaChannelProtocolLoads();
13+
throw new Error("QA Lab entrypoint loaded the private QA channel protocol");
14+
});
15+
16+
describe("QA Lab plugin entrypoint", () => {
17+
it("loads without the private QA transport runtime", async () => {
18+
const { default: plugin } = await import("./index.js");
19+
20+
expect(plugin.id).toBe("qa-lab");
21+
expect(qaChannelLoads).not.toHaveBeenCalled();
22+
expect(qaChannelProtocolLoads).not.toHaveBeenCalled();
23+
});
24+
25+
it("loads the package Telegram harness without the private QA transport runtime", async () => {
26+
const { runQaTelegramSuite } = await import("./src/live-transports/telegram/cli.runtime.js");
27+
28+
expect(runQaTelegramSuite).toBeTypeOf("function");
29+
expect(qaChannelLoads).not.toHaveBeenCalled();
30+
expect(qaChannelProtocolLoads).not.toHaveBeenCalled();
31+
});
32+
});

extensions/qa-lab/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Qa Lab plugin entrypoint registers its OpenClaw integration.
22
import { setTimeout as sleep } from "node:timers/promises";
3+
// Keep plugin registration independent of private QA transports, which packaged runtimes omit.
4+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
35
import { jsonResult } from "openclaw/plugin-sdk/tool-results";
4-
import { definePluginEntry } from "./runtime-api.js";
56
import { registerQaLabCli } from "./src/cli.js";
67
import { createQaLabWebSearchProvider } from "./src/qa-web-search-provider.js";
78
import { createStaticSshWorkerProvider } from "./src/static-ssh-worker-provider.js";

extensions/qa-lab/src/bus-queries.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Qa Lab plugin module implements bus queries behavior.
2-
import { parseQaTarget } from "openclaw/plugin-sdk/qa-channel-protocol";
32
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
3+
import { parseQaTarget } from "./qa-bus-protocol.js";
44
import type {
55
QaBusAttachment,
66
QaBusConversation,

extensions/qa-lab/src/bus-state.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
// Qa Lab plugin module implements bus state behavior.
22
import { randomUUID } from "node:crypto";
3-
import { sanitizeQaBusToolCalls } from "openclaw/plugin-sdk/qa-channel-protocol";
43
import {
54
buildQaBusSnapshot,
65
cloneMessage,
@@ -12,6 +11,7 @@ import {
1211
searchQaBusMessages,
1312
} from "./bus-queries.js";
1413
import { createQaBusWaiterStore } from "./bus-waiters.js";
14+
import { sanitizeQaBusToolCalls } from "./qa-bus-protocol.js";
1515
import type {
1616
QaBusAttachment,
1717
QaBusConversation,

extensions/qa-lab/src/lab-server.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const qaChannelMock = vi.hoisted(() => ({
1414
startAccount: vi.fn(),
1515
}));
1616

17-
vi.mock("./runtime-api.js", () => ({
17+
vi.mock("openclaw/plugin-sdk/qa-channel", () => ({
1818
qaChannelPlugin: {
1919
config: {
2020
resolveAccount: qaChannelMock.resolveAccount,

extensions/qa-lab/src/lab-server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import fs from "node:fs";
33
import { createServer } from "node:http";
44
import path from "node:path";
5+
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
56
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
67
import {
78
acquireDebugProxyCaptureStore,
@@ -54,7 +55,6 @@ import {
5455
createQaRunOutputDir,
5556
normalizeQaRunSelection,
5657
} from "./run-config.js";
57-
import { qaChannelPlugin, setQaChannelRuntime, type OpenClawConfig } from "./runtime-api.js";
5858
import { readQaBootstrapScenarioCatalog } from "./scenario-catalog.js";
5959
import { runQaSelfCheckAgainstState, type QaSelfCheckResult } from "./self-check.js";
6060

@@ -247,6 +247,7 @@ function detectQaEvidenceArtifactContentType(filePath: string): string {
247247
}
248248

249249
async function startQaGatewayLoop(params: { state: QaBusState; baseUrl: string }) {
250+
const { qaChannelPlugin, setQaChannelRuntime } = await import("openclaw/plugin-sdk/qa-channel");
250251
const runtime = createQaRunnerRuntime();
251252
setQaChannelRuntime(runtime);
252253
const cfg = createQaLabConfig(params.baseUrl);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {
2+
parseQaTarget as parseCanonicalQaTarget,
3+
sanitizeQaBusToolCalls as sanitizeCanonicalQaBusToolCalls,
4+
} from "openclaw/plugin-sdk/qa-channel-protocol";
5+
import { describe, expect, it } from "vitest";
6+
import { parseQaTarget, sanitizeQaBusToolCalls } from "./qa-bus-protocol.js";
7+
8+
describe("QA Lab package bus protocol", () => {
9+
it.each([
10+
"bare-id",
11+
"channel:CaseSensitive",
12+
"group:team-room",
13+
"dm:user-1",
14+
"thread:Room/Topic",
15+
])("matches the canonical target parser for %s", (target) => {
16+
expect(parseQaTarget(target)).toEqual(parseCanonicalQaTarget(target));
17+
});
18+
19+
it.each(["", "CHANNEL:CaseSensitive", "thread:Room/", "dm:"])(
20+
"matches canonical target errors for %j",
21+
(target) => {
22+
expect(() => parseQaTarget(target)).toThrow();
23+
expect(() => parseCanonicalQaTarget(target)).toThrow();
24+
},
25+
);
26+
27+
it("matches canonical bounded redaction", () => {
28+
const toolCalls = [
29+
null,
30+
{ name: 123 },
31+
{
32+
name: " exec ",
33+
arguments: {
34+
command: "cat README.md",
35+
apiToken: "secret-token",
36+
headers: { Authorization: "Bearer secret" },
37+
values: ["ok", { password: "hunter2" }],
38+
nested: { one: { two: { three: { four: "truncated" } } } },
39+
finite: 42,
40+
infinite: Number.POSITIVE_INFINITY,
41+
bigint: 123n,
42+
omitted: undefined,
43+
},
44+
},
45+
];
46+
47+
expect(sanitizeQaBusToolCalls(toolCalls)).toEqual(sanitizeCanonicalQaBusToolCalls(toolCalls));
48+
});
49+
50+
it("matches the canonical count limit without processing the tail", () => {
51+
const toolCalls = Array.from({ length: 50 }, (_, index) => ({ name: `tool-${index}` }));
52+
toolCalls.push({
53+
get name(): string {
54+
throw new Error("tail should not be sanitized");
55+
},
56+
});
57+
58+
expect(sanitizeQaBusToolCalls(toolCalls)).toEqual(sanitizeCanonicalQaBusToolCalls(toolCalls));
59+
});
60+
});
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// QA Lab source is mounted into package acceptance without the local-only QA Channel SDK.
2+
// Keep its in-memory bus protocol self-contained; parity tests guard the shared semantics.
3+
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
4+
import type { QaBusConversation, QaBusToolCall } from "./runtime-api.js";
5+
6+
type QaTargetParts = {
7+
chatType: QaBusConversation["kind"];
8+
conversationId: string;
9+
threadId?: string;
10+
};
11+
12+
export function parseQaTarget(raw: string): QaTargetParts {
13+
const normalized = raw.trim();
14+
if (!normalized) {
15+
throw new Error("qa-channel target is required");
16+
}
17+
const prefixed = /^(thread|channel|group|dm):(.*)$/u.exec(normalized);
18+
if (!prefixed && /^(thread|channel|group|dm):/iu.test(normalized)) {
19+
throw new Error(`qa-channel target prefixes must be lowercase: ${normalized}`);
20+
}
21+
const prefix = prefixed?.[1];
22+
const rest = prefixed?.[2]?.trim();
23+
if (prefix === "thread") {
24+
if (!rest) {
25+
throw new Error(`invalid qa-channel thread target: ${normalized}`);
26+
}
27+
const slashIndex = rest.indexOf("/");
28+
if (slashIndex <= 0 || slashIndex === rest.length - 1) {
29+
throw new Error(`invalid qa-channel thread target: ${normalized}`);
30+
}
31+
const conversationId = rest.slice(0, slashIndex).trim();
32+
const threadId = rest.slice(slashIndex + 1).trim();
33+
if (!conversationId || !threadId) {
34+
throw new Error(`invalid qa-channel thread target: ${normalized}`);
35+
}
36+
return {
37+
chatType: "channel",
38+
conversationId,
39+
threadId,
40+
};
41+
}
42+
if (prefix) {
43+
if (!rest) {
44+
throw new Error(`invalid qa-channel ${prefix} target: ${normalized}`);
45+
}
46+
return {
47+
chatType: prefix === "dm" ? "direct" : prefix === "group" ? "group" : "channel",
48+
conversationId: rest,
49+
};
50+
}
51+
return {
52+
chatType: "direct",
53+
conversationId: normalized,
54+
};
55+
}
56+
57+
const TOOL_CALL_MAX_COUNT = 50;
58+
const TOOL_CALL_MAX_DEPTH = 4;
59+
const TOOL_CALL_MAX_ARRAY_LENGTH = 20;
60+
const TOOL_CALL_MAX_OBJECT_KEYS = 40;
61+
const TOOL_CALL_REDACTED = "[redacted]";
62+
const TOOL_CALL_SENSITIVE_KEY_RE =
63+
/authorization|cookie|credential|password|secret|token|api[-_]?key|access[-_]?key|private[-_]?key/iu;
64+
65+
function sanitizeToolCallValue(value: unknown, depth: number, key?: string): unknown {
66+
if (key && TOOL_CALL_SENSITIVE_KEY_RE.test(key)) {
67+
return TOOL_CALL_REDACTED;
68+
}
69+
if (value === null || typeof value === "boolean" || typeof value === "number") {
70+
return Number.isFinite(value as number) || typeof value !== "number" ? value : String(value);
71+
}
72+
if (typeof value === "string") {
73+
return TOOL_CALL_REDACTED;
74+
}
75+
if (typeof value === "bigint") {
76+
return value.toString();
77+
}
78+
if (value === undefined || typeof value === "function" || typeof value === "symbol") {
79+
return undefined;
80+
}
81+
if (depth >= TOOL_CALL_MAX_DEPTH) {
82+
return "[truncated]";
83+
}
84+
if (Array.isArray(value)) {
85+
return value.slice(0, TOOL_CALL_MAX_ARRAY_LENGTH).map((entry) => {
86+
return sanitizeToolCallValue(entry, depth + 1);
87+
});
88+
}
89+
if (isRecord(value)) {
90+
return Object.fromEntries(
91+
Object.entries(value)
92+
.slice(0, TOOL_CALL_MAX_OBJECT_KEYS)
93+
.flatMap(([entryKey, entryValue]) => {
94+
const sanitized = sanitizeToolCallValue(entryValue, depth + 1, entryKey);
95+
return sanitized === undefined ? [] : [[entryKey, sanitized]];
96+
}),
97+
);
98+
}
99+
return undefined;
100+
}
101+
102+
function sanitizeToolCallArguments(value: unknown): Record<string, unknown> | undefined {
103+
if (!isRecord(value)) {
104+
return undefined;
105+
}
106+
const sanitized = sanitizeToolCallValue(value, 0);
107+
return isRecord(sanitized) ? sanitized : undefined;
108+
}
109+
110+
export function sanitizeQaBusToolCalls(value: unknown): QaBusToolCall[] | undefined {
111+
if (!Array.isArray(value)) {
112+
return undefined;
113+
}
114+
const sanitized = value.slice(0, TOOL_CALL_MAX_COUNT).flatMap((toolCall) => {
115+
if (!isRecord(toolCall)) {
116+
return [];
117+
}
118+
const name = typeof toolCall.name === "string" ? toolCall.name.trim() : "";
119+
if (!name) {
120+
return [];
121+
}
122+
const args = sanitizeToolCallArguments(toolCall.arguments);
123+
return [
124+
{
125+
name,
126+
...(args && Object.keys(args).length > 0 ? { arguments: args } : {}),
127+
},
128+
];
129+
});
130+
return sanitized.length > 0 ? sanitized : undefined;
131+
}

extensions/qa-lab/src/qa-channel-transport.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import type {
1818
QaTransportPolicy,
1919
QaTransportReportParams,
2020
} from "./qa-transport.js";
21-
import { qaChannelPlugin } from "./runtime-api.js";
2221

2322
const QA_CHANNEL_ID = "qa-channel";
2423
const QA_CHANNEL_ACCOUNT_ID = "default";
@@ -142,6 +141,7 @@ async function handleQaChannelAction(params: {
142141
cfg: OpenClawConfig;
143142
accountId?: string | null;
144143
}) {
144+
const { qaChannelPlugin } = await import("openclaw/plugin-sdk/qa-channel");
145145
return await qaChannelPlugin.actions?.handleAction?.({
146146
channel: QA_CHANNEL_ID,
147147
action: params.action,

extensions/qa-lab/src/scenario-runtime-api.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,6 @@ function createDeps(overrides?: Partial<QaScenarioRuntimeDeps>): QaScenarioRunti
8686
liveTurnTimeoutMs: fn,
8787
resolveQaLiveTurnTimeoutMs: fn,
8888
splitModelRef: fn,
89-
qaChannelPlugin: { id: "qa-channel" },
9089
hasDiscoveryLabels: fn,
9190
reportsDiscoveryScopeLeak: fn,
9291
reportsMissingDiscoveryFiles: fn,

0 commit comments

Comments
 (0)