refactor(irc,workboard): trim internal exports (#107749)

* refactor(irc): privatize internal plugin surfaces

* refactor(workboard): privatize internal plugin surfaces

* chore(deadcode): refresh unused-export baseline
This commit is contained in:
Peter Steinberger
2026-07-14 13:19:08 -07:00
committed by GitHub
parent 892c460ce8
commit 074f07c520
23 changed files with 389 additions and 223 deletions
+1 -10
View File
@@ -1,13 +1,8 @@
// Irc tests cover channel plugin behavior.
import { afterEach, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import { ircOutboundBaseAdapter } from "./outbound-base.js";
import { clearIrcRuntime } from "./runtime.js";
describe("irc outbound chunking", () => {
afterEach(() => {
clearIrcRuntime();
});
it("chunks outbound text without requiring IRC runtime initialization", () => {
expect(ircOutboundBaseAdapter.chunker("alpha beta", 5)).toEqual(["alpha", "beta"]);
expect(ircOutboundBaseAdapter.deliveryMode).toBe("direct");
@@ -17,10 +12,6 @@ describe("irc outbound chunking", () => {
});
describe("irc outbound sanitizeText", () => {
afterEach(() => {
clearIrcRuntime();
});
it("strips internal tool-trace banners before outbound delivery", () => {
const text = "Done.\n⚠️ 🛠️ `search repos (agent)` failed";
+104 -41
View File
@@ -1,7 +1,7 @@
// Irc tests cover client plugin behavior.
import net from "node:net";
import { describe, expect, it } from "vitest";
import { buildFallbackNick, buildIrcNickServCommands, connectIrcClient } from "./client.js";
import { connectIrcClient } from "./client.js";
type LoopbackIrcServer = {
port: number;
@@ -17,13 +17,16 @@ type HangingIrcServer = {
close(): Promise<void>;
};
async function startLoopbackIrcServer(): Promise<LoopbackIrcServer> {
async function startLoopbackIrcServer(options?: {
rejectInitialNick?: boolean;
}): Promise<LoopbackIrcServer> {
const lines: string[] = [];
const sockets = new Set<net.Socket>();
const server = net.createServer((socket) => {
sockets.add(socket);
socket.setEncoding("utf8");
let buffer = "";
let awaitingFallbackNick = false;
socket.on("data", (chunk: string) => {
buffer += chunk;
let idx = buffer.indexOf("\n");
@@ -33,7 +36,15 @@ async function startLoopbackIrcServer(): Promise<LoopbackIrcServer> {
idx = buffer.indexOf("\n");
lines.push(line);
if (line.startsWith("USER ")) {
socket.write(":server 001 bot :welcome\r\n");
if (options?.rejectInitialNick) {
awaitingFallbackNick = true;
socket.write(":server 433 * bot :Nickname in use\r\n");
} else {
socket.write(":server 001 bot :welcome\r\n");
}
} else if (awaitingFallbackNick && line.startsWith("NICK ")) {
awaitingFallbackNick = false;
socket.write(`:server 001 ${line.slice("NICK ".length)} :welcome\r\n`);
}
}
});
@@ -68,6 +79,54 @@ async function startLoopbackIrcServer(): Promise<LoopbackIrcServer> {
};
}
async function connectAndCollectRegistration(params: {
nickserv: NonNullable<Parameters<typeof connectIrcClient>[0]["nickserv"]>;
done: (lines: string[], errors: Error[]) => boolean;
}): Promise<{ lines: string[]; errors: Error[] }> {
const server = await startLoopbackIrcServer();
const errors: Error[] = [];
const client = await connectIrcClient({
host: "127.0.0.1",
port: server.port,
tls: false,
nick: "bot",
username: "bot",
realname: "OpenClaw Bot",
nickserv: params.nickserv,
onError: (error) => errors.push(error),
});
try {
await waitForIrcCondition(
() => params.done(server.lines, errors),
"expected IRC registration outcome",
);
return { lines: [...server.lines], errors };
} finally {
client.close();
await server.close();
}
}
async function connectAfterNickCollision(nick: string): Promise<string> {
const server = await startLoopbackIrcServer({ rejectInitialNick: true });
const client = await connectIrcClient({
host: "127.0.0.1",
port: server.port,
tls: false,
nick,
username: "bot",
realname: "OpenClaw Bot",
});
try {
const nickLines = server.lines.filter((line) => line.startsWith("NICK "));
expect(nickLines).toHaveLength(2);
return nickLines[1]!.slice("NICK ".length);
} finally {
client.close();
await server.close();
}
}
async function waitForIrcCondition(
predicate: () => boolean,
message: string,
@@ -132,43 +191,53 @@ async function startHangingIrcServer(): Promise<HangingIrcServer> {
}
describe("irc client nickserv", () => {
it("builds IDENTIFY command when password is set", () => {
expect(
buildIrcNickServCommands({
password: "secret",
}),
).toEqual(["PRIVMSG NickServ :IDENTIFY secret"]);
it("sends IDENTIFY when a password is configured", async () => {
const result = await connectAndCollectRegistration({
nickserv: { password: "secret" },
done: (lines) => lines.includes("PRIVMSG NickServ :IDENTIFY secret"),
});
expect(result.lines).toContain("PRIVMSG NickServ :IDENTIFY secret");
});
it("builds REGISTER command when enabled with email", () => {
expect(
buildIrcNickServCommands({
it("sends REGISTER after IDENTIFY when enabled with email", async () => {
const result = await connectAndCollectRegistration({
nickserv: {
password: "secret",
register: true,
registerEmail: "bot@example.com",
}),
).toEqual([
},
done: (lines) => lines.some((line) => line.startsWith("PRIVMSG NickServ :REGISTER ")),
});
expect(result.lines.filter((line) => line.startsWith("PRIVMSG NickServ :"))).toEqual([
"PRIVMSG NickServ :IDENTIFY secret",
"PRIVMSG NickServ :REGISTER secret bot@example.com",
]);
});
it("rejects register without registerEmail", () => {
expect(() =>
buildIrcNickServCommands({
it("reports register without registerEmail", async () => {
const result = await connectAndCollectRegistration({
nickserv: {
password: "secret",
register: true,
}),
).toThrow(/registerEmail/);
},
done: (_lines, errors) => errors.length > 0,
});
expect(result.errors[0]?.message).toMatch(/registerEmail/);
});
it("sanitizes outbound NickServ payloads", () => {
expect(
buildIrcNickServCommands({
it("sanitizes outbound NickServ payloads", async () => {
const result = await connectAndCollectRegistration({
nickserv: {
service: "NickServ\n",
password: "secret\r\nJOIN #bad",
}),
).toEqual(["PRIVMSG NickServ :IDENTIFY secret JOIN #bad"]);
},
done: (lines) => lines.some((line) => line.startsWith("PRIVMSG NickServ :IDENTIFY")),
});
expect(result.lines).toContain("PRIVMSG NickServ :IDENTIFY secret JOIN #bad");
});
});
@@ -200,35 +269,29 @@ describe("irc client readiness timeout", () => {
});
describe("irc client fallback nick", () => {
it("produces unique fallback nicks across sequential calls", () => {
const first = buildFallbackNick("bot");
const second = buildFallbackNick("bot");
const third = buildFallbackNick("bot");
// First call gets suffix _ (seq=1), subsequent calls get _2, _3, ...
expect(first).toBe("bot_");
it("produces unique fallback nicks across sequential collisions", async () => {
const first = await connectAfterNickCollision("bot");
const second = await connectAfterNickCollision("bot");
const third = await connectAfterNickCollision("bot");
expect(first).toMatch(/^bot_\d*$/);
expect(second).toMatch(/^bot_\d+$/);
expect(third).toMatch(/^bot_\d+$/);
expect(new Set([first, second, third]).size).toBe(3);
});
it("sanitizes whitespace and special characters in nick", () => {
const nick = buildFallbackNick("my bot!");
it("sanitizes whitespace and special characters after a collision", async () => {
const nick = await connectAfterNickCollision("my bot!");
expect(nick).toMatch(/^mybot_\d*$/);
});
it("falls back to openclaw when nick consists entirely of special characters", () => {
const nick = buildFallbackNick("!!!");
it("falls back to openclaw when a colliding nick is entirely special characters", async () => {
const nick = await connectAfterNickCollision("!!!");
expect(nick).toMatch(/^openclaw_\d*$/);
});
it("falls back to openclaw when nick is empty after sanitization", () => {
const nick = buildFallbackNick("");
expect(nick).toMatch(/^openclaw_\d*$/);
});
it("truncates long nicks to max 30 chars", () => {
it("truncates a long fallback nick to 30 characters", async () => {
const longNick = "a".repeat(50);
const nick = buildFallbackNick(longNick);
const nick = await connectAfterNickCollision(longNick);
expect(nick.length).toBeLessThanOrEqual(30);
expect(nick).toMatch(/^a+_\d*$/);
});
+2 -2
View File
@@ -100,7 +100,7 @@ function toError(err: unknown): Error {
let nickCollisionFallbackSeq = 0;
export function buildFallbackNick(nick: string): string {
function buildFallbackNick(nick: string): string {
const normalized = nick.replace(/\s+/g, "");
const safe = normalized.replace(/[^A-Za-z0-9_\-[\]\\`^{}|]/g, "");
const base = safe || "openclaw";
@@ -117,7 +117,7 @@ function normalizeIrcNick(value: string): string {
return normalizeLowercaseStringOrEmpty(value);
}
export function buildIrcNickServCommands(options?: IrcNickServOptions): string[] {
function buildIrcNickServCommands(options?: IrcNickServOptions): string[] {
if (!options || options.enabled === false) {
return [];
}
+42 -24
View File
@@ -1,27 +1,41 @@
// Irc tests cover config schema plugin behavior.
import { describe, expect, it } from "vitest";
import { IrcConfigSchema } from "./config-schema.js";
import { IrcChannelConfigSchema } from "./config-schema.js";
import type { IrcAccountConfig } from "./types.js";
function expectValidConfig(result: ReturnType<typeof IrcConfigSchema.safeParse>) {
type ParsedIrcConfig = IrcAccountConfig & {
accounts?: Record<string, IrcAccountConfig>;
defaultAccount?: string;
};
function parseIrcConfig(value: unknown) {
const runtime = IrcChannelConfigSchema.runtime;
if (!runtime) {
throw new Error("expected IRC channel config runtime");
}
return runtime.safeParse(value);
}
function expectValidConfig(result: ReturnType<typeof parseIrcConfig>) {
expect(result.success).toBe(true);
if (!result.success) {
throw new Error("expected config to be valid");
}
return result.data;
return result.data as ParsedIrcConfig;
}
function expectInvalidConfig(result: ReturnType<typeof IrcConfigSchema.safeParse>) {
function expectInvalidConfig(result: ReturnType<typeof parseIrcConfig>) {
expect(result.success).toBe(false);
if (result.success) {
throw new Error("expected config to be invalid");
}
return result.error.issues;
return result.issues;
}
describe("irc config schema", () => {
it("accepts basic config", () => {
const config = expectValidConfig(
IrcConfigSchema.safeParse({
parseIrcConfig({
host: "irc.libera.chat",
nick: "openclaw-bot",
channels: ["#openclaw"],
@@ -34,18 +48,18 @@ describe("irc config schema", () => {
it('rejects dmPolicy="open" without allowFrom "*"', () => {
const issues = expectInvalidConfig(
IrcConfigSchema.safeParse({
parseIrcConfig({
dmPolicy: "open",
allowFrom: ["alice"],
}),
);
expect(issues[0]?.path.join(".")).toBe("allowFrom");
expect(issues[0]?.path?.join(".")).toBe("allowFrom");
});
it('accepts dmPolicy="open" with allowFrom "*"', () => {
const config = expectValidConfig(
IrcConfigSchema.safeParse({
parseIrcConfig({
dmPolicy: "open",
allowFrom: ["*"],
}),
@@ -55,31 +69,35 @@ describe("irc config schema", () => {
});
it("accepts numeric allowFrom and groupAllowFrom entries", () => {
const parsed = IrcConfigSchema.parse({
dmPolicy: "allowlist",
allowFrom: [12345, "alice"],
groupAllowFrom: [67890, "alice!ident@example.org"],
});
const parsed = expectValidConfig(
parseIrcConfig({
dmPolicy: "allowlist",
allowFrom: [12345, "alice"],
groupAllowFrom: [67890, "alice!ident@example.org"],
}),
);
expect(parsed.allowFrom).toEqual([12345, "alice"]);
expect(parsed.groupAllowFrom).toEqual([67890, "alice!ident@example.org"]);
});
it("accepts numeric per-channel allowFrom entries", () => {
const parsed = IrcConfigSchema.parse({
groups: {
"#ops": {
allowFrom: [42, "alice"],
const parsed = expectValidConfig(
parseIrcConfig({
groups: {
"#ops": {
allowFrom: [42, "alice"],
},
},
},
});
}),
);
expect(parsed.groups?.["#ops"]?.allowFrom).toEqual([42, "alice"]);
});
it("rejects nickserv register without registerEmail", () => {
const issues = expectInvalidConfig(
IrcConfigSchema.safeParse({
parseIrcConfig({
nickserv: {
register: true,
password: "secret",
@@ -87,12 +105,12 @@ describe("irc config schema", () => {
}),
);
expect(issues[0]?.path.join(".")).toBe("nickserv.registerEmail");
expect(issues[0]?.path?.join(".")).toBe("nickserv.registerEmail");
});
it("accepts nickserv register with password and registerEmail", () => {
const config = expectValidConfig(
IrcConfigSchema.safeParse({
parseIrcConfig({
nickserv: {
register: true,
password: "secret",
@@ -106,7 +124,7 @@ describe("irc config schema", () => {
it("accepts nickserv register with registerEmail only", () => {
expectValidConfig(
IrcConfigSchema.safeParse({
parseIrcConfig({
nickserv: {
register: true,
registerEmail: "bot@example.com",
+1 -1
View File
@@ -79,7 +79,7 @@ const IrcAccountSchema = IrcAccountSchemaBase.superRefine((value, ctx) => {
});
});
export const IrcConfigSchema = IrcAccountSchemaBase.extend({
const IrcConfigSchema = IrcAccountSchemaBase.extend({
accounts: z.record(z.string(), IrcAccountSchema.optional()).optional(),
defaultAccount: z.string().optional(),
}).superRefine((value, ctx) => {
+5 -6
View File
@@ -1,13 +1,12 @@
// Irc tests cover control chars plugin behavior.
import { describe, expect, it } from "vitest";
import { hasIrcControlChars, isIrcControlChar, stripIrcControlChars } from "./control-chars.js";
import { hasIrcControlChars, stripIrcControlChars } from "./control-chars.js";
describe("irc control char helpers", () => {
it("detects IRC control characters by codepoint", () => {
expect(isIrcControlChar(0x00)).toBe(true);
expect(isIrcControlChar(0x1f)).toBe(true);
expect(isIrcControlChar(0x7f)).toBe(true);
expect(isIrcControlChar(0x20)).toBe(false);
it("detects IRC control characters without classifying spaces as control text", () => {
expect(hasIrcControlChars("\u0000hello\u001f")).toBe(true);
expect(hasIrcControlChars("hello\u007f")).toBe(true);
expect(hasIrcControlChars("hello world")).toBe(false);
});
it("detects and strips IRC control characters from strings", () => {
+1 -1
View File
@@ -1,5 +1,5 @@
// Irc plugin module implements control chars behavior.
export function isIrcControlChar(charCode: number): boolean {
function isIrcControlChar(charCode: number): boolean {
return charCode <= 0x1f || charCode === 0x7f;
}
+2 -6
View File
@@ -1,10 +1,10 @@
// Irc tests cover inbound.behavior plugin behavior.
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ResolvedIrcAccount } from "./accounts.js";
import { handleIrcInbound } from "./inbound.js";
import type { RuntimeEnv } from "./runtime-api.js";
import { clearIrcRuntime, setIrcRuntime } from "./runtime.js";
import { setIrcRuntime } from "./runtime.js";
import type { CoreConfig, IrcInboundMessage } from "./types.js";
const {
@@ -98,10 +98,6 @@ describe("irc inbound behavior", () => {
installIrcRuntime();
});
afterEach(() => {
clearIrcRuntime();
});
it("issues a DM pairing challenge and sends the reply to the sender nick", async () => {
const sendReply = vi.fn<(target: string, text: string, replyToId?: string) => Promise<void>>(
async () => {},
+102 -44
View File
@@ -1,9 +1,9 @@
// Irc tests cover monitor plugin behavior.
import net from "node:net";
import { afterEach, describe, expect, it, vi } from "vitest";
import { monitorIrcProvider, resolveIrcInboundTarget } from "./monitor.js";
import { clearIrcRuntime, setIrcRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
import { describe, expect, it, vi } from "vitest";
import { monitorIrcProvider } from "./monitor.js";
import { setIrcRuntime } from "./runtime.js";
import type { CoreConfig, IrcInboundMessage } from "./types.js";
type DisconnectingIrcServer = {
port: number;
@@ -12,6 +12,11 @@ type DisconnectingIrcServer = {
close(): Promise<void>;
};
type InboundIrcServer = {
port: number;
close(): Promise<void>;
};
async function waitForIrcCondition(
predicate: () => boolean,
message: string,
@@ -90,6 +95,56 @@ async function startDisconnectingIrcServer(): Promise<DisconnectingIrcServer> {
};
}
async function startInboundIrcServer(target: string): Promise<InboundIrcServer> {
const sockets = new Set<net.Socket>();
const server = net.createServer((socket) => {
sockets.add(socket);
socket.setEncoding("utf8");
let buffer = "";
socket.on("data", (chunk: string) => {
buffer += chunk;
let idx = buffer.indexOf("\n");
while (idx !== -1) {
const line = buffer.slice(0, idx).replace(/\r$/, "");
buffer = buffer.slice(idx + 1);
idx = buffer.indexOf("\n");
if (line.startsWith("USER ")) {
socket.write(":server 001 bot :welcome\r\n");
setTimeout(() => {
socket.write(`:alice!ident@example.org PRIVMSG ${target} :hello\r\n`);
}, 20);
}
}
});
socket.on("close", () => sockets.delete(socket));
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("expected loopback IRC server to bind a TCP port");
}
return {
port: address.port,
close: async () => {
for (const socket of sockets) {
socket.destroy();
}
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
},
};
}
function installMonitorRuntime() {
setIrcRuntime({
logging: {
@@ -109,10 +164,6 @@ function installMonitorRuntime() {
} as never);
}
afterEach(() => {
clearIrcRuntime();
});
describe("irc monitor reconnect", () => {
it("reconnects when an established IRC socket closes", async () => {
installMonitorRuntime();
@@ -150,42 +201,49 @@ describe("irc monitor reconnect", () => {
});
describe("irc monitor inbound target", () => {
it("keeps channel target for group messages", () => {
expect(
resolveIrcInboundTarget({
target: "#openclaw",
it.each([
{
label: "channel",
serverTarget: "#openclaw",
expected: { isGroup: true, target: "#openclaw", rawTarget: "#openclaw" },
},
{
label: "DM",
serverTarget: "openclaw-bot",
expected: { isGroup: false, target: "alice", rawTarget: "openclaw-bot" },
},
])("maps $label targets through the monitor boundary", async ({ serverTarget, expected }) => {
installMonitorRuntime();
const server = await startInboundIrcServer(serverTarget);
const messages: IrcInboundMessage[] = [];
let monitor: { stop: () => void } | undefined;
try {
monitor = await monitorIrcProvider({
config: {
channels: {
irc: {
host: "127.0.0.1",
port: server.port,
tls: false,
nick: "bot",
username: "bot",
realname: "OpenClaw",
},
},
} as CoreConfig,
onMessage: (message) => {
messages.push(message);
},
});
await waitForIrcCondition(() => messages.length === 1, "expected one inbound IRC message");
expect(messages[0]).toMatchObject({
...expected,
senderNick: "alice",
}),
).toEqual({
isGroup: true,
target: "#openclaw",
rawTarget: "#openclaw",
});
});
it("maps DM target to sender nick and preserves raw target", () => {
expect(
resolveIrcInboundTarget({
target: "openclaw-bot",
senderNick: "alice",
}),
).toEqual({
isGroup: false,
target: "alice",
rawTarget: "openclaw-bot",
});
});
it("falls back to raw target when sender nick is empty", () => {
expect(
resolveIrcInboundTarget({
target: "openclaw-bot",
senderNick: " ",
}),
).toEqual({
isGroup: false,
target: "openclaw-bot",
rawTarget: "openclaw-bot",
});
text: "hello",
});
} finally {
monitor?.stop();
await server.close();
}
});
});
+1 -1
View File
@@ -22,7 +22,7 @@ type IrcMonitorOptions = {
const IRC_MONITOR_RECONNECT_DELAY_MS = 1000;
export function resolveIrcInboundTarget(params: { target: string; senderNick: string }): {
function resolveIrcInboundTarget(params: { target: string; senderNick: string }): {
isGroup: boolean;
target: string;
rawTarget: string;
+5 -11
View File
@@ -2,15 +2,9 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import type { PluginRuntime } from "./runtime-api.js";
const {
setRuntime: setIrcRuntime,
clearRuntime: clearStoredIrcRuntime,
getRuntime: getIrcRuntime,
} = createPluginRuntimeStore<PluginRuntime>({
pluginId: "irc",
errorMessage: "IRC runtime not initialized",
});
const { setRuntime: setIrcRuntime, getRuntime: getIrcRuntime } =
createPluginRuntimeStore<PluginRuntime>({
pluginId: "irc",
errorMessage: "IRC runtime not initialized",
});
export { getIrcRuntime, setIrcRuntime };
export function clearIrcRuntime() {
clearStoredIrcRuntime();
}
+3 -7
View File
@@ -1,9 +1,9 @@
// Irc tests cover send plugin behavior.
import { verifyChannelMessageAdapterCapabilityProofs } from "openclaw/plugin-sdk/channel-outbound";
import { createSendCfgThreadingRuntime } from "openclaw/plugin-sdk/channel-test-helpers";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { IrcClient } from "./client.js";
import { clearIrcRuntime, setIrcRuntime } from "./runtime.js";
import { setIrcRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
const hoisted = vi.hoisted(() => {
@@ -95,10 +95,6 @@ describe("sendMessageIrc cfg threading", () => {
setIrcRuntime(createSendCfgThreadingRuntime(hoisted) as never);
});
afterEach(() => {
clearIrcRuntime();
});
it("uses explicitly provided cfg without loading runtime config", async () => {
const providedCfg = {
channels: {
@@ -178,7 +174,7 @@ describe("sendMessageIrc cfg threading", () => {
expect(hoisted.record).not.toHaveBeenCalled();
});
it("sends with provided cfg even when the runtime store is not initialized", async () => {
it("sends with provided cfg when runtime activity recording is unavailable", async () => {
const providedCfg = {
channels: {
irc: {
+1 -2
View File
@@ -19,7 +19,7 @@ import {
type ResolvedIrcAccount,
} from "./accounts.js";
import { startIrcGatewayAccount } from "./gateway.js";
import { clearIrcRuntime, setIrcRuntime } from "./runtime.js";
import { setIrcRuntime } from "./runtime.js";
import {
ircSetupAdapter,
parsePort,
@@ -105,7 +105,6 @@ function installIrcRuntime() {
describe("irc setup", () => {
afterEach(() => {
vi.clearAllMocks();
clearIrcRuntime();
});
it("parses valid ports and falls back for invalid values", () => {
@@ -10,8 +10,9 @@ import type {
} from "openclaw/plugin-sdk/runtime-doctor";
import { describe, expect, it } from "vitest";
import { stateMigrations } from "./doctor-contract-api.js";
import type { PersistedWorkboardCard } from "./src/persistence-types.js";
import { createWorkboardSqliteStores } from "./src/sqlite-store.js";
import { WorkboardStore, type PersistedWorkboardCard } from "./src/store.js";
import { WorkboardStore } from "./src/store.js";
function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext {
return {
+2 -1
View File
@@ -2,7 +2,8 @@
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerWorkboardCli } from "./cli.js";
import { WorkboardStore, type PersistedWorkboardCard, type WorkboardKeyedStore } from "./store.js";
import type { PersistedWorkboardCard, WorkboardKeyedStore } from "./persistence-types.js";
import { WorkboardStore } from "./store.js";
const gatewayRuntime = vi.hoisted(() => ({
callGatewayFromCli: vi.fn(),
+100 -34
View File
@@ -3,9 +3,9 @@ import { expectDefined } from "@openclaw/normalization-core";
import type { OpenClawPluginCommandDefinition } from "openclaw/plugin-sdk/core";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawPluginApi } from "../api.js";
import { handleWorkboardCommand, registerWorkboardCommand } from "./command.js";
import type { WorkboardSubagentRuntime, WorkboardWorktreeRuntime } from "./dispatcher.js";
import { WorkboardStore, type PersistedWorkboardCard, type WorkboardKeyedStore } from "./store.js";
import { registerWorkboardCommand } from "./command.js";
import type { PersistedWorkboardCard, WorkboardKeyedStore } from "./persistence-types.js";
import { WorkboardStore } from "./store.js";
import {
resolveAgentWorkboardWorkspaceRuntime,
resolveCommandWorkboardWorkspaceAccess,
@@ -29,10 +29,9 @@ function createMemoryStore<T = PersistedWorkboardCard>(): WorkboardKeyedStore<T>
};
}
function createApi(run = vi.fn().mockResolvedValue({ runId: "run-1" })): {
runtime: { subagent: WorkboardSubagentRuntime; worktrees: WorkboardWorktreeRuntime };
} {
function createApi(run = vi.fn().mockResolvedValue({ runId: "run-1" })): OpenClawPluginApi {
return {
registerCommand: vi.fn(),
runtime: {
subagent: { run },
worktrees: {
@@ -41,8 +40,46 @@ function createApi(run = vi.fn().mockResolvedValue({ runId: "run-1" })): {
release: vi.fn(),
removeIfLossless: vi.fn(),
},
sandbox: {
resolveWorkspaceAuthority: vi.fn(() => ({
sandboxed: false,
workspaceAccess: "rw",
})),
prepareWorkspaceAuthority: vi.fn(async () => ({
sandboxed: false,
workspaceAccess: "rw",
})),
},
},
} as unknown as OpenClawPluginApi;
}
async function runWorkboardCommand(params: {
api: OpenClawPluginApi;
store: WorkboardStore;
args?: string;
context?: {
senderIsOwner?: boolean;
gatewayClientScopes?: string[];
config?: Record<string, unknown>;
agentId?: string;
sessionKey?: string;
};
}) {
let command: OpenClawPluginCommandDefinition | undefined;
vi.mocked(params.api.registerCommand).mockImplementationOnce((definition) => {
command = definition;
});
registerWorkboardCommand({ api: params.api, store: params.store });
return await expectDefined(command, "registered Workboard command").handler({
channel: "test",
isAuthorizedSender: true,
commandBody: "/workboard",
config: {},
sessionKey: "agent:main:main",
args: params.args,
...params.context,
} as never);
}
async function createAmbiguousPrefix(store: WorkboardStore): Promise<string> {
@@ -194,11 +231,11 @@ describe("handleWorkboardCommand", () => {
const api = createApi();
await expect(
handleWorkboardCommand({
runWorkboardCommand({
api,
store,
args: "create Ship CLI",
senderIsOwner: true,
context: { senderIsOwner: true },
}),
).resolves.toEqual(expect.objectContaining({ text: expect.stringContaining("Ship CLI") }));
const card = expectDefined((await store.list())[0], "created workboard card");
@@ -207,16 +244,16 @@ describe("handleWorkboardCommand", () => {
metadata: { automation: { workspaceAccess: { unrestricted: true } } },
});
await expect(handleWorkboardCommand({ api, store, args: "list" })).resolves.toEqual(
await expect(runWorkboardCommand({ api, store, args: "list" })).resolves.toEqual(
expect.objectContaining({ text: expect.stringContaining("Ship CLI") }),
);
await store.update(card.id, { status: "ready" });
await expect(
handleWorkboardCommand({
runWorkboardCommand({
api,
store,
args: "dispatch",
gatewayClientScopes: ["operator.write"],
context: { senderIsOwner: true },
}),
).resolves.toEqual(expect.objectContaining({ text: expect.stringContaining("started=1") }));
expect(api.runtime.subagent.run).toHaveBeenCalledOnce();
@@ -227,23 +264,23 @@ describe("handleWorkboardCommand", () => {
const api = createApi();
const card = await store.create({ title: "Ready worker", status: "ready" });
await expect(handleWorkboardCommand({ api, store, args: "list" })).resolves.toEqual(
await expect(runWorkboardCommand({ api, store, args: "list" })).resolves.toEqual(
expect.objectContaining({ text: expect.stringContaining("Ready worker") }),
);
await expect(handleWorkboardCommand({ api, store, args: "create Blocked" })).resolves.toEqual(
await expect(runWorkboardCommand({ api, store, args: "create Blocked" })).resolves.toEqual(
expect.objectContaining({
isError: true,
text: expect.stringContaining("operator.write"),
}),
);
await expect(handleWorkboardCommand({ api, store, args: "dispatch" })).resolves.toEqual(
await expect(runWorkboardCommand({ api, store, args: "dispatch" })).resolves.toEqual(
expect.objectContaining({
isError: true,
text: expect.stringContaining("operator.write"),
}),
);
await expect(
handleWorkboardCommand({ api, store, args: `move ${card.id} --status running` }),
runWorkboardCommand({ api, store, args: `move ${card.id} --status running` }),
).resolves.toEqual(
expect.objectContaining({
isError: true,
@@ -261,11 +298,11 @@ describe("handleWorkboardCommand", () => {
await store.claim(card.id, { ownerId: "worker", token: "secret-token" });
await expect(
handleWorkboardCommand({
runWorkboardCommand({
api,
store,
args: `move ${card.id.slice(0, 8)} --status review`,
gatewayClientScopes: ["operator.write"],
context: { gatewayClientScopes: ["operator.write"] },
}),
).resolves.toEqual(expect.objectContaining({ text: expect.stringContaining("review") }));
await expect(store.get(card.id)).resolves.toMatchObject({
@@ -280,11 +317,11 @@ describe("handleWorkboardCommand", () => {
const card = await store.create({ title: "Invalid slash move" });
await expect(
handleWorkboardCommand({
runWorkboardCommand({
api,
store,
args: `move ${card.id} --status later`,
senderIsOwner: true,
context: { senderIsOwner: true },
}),
).resolves.toEqual(
expect.objectContaining({ isError: true, text: expect.stringContaining("status must be") }),
@@ -306,13 +343,33 @@ describe("handleWorkboardCommand", () => {
workspace: { kind: "worktree", path: "/repo-denied" },
});
const restrictedConfig = {
tools: { fs: { workspaceOnly: true } },
agents: {
list: [
{ id: "main", default: true, workspace: "/workspace" },
{ id: "restricted", workspace: "/workspace" },
],
},
};
vi.mocked(api.runtime.sandbox.resolveWorkspaceAuthority).mockReturnValue({
sandboxed: true,
workspaceAccess: "rw",
});
vi.mocked(api.runtime.sandbox.prepareWorkspaceAuthority).mockResolvedValue({
sandboxed: true,
workspaceAccess: "rw",
});
await expect(
handleWorkboardCommand({
runWorkboardCommand({
api,
store,
args: "dispatch",
gatewayClientScopes: ["operator.write"],
workspaceAccess: { unrestricted: false, roots: ["/workspace"], writable: true },
context: {
gatewayClientScopes: ["operator.write"],
config: restrictedConfig,
agentId: "main",
},
}),
).resolves.toEqual(
expect.objectContaining({ text: expect.stringContaining("outside the caller") }),
@@ -328,17 +385,15 @@ describe("handleWorkboardCommand", () => {
agentId: "restricted",
workspace: { kind: "worktree", path: "/workspace" },
});
await handleWorkboardCommand({
await runWorkboardCommand({
api,
store,
args: "dispatch",
senderIsOwner: true,
resolveAgentWorkspace: () => "/workspace",
resolveAgentWorkspaceRuntime: () => ({
sandboxed: true,
workspaceAccess: { unrestricted: false, roots: ["/workspace"], writable: true },
}),
workspaceAccess: { unrestricted: false, roots: ["/workspace"], writable: true },
context: {
senderIsOwner: true,
config: restrictedConfig,
agentId: "main",
},
});
expect(createWorktree).not.toHaveBeenCalled();
expect(api.runtime.subagent.run).toHaveBeenCalledWith(
@@ -355,12 +410,23 @@ describe("handleWorkboardCommand", () => {
workspace: { kind: "worktree", path: "/repo-allowed" },
workspaceAccess: { unrestricted: true },
});
await handleWorkboardCommand({
vi.mocked(api.runtime.sandbox.resolveWorkspaceAuthority).mockReturnValue({
sandboxed: false,
workspaceAccess: "rw",
});
vi.mocked(api.runtime.sandbox.prepareWorkspaceAuthority).mockResolvedValue({
sandboxed: false,
workspaceAccess: "rw",
});
await runWorkboardCommand({
api,
store,
args: "dispatch",
gatewayClientScopes: ["operator.admin"],
workspaceAccess: { unrestricted: true },
context: {
gatewayClientScopes: ["operator.admin"],
config: { agents: { list: [{ id: "admin", default: true, workspace: "/repo-allowed" }] } },
agentId: "admin",
},
});
expect(createWorktree).toHaveBeenCalledWith(
@@ -376,7 +442,7 @@ describe("handleWorkboardCommand", () => {
const api = createApi();
const prefix = await createAmbiguousPrefix(store);
await expect(handleWorkboardCommand({ api, store, args: `show ${prefix}` })).resolves.toEqual(
await expect(runWorkboardCommand({ api, store, args: `show ${prefix}` })).resolves.toEqual(
expect.objectContaining({
isError: true,
text: expect.stringContaining("Ambiguous card id prefix"),
+1 -1
View File
@@ -96,7 +96,7 @@ function requireWriteAccess(params: {
};
}
export async function handleWorkboardCommand(params: {
async function handleWorkboardCommand(params: {
api: WorkboardCommandApi;
store: WorkboardStore;
args?: string;
+2 -1
View File
@@ -2,7 +2,8 @@
import { describe, expect, it, vi } from "vitest";
import { cleanupWorkboardRunWorktree } from "./dispatcher-workspace.js";
import { dispatchAndStartWorkboardCards } from "./dispatcher.js";
import { WorkboardStore, type PersistedWorkboardCard, type WorkboardKeyedStore } from "./store.js";
import type { PersistedWorkboardCard, WorkboardKeyedStore } from "./persistence-types.js";
import { WorkboardStore } from "./store.js";
function createMemoryStore<T = PersistedWorkboardCard>(): WorkboardKeyedStore<T> {
const entries = new Map<string, T>();
+2 -1
View File
@@ -2,7 +2,8 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawPluginApi } from "../api.js";
import { registerWorkboardGatewayMethods } from "./gateway.js";
import { WorkboardStore, type PersistedWorkboardCard, type WorkboardKeyedStore } from "./store.js";
import type { PersistedWorkboardCard, WorkboardKeyedStore } from "./persistence-types.js";
import { WorkboardStore } from "./store.js";
function createMemoryStore<T = PersistedWorkboardCard>(): WorkboardKeyedStore<T> {
const entries = new Map<string, T>();
+8 -8
View File
@@ -5,15 +5,15 @@ import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { MAX_DATE_TIMESTAMP_MS } from "openclaw/plugin-sdk/number-runtime";
import { describe, expect, it, vi } from "vitest";
import type {
PersistedWorkboardAttachment,
PersistedWorkboardBoard,
PersistedWorkboardCard,
PersistedWorkboardNotificationSubscription,
WorkboardKeyedStore,
} from "./persistence-types.js";
import { createWorkboardSqliteStores } from "./sqlite-store.js";
import {
WorkboardStore,
type PersistedWorkboardAttachment,
type PersistedWorkboardBoard,
type PersistedWorkboardCard,
type PersistedWorkboardNotificationSubscription,
type WorkboardKeyedStore,
} from "./store.js";
import { WorkboardStore } from "./store.js";
function createMemoryStore<T = PersistedWorkboardCard>(options?: {
beforeRegister?: (key: string, value: T) => Promise<void> | void;
-7
View File
@@ -41,13 +41,6 @@ import {
} from "./store-normalizers.js";
import { WorkboardNotificationStore } from "./store-notifications.js";
export type {
PersistedWorkboardAttachment,
PersistedWorkboardBoard,
PersistedWorkboardCard,
PersistedWorkboardNotificationSubscription,
WorkboardKeyedStore,
} from "./persistence-types.js";
export type { WorkboardDispatchResult } from "./store-inputs.js";
// Capability layers split review boundaries only; the core still owns persistence and mutation order.
+2 -1
View File
@@ -2,7 +2,8 @@
import { expectDefined } from "@openclaw/normalization-core";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawPluginApi } from "../api.js";
import { WorkboardStore, type PersistedWorkboardCard, type WorkboardKeyedStore } from "./store.js";
import type { PersistedWorkboardCard, WorkboardKeyedStore } from "./persistence-types.js";
import { WorkboardStore } from "./store.js";
import { createWorkboardTools } from "./tools.js";
import { guardWorkboardToolsForWorkspaceAccess } from "./workspace-access.js";
-12
View File
@@ -42,12 +42,6 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [
"extensions/googlechat/src/monitor.ts: testing",
"extensions/googlechat/src/targets.ts: resolveGoogleChatSpaceChatType",
"extensions/imessage/src/monitor-reply-cache.ts: resetIMessageShortIdState",
"extensions/irc/src/client.ts: buildFallbackNick",
"extensions/irc/src/client.ts: buildIrcNickServCommands",
"extensions/irc/src/config-schema.ts: IrcConfigSchema",
"extensions/irc/src/control-chars.ts: isIrcControlChar",
"extensions/irc/src/monitor.ts: resolveIrcInboundTarget",
"extensions/irc/src/runtime.ts: clearIrcRuntime",
"extensions/linux-node/src/commands.ts: LinuxNodeCommandDeps",
"extensions/linux-node/src/commands.ts: listLinuxVideoDevices",
"extensions/linux-node/src/commands.ts: MAX_MEDIA_RAW_BYTES",
@@ -137,12 +131,6 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [
"extensions/voice-call/src/cli.ts: testing",
"extensions/voice-call/src/runtime-state.ts: clearVoiceCallStateRuntime",
"extensions/whatsapp/src/auto-reply/monitor/group-gating.ts: resetGroupDropWarningsForTests",
"extensions/workboard/src/command.ts: handleWorkboardCommand",
"extensions/workboard/src/store.ts: PersistedWorkboardAttachment",
"extensions/workboard/src/store.ts: PersistedWorkboardBoard",
"extensions/workboard/src/store.ts: PersistedWorkboardCard",
"extensions/workboard/src/store.ts: PersistedWorkboardNotificationSubscription",
"extensions/workboard/src/store.ts: WorkboardKeyedStore",
"extensions/workspaces/src/broadcast.ts: resetWorkspaceBroadcastForTest",
"extensions/workspaces/src/cli.ts: parseWorkspaceBindingShorthand",
"extensions/workspaces/src/manifest.ts: loadWidgetManifest",