mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 19:08:22 -06:00
merge(main): refresh realtime Talk QA evidence
* origin/main: (28 commits) refactor(ui): consolidate cron form control rendering (#117576) fix(memory): recover restored session freshness (#117548) fix(whatsapp): normalize future-proof QA poll and video note ingress (#117579) fix(perf): separate warm and first-device health probes (#117525) fix(ci): repair plugin prerelease validation (#117562) fix(tasks): sanitize every human task and flow detail (#117568) fix(feishu): cancel unread streaming-card error bodies before release (#117312) fix(status): honor explicit local RPC fallback timeouts (#117519) fix(ui): preserve Talk transcript surrogate bounds fix(tasks): validate notification policy before persistence (#117554) refactor: consolidate full release child workflows (#117385) refactor(policy): centralize doctor health-check descriptors (#117578) perf(gateway): mark recovery before model preparation (#117544) fix(ui): harden Talk transcript marker bounds test(openai): parse realtime websocket frames safely fix(openai): keep embedding identity stable across upgrades (#117557) fix(googlechat): drop invalid thread resource names before send (#108324) fix(skills): preserve profile in ClawHub command hints (#117555) fix(cli): explain filtered plugin policy without unsafe recovery (#117556) fix(irc): strip markdown from outbound text (#112961) ...
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -807,7 +807,8 @@ jobs:
|
||||
|
||||
OPENCLAW_HOME="$gateway_home" OPENCLAW_STATE_DIR="$gateway_state" OPENCLAW_CONFIG_PATH="$gateway_config" OPENCLAW_GATEWAY_PORT="$gateway_port" \
|
||||
node --import tsx scripts/bench-cli-startup.ts \
|
||||
--case gatewayHealthJson \
|
||||
--case gatewayHealthJsonConnected \
|
||||
--case gatewayHealthJsonFirstDevice \
|
||||
--case configGetGatewayPort \
|
||||
--runs "$source_runs" \
|
||||
--warmup 1 \
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// Feishu streaming card tests exercise error-path response body cancellation
|
||||
// through a real guarded HTTP transport against a loopback server.
|
||||
import { createServer, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const loopback = vi.hoisted(() => ({
|
||||
baseUrl: "",
|
||||
releases: [] as Array<{ bodyIsNull: boolean; bodyUsed: boolean }>,
|
||||
authStatus: 200,
|
||||
createStatus: 200,
|
||||
settingsStatus: 200,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchWithSsrFGuard: async (...args: Parameters<typeof actual.fetchWithSsrFGuard>) => {
|
||||
const [params] = args;
|
||||
const url = new URL(params.url);
|
||||
const redirected = new URL(`${url.pathname}${url.search}`, loopback.baseUrl).toString();
|
||||
const guarded = await actual.fetchWithSsrFGuard({
|
||||
...params,
|
||||
policy: { allowPrivateNetwork: true },
|
||||
url: redirected,
|
||||
});
|
||||
return {
|
||||
...guarded,
|
||||
release: async () => {
|
||||
loopback.releases.push({
|
||||
bodyIsNull: guarded.response.body === null,
|
||||
bodyUsed: guarded.response.bodyUsed,
|
||||
});
|
||||
await guarded.release();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { FeishuStreamingSession } = await import("./streaming-card.js");
|
||||
|
||||
function writeJson(res: import("node:http").ServerResponse, payload: unknown, status = 200): void {
|
||||
res.writeHead(status, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
let server: Server;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (url.pathname.includes("/auth/")) {
|
||||
if (loopback.authStatus === 200) {
|
||||
writeJson(res, { code: 0, msg: "ok", tenant_access_token: "token", expire: 3600 });
|
||||
} else {
|
||||
writeJson(res, { error: "tenant token rejected" }, loopback.authStatus);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (url.pathname.endsWith("/settings")) {
|
||||
if (loopback.settingsStatus === 200) {
|
||||
writeJson(res, { code: 0, msg: "ok" });
|
||||
} else {
|
||||
writeJson(res, { error: "settings rejected" }, loopback.settingsStatus);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (loopback.createStatus === 200) {
|
||||
writeJson(res, { code: 0, msg: "ok", data: { card_id: "card_1" } });
|
||||
} else {
|
||||
writeJson(res, { error: "card create rejected" }, loopback.createStatus);
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address() as AddressInfo;
|
||||
loopback.baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
loopback.releases = [];
|
||||
loopback.authStatus = 200;
|
||||
loopback.createStatus = 200;
|
||||
loopback.settingsStatus = 200;
|
||||
});
|
||||
|
||||
describe("feishu streaming card error-path body release", () => {
|
||||
it("cancels the unread tenant-token error body before release", async () => {
|
||||
loopback.authStatus = 500;
|
||||
const session = new FeishuStreamingSession({} as never, {
|
||||
appId: "app_error_token",
|
||||
appSecret: "secret",
|
||||
});
|
||||
|
||||
await expect(session.start("chat_id", "open_id")).rejects.toThrow(
|
||||
"Token request failed with HTTP 500",
|
||||
);
|
||||
|
||||
expect(loopback.releases).toEqual([{ bodyIsNull: false, bodyUsed: true }]);
|
||||
});
|
||||
|
||||
it("cancels the unread create-card error body before release", async () => {
|
||||
loopback.createStatus = 500;
|
||||
const session = new FeishuStreamingSession({} as never, {
|
||||
appId: "app_error_create",
|
||||
appSecret: "secret",
|
||||
});
|
||||
|
||||
await expect(session.start("chat_id", "open_id")).rejects.toThrow(
|
||||
"Create card request failed with HTTP 500",
|
||||
);
|
||||
|
||||
expect(loopback.releases).toEqual([
|
||||
{ bodyIsNull: false, bodyUsed: true },
|
||||
{ bodyIsNull: false, bodyUsed: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("cancels the unread close-settings error body before release", async () => {
|
||||
const client = {
|
||||
im: {
|
||||
message: {
|
||||
create: async () => ({ code: 0, data: { message_id: "msg_1" } }),
|
||||
},
|
||||
},
|
||||
};
|
||||
const session = new FeishuStreamingSession(client as never, {
|
||||
appId: "app_error_close",
|
||||
appSecret: "secret",
|
||||
});
|
||||
await session.start("chat_id", "open_id");
|
||||
loopback.settingsStatus = 500;
|
||||
|
||||
await expect(session.close()).resolves.toBe(false);
|
||||
|
||||
expect(loopback.releases).toEqual([
|
||||
{ bodyIsNull: false, bodyUsed: true },
|
||||
{ bodyIsNull: false, bodyUsed: true },
|
||||
{ bodyIsNull: false, bodyUsed: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -128,12 +128,22 @@ function resolveAllowedHostnames(domain?: FeishuDomain): string[] {
|
||||
return ["open.feishu.cn"];
|
||||
}
|
||||
|
||||
function cancelUnreadResponseBody(response: Response): void {
|
||||
// A rejected response leaves its body unread; start cancellation before the
|
||||
// guarded dispatcher is released so the connection is not leaked. Do not
|
||||
// await: debug capture can tee the stream and deadlock a waiter.
|
||||
if (!response.bodyUsed) {
|
||||
void response.body?.cancel().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertSuccessfulCardKitResponse(
|
||||
response: Response,
|
||||
auditContext: string,
|
||||
action: string,
|
||||
): Promise<void> {
|
||||
if (!response.ok) {
|
||||
cancelUnreadResponseBody(response);
|
||||
throw new Error(`${action} failed with HTTP ${response.status}`);
|
||||
}
|
||||
const data = await readFeishuJsonResponse<CardKitResponse>(response, auditContext);
|
||||
@@ -174,6 +184,7 @@ async function getToken(creds: Credentials, deps?: FeishuStreamingDeps): Promise
|
||||
};
|
||||
try {
|
||||
if (!response.ok) {
|
||||
cancelUnreadResponseBody(response);
|
||||
throw new Error(`Token request failed with HTTP ${response.status}`);
|
||||
}
|
||||
data = await readFeishuJsonResponse(response, "feishu.streaming-card.token");
|
||||
@@ -328,6 +339,7 @@ export class FeishuStreamingSession {
|
||||
};
|
||||
try {
|
||||
if (!createRes.ok) {
|
||||
cancelUnreadResponseBody(createRes);
|
||||
throw new Error(`Create card request failed with HTTP ${createRes.status}`);
|
||||
}
|
||||
createData = await readFeishuJsonResponse(createRes, "feishu.streaming-card.create");
|
||||
|
||||
@@ -178,6 +178,19 @@ async function fetchBuffer(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A Google Chat `thread` must be a `spaces/{space}/threads/{thread}` resource
|
||||
* name that belongs to the target space. Reply routing sometimes yields other
|
||||
* shapes — a bare id, a `spaces/{space}/messages/{message}` name, or a thread
|
||||
* from a different (or wrongly-cased) space — and passing any of those makes the
|
||||
* Chat API reject the whole send with `400 INVALID_ARGUMENT`. Accept only a
|
||||
* well-formed, same-space thread name; callers drop the rest so the message
|
||||
* still delivers to the space (as a new thread) instead of failing outright.
|
||||
*/
|
||||
function isUsableGoogleChatThreadName(thread: string, space: string): boolean {
|
||||
return /^spaces\/[^/]+\/threads\/[^/]+$/.test(thread) && thread.startsWith(`${space}/threads/`);
|
||||
}
|
||||
|
||||
export async function sendGoogleChatMessage(params: {
|
||||
account: ResolvedGoogleChatAccount;
|
||||
space: string;
|
||||
@@ -186,6 +199,7 @@ export async function sendGoogleChatMessage(params: {
|
||||
cardsV2?: GoogleChatCardV2[];
|
||||
}): Promise<{ messageName?: string; threadName?: string } | null> {
|
||||
const { account, space, text, thread, cardsV2 } = params;
|
||||
const usableThread = thread && isUsableGoogleChatThreadName(thread, space) ? thread : undefined;
|
||||
if (
|
||||
text &&
|
||||
(!cardsV2 || cardsV2.length === 0) &&
|
||||
@@ -200,11 +214,11 @@ export async function sendGoogleChatMessage(params: {
|
||||
if (cardsV2 && cardsV2.length > 0) {
|
||||
body.cardsV2 = cardsV2;
|
||||
}
|
||||
if (thread) {
|
||||
body.thread = { name: thread };
|
||||
if (usableThread) {
|
||||
body.thread = { name: usableThread };
|
||||
}
|
||||
const urlObj = new URL(`${CHAT_API_BASE}/${space}/messages`);
|
||||
if (thread) {
|
||||
if (usableThread) {
|
||||
urlObj.searchParams.set("messageReplyOption", "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD");
|
||||
}
|
||||
const url = urlObj.toString();
|
||||
|
||||
@@ -568,6 +568,58 @@ describe("sendGoogleChatMessage", () => {
|
||||
expect(String(url)).not.toContain("messageReplyOption=");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a bare id", "113887189178345237288721356"],
|
||||
["a thread key without prefix", "pytxeqyhqck"],
|
||||
["a message resource name", "spaces/AAA/messages/1720896000000.000000"],
|
||||
["a space resource name", "spaces/AAA"],
|
||||
["a thread from a different space", "spaces/BBB/threads/xyz"],
|
||||
])(
|
||||
"drops an invalid thread resource name (%s) and posts to the space",
|
||||
async (_label, badThread) => {
|
||||
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/126");
|
||||
|
||||
const result = await sendGoogleChatMessage({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "hello",
|
||||
thread: badThread,
|
||||
});
|
||||
|
||||
const url = mockCallArg(fetchMock);
|
||||
const init = mockCallArg(fetchMock, 0, 1) as RequestInit | undefined;
|
||||
// Invalid thread must not be forwarded, and the reply option must be omitted
|
||||
// so the Chat API accepts the send instead of returning 400 INVALID_ARGUMENT.
|
||||
expect(String(url)).not.toContain("messageReplyOption=");
|
||||
if (typeof init?.body !== "string") {
|
||||
throw new Error("Expected Google Chat request body");
|
||||
}
|
||||
const body = JSON.parse(init.body) as { thread?: unknown };
|
||||
expect(body.thread).toBeUndefined();
|
||||
expect(result).toEqual({ messageName: "spaces/AAA/messages/126" });
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a valid same-space thread resource name", async () => {
|
||||
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/127", "spaces/AAA/threads/xyz");
|
||||
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "hello",
|
||||
thread: "spaces/AAA/threads/xyz",
|
||||
});
|
||||
|
||||
const url = mockCallArg(fetchMock);
|
||||
const init = mockCallArg(fetchMock, 0, 1) as RequestInit | undefined;
|
||||
expect(String(url)).toContain("messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD");
|
||||
if (typeof init?.body !== "string") {
|
||||
throw new Error("Expected Google Chat request body");
|
||||
}
|
||||
const body = JSON.parse(init.body) as { thread?: { name?: unknown } };
|
||||
expect(body.thread?.name).toBe("spaces/AAA/threads/xyz");
|
||||
});
|
||||
|
||||
it("sends cardsV2 with the text fallback", async () => {
|
||||
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/125");
|
||||
const cardsV2 = [
|
||||
|
||||
@@ -10,11 +10,13 @@ const hoisted = vi.hoisted(() => {
|
||||
const loadConfig = vi.fn();
|
||||
const resolveMarkdownTableMode = vi.fn(() => "preserve");
|
||||
const convertMarkdownTables = vi.fn((text: string) => text);
|
||||
const stripMarkdown = vi.fn((text: string) => text);
|
||||
const record = vi.fn();
|
||||
return {
|
||||
loadConfig,
|
||||
resolveMarkdownTableMode,
|
||||
convertMarkdownTables,
|
||||
stripMarkdown,
|
||||
record,
|
||||
normalizeIrcMessagingTarget: vi.fn((value: string) => value.trim()),
|
||||
connectIrcClient: vi.fn(),
|
||||
@@ -47,6 +49,14 @@ vi.mock("openclaw/plugin-sdk/plugin-config-runtime", async () => {
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return original;
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/markdown-table-runtime", async () => {
|
||||
const original = (await vi.importActual("openclaw/plugin-sdk/markdown-table-runtime")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return {
|
||||
...original,
|
||||
resolveMarkdownTableMode: hoisted.resolveMarkdownTableMode,
|
||||
@@ -61,6 +71,7 @@ vi.mock("openclaw/plugin-sdk/text-chunking", async () => {
|
||||
return {
|
||||
...original,
|
||||
convertMarkdownTables: hoisted.convertMarkdownTables,
|
||||
stripMarkdown: hoisted.stripMarkdown,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -71,6 +82,7 @@ function resetHoistedMocks() {
|
||||
hoisted.loadConfig.mockReset();
|
||||
hoisted.resolveMarkdownTableMode.mockReset().mockReturnValue("preserve");
|
||||
hoisted.convertMarkdownTables.mockReset().mockImplementation((text: string) => text);
|
||||
hoisted.stripMarkdown.mockReset().mockImplementation((text: string) => text);
|
||||
hoisted.record.mockReset();
|
||||
hoisted.normalizeIrcMessagingTarget
|
||||
.mockReset()
|
||||
@@ -85,6 +97,7 @@ afterAll(() => {
|
||||
vi.doUnmock("./connect-options.js");
|
||||
vi.doUnmock("./protocol.js");
|
||||
vi.doUnmock("openclaw/plugin-sdk/plugin-config-runtime");
|
||||
vi.doUnmock("openclaw/plugin-sdk/markdown-table-runtime");
|
||||
vi.doUnmock("openclaw/plugin-sdk/text-chunking");
|
||||
vi.resetModules();
|
||||
});
|
||||
@@ -159,6 +172,39 @@ describe("sendMessageIrc cfg threading", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("strips markdown after table conversion before sending to IRC", async () => {
|
||||
const providedCfg = {
|
||||
channels: {
|
||||
irc: {
|
||||
host: "irc.example.com",
|
||||
nick: "openclaw",
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
const client = {
|
||||
isReady: vi.fn(() => true),
|
||||
sendPrivmsg: vi.fn(),
|
||||
} as unknown as IrcClient;
|
||||
hoisted.resolveMarkdownTableMode.mockReturnValue("bullets");
|
||||
hoisted.convertMarkdownTables.mockReturnValue("**Status**\n- [docs](https://example.com)");
|
||||
hoisted.stripMarkdown.mockReturnValue("Status\n- docs (https://example.com)");
|
||||
|
||||
await sendMessageIrc("#room", " | a |\n| - |\n| **docs** | ", {
|
||||
cfg: providedCfg,
|
||||
client,
|
||||
});
|
||||
|
||||
expect(hoisted.convertMarkdownTables).toHaveBeenCalledWith(
|
||||
"| a |\n| - |\n| **docs** |",
|
||||
"bullets",
|
||||
);
|
||||
expect(hoisted.stripMarkdown).toHaveBeenCalledWith("**Status**\n- [docs](https://example.com)");
|
||||
expect(client.sendPrivmsg).toHaveBeenCalledWith(
|
||||
"#room",
|
||||
"Status\n- docs (https://example.com)",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails hard when cfg is omitted", async () => {
|
||||
const client = {
|
||||
isReady: vi.fn(() => true),
|
||||
@@ -254,6 +300,33 @@ describe("sendMessageIrc cfg threading", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects stripped-empty replies before adding reply metadata", async () => {
|
||||
const providedCfg = {
|
||||
channels: {
|
||||
irc: {
|
||||
host: "irc.example.com",
|
||||
nick: "openclaw",
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
const client = {
|
||||
isReady: vi.fn(() => true),
|
||||
sendPrivmsg: vi.fn(),
|
||||
} as unknown as IrcClient;
|
||||
hoisted.stripMarkdown.mockReturnValue("");
|
||||
|
||||
await expect(
|
||||
sendMessageIrc("#room", "#", {
|
||||
cfg: providedCfg,
|
||||
client,
|
||||
replyTo: "irc-parent-1",
|
||||
}),
|
||||
).rejects.toThrow("Message must be non-empty for IRC sends");
|
||||
|
||||
expect(client.sendPrivmsg).not.toHaveBeenCalled();
|
||||
expect(hoisted.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("declares message adapter durable text, media, and reply with receipt proofs", async () => {
|
||||
const providedCfg = {
|
||||
channels: {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
|
||||
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking";
|
||||
import { convertMarkdownTables, stripMarkdown } from "openclaw/plugin-sdk/text-chunking";
|
||||
import { resolveIrcAccount } from "./accounts.js";
|
||||
import type { IrcClient } from "./client.js";
|
||||
import { connectIrcClient } from "./client.js";
|
||||
@@ -78,12 +78,11 @@ export async function sendMessageIrc(
|
||||
channel: "irc",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const prepared = convertMarkdownTables(text.trim(), tableMode);
|
||||
const payload = opts.replyTo ? `${prepared}\n\n[reply:${opts.replyTo}]` : prepared;
|
||||
|
||||
if (!payload.trim()) {
|
||||
const prepared = stripMarkdown(convertMarkdownTables(text.trim(), tableMode));
|
||||
if (!prepared.trim()) {
|
||||
throw new Error("Message must be non-empty for IRC sends");
|
||||
}
|
||||
const payload = opts.replyTo ? `${prepared}\n\n[reply:${opts.replyTo}]` : prepared;
|
||||
|
||||
const client = opts.client;
|
||||
if (client?.isReady()) {
|
||||
|
||||
@@ -106,6 +106,12 @@ describe("memory session sync state", () => {
|
||||
mtimeMs: 250,
|
||||
size: 20,
|
||||
},
|
||||
{
|
||||
absPath: "/tmp/sessions/rolled-back.jsonl",
|
||||
path: "sessions/rolled-back.jsonl",
|
||||
mtimeMs: 150,
|
||||
size: 20,
|
||||
},
|
||||
{
|
||||
absPath: "/tmp/sessions/resized.jsonl",
|
||||
path: "sessions/resized.jsonl",
|
||||
@@ -124,6 +130,7 @@ describe("memory session sync state", () => {
|
||||
{ path: "sessions/sub-ms-newer.jsonl", hash: "hash-sub-ms", mtime: 100.25, size: 10 },
|
||||
{ path: "sessions/invalidated.jsonl", hash: "", mtime: 200, size: 20 },
|
||||
{ path: "sessions/newer.jsonl", hash: "hash-newer", mtime: 200, size: 20 },
|
||||
{ path: "sessions/rolled-back.jsonl", hash: "hash-rolled-back", mtime: 200, size: 20 },
|
||||
{ path: "sessions/resized.jsonl", hash: "hash-resized", mtime: 300, size: 30 },
|
||||
],
|
||||
});
|
||||
@@ -132,6 +139,7 @@ describe("memory session sync state", () => {
|
||||
"/tmp/sessions/sub-ms-newer.jsonl",
|
||||
"/tmp/sessions/invalidated.jsonl",
|
||||
"/tmp/sessions/newer.jsonl",
|
||||
"/tmp/sessions/rolled-back.jsonl",
|
||||
"/tmp/sessions/resized.jsonl",
|
||||
"/tmp/sessions/missing.jsonl",
|
||||
]);
|
||||
|
||||
@@ -26,7 +26,9 @@ export function resolveMemorySessionStartupDirtyFiles(params: {
|
||||
dirtyFiles.push(file.absPath);
|
||||
continue;
|
||||
}
|
||||
if (file.size !== indexedSize || file.mtimeMs > indexedMtimeMs) {
|
||||
// File mtimes and SQLite session updatedAt values can move backward after
|
||||
// restore/reset. The downstream content-hash gate suppresses unchanged rewrites.
|
||||
if (file.size !== indexedSize || file.mtimeMs !== indexedMtimeMs) {
|
||||
dirtyFiles.push(file.absPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,29 @@ export abstract class MemoryManagerSourceSyncOps extends MemoryManagerSessionSyn
|
||||
const deleteChunksByPathAndSource = this.db.prepare(
|
||||
`DELETE FROM memory_index_chunks WHERE path = ? AND source = ?`,
|
||||
);
|
||||
const updateUnchangedSessionSourceMetadata = this.db.prepare(
|
||||
`UPDATE memory_index_sources
|
||||
SET mtime = ?, size = ?
|
||||
WHERE path = ? AND source = 'sessions' AND hash = ?`,
|
||||
);
|
||||
const refreshUnchangedSessionSourceMetadata = (entry: MemoryIndexEntry): boolean => {
|
||||
// Hash equality preserves chunks and embeddings; only converge the source
|
||||
// fingerprint so restored sessions do not repeat catch-up on every startup.
|
||||
return (
|
||||
updateUnchangedSessionSourceMetadata.run(entry.mtimeMs, entry.size, entry.path, entry.hash)
|
||||
.changes === 1
|
||||
);
|
||||
};
|
||||
const canSkipUnchangedSessionEntry = (
|
||||
entry: MemoryIndexEntry,
|
||||
absPath: string,
|
||||
existingHash: string | undefined,
|
||||
): boolean => {
|
||||
if (params.needsFullReindex || existingHash !== entry.hash) {
|
||||
return false;
|
||||
}
|
||||
return !this.sessionsDirtyFiles.has(absPath) || refreshUnchangedSessionSourceMetadata(entry);
|
||||
};
|
||||
const deleteFtsRowsByPathAndSource =
|
||||
this.fts.enabled && this.fts.available
|
||||
? this.db.prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ?`)
|
||||
@@ -340,7 +363,7 @@ export abstract class MemoryManagerSourceSyncOps extends MemoryManagerSessionSyn
|
||||
path: entry.path,
|
||||
existingHashes,
|
||||
});
|
||||
if (!params.needsFullReindex && existingHash === entry.hash) {
|
||||
if (canSkipUnchangedSessionEntry(entry, absPath, existingHash)) {
|
||||
if (params.progress) {
|
||||
params.progress.completed += 1;
|
||||
params.progress.report({
|
||||
@@ -412,7 +435,7 @@ export abstract class MemoryManagerSourceSyncOps extends MemoryManagerSessionSyn
|
||||
path: entry.path,
|
||||
existingHashes,
|
||||
});
|
||||
if (!params.needsFullReindex && existingHash === entry.hash) {
|
||||
if (canSkipUnchangedSessionEntry(entry, absPath, existingHash)) {
|
||||
if (params.progress) {
|
||||
params.progress.completed += 1;
|
||||
params.progress.report({
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import {
|
||||
resolveSessionTranscriptsDirForAgent,
|
||||
type OpenClawConfig,
|
||||
type ResolvedMemorySearchConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
|
||||
import { statSessionEntrySync } from "openclaw/plugin-sdk/memory-core-host-engine-qmd";
|
||||
import {
|
||||
buildSessionEntry,
|
||||
statSessionEntrySync,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-qmd";
|
||||
import {
|
||||
MEMORY_CHUNKING_VERSION,
|
||||
type MemorySource,
|
||||
@@ -64,8 +67,43 @@ type MemorySessionTranscriptUpdate = {
|
||||
const originalStartupStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const originalStartupConfigPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
let transcriptUpdateListener: ((update: MemorySessionTranscriptUpdate) => void) | undefined;
|
||||
const startupHarnessDatabases = new Set<DatabaseSync>();
|
||||
|
||||
type SourceStateRow = { path: string; hash: string; mtime: number; size: number };
|
||||
|
||||
function createStartupHarnessDatabase(sourceRows: SourceStateRow[]): DatabaseSync {
|
||||
const db = new DatabaseSync(":memory:");
|
||||
db.exec(`
|
||||
CREATE TABLE memory_index_sources (
|
||||
path TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
mtime REAL NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
UNIQUE(path, source)
|
||||
);
|
||||
CREATE TABLE memory_index_chunks (
|
||||
id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
model TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE memory_index_source_update_audit (path TEXT NOT NULL);
|
||||
CREATE TRIGGER memory_index_source_update_audit_trigger
|
||||
AFTER UPDATE ON memory_index_sources
|
||||
BEGIN
|
||||
INSERT INTO memory_index_source_update_audit (path) VALUES (NEW.path);
|
||||
END;
|
||||
`);
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO memory_index_sources (path, source, hash, mtime, size) VALUES (?, 'sessions', ?, ?, ?)`,
|
||||
);
|
||||
for (const row of sourceRows) {
|
||||
insert.run(row.path, row.hash, row.mtime, row.size);
|
||||
}
|
||||
startupHarnessDatabases.add(db);
|
||||
return db;
|
||||
}
|
||||
function setStartupStateDir(stateDir: string): void {
|
||||
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
|
||||
}
|
||||
@@ -148,16 +186,37 @@ class SessionStartupCatchupHarness extends MemoryManagerSyncOps {
|
||||
sourceRows: SourceStateRow[],
|
||||
private readonly indexSessionUpdates = false,
|
||||
private readonly subscribeToRealEvents = false,
|
||||
private readonly deferSessionIndex = false,
|
||||
database?: DatabaseSync,
|
||||
) {
|
||||
super();
|
||||
this.sources.add("sessions");
|
||||
this.db = {
|
||||
prepare: () => ({
|
||||
all: () => sourceRows,
|
||||
get: () => undefined,
|
||||
run: () => undefined,
|
||||
}),
|
||||
} as unknown as DatabaseSync;
|
||||
this.db = database ?? createStartupHarnessDatabase(sourceRows);
|
||||
}
|
||||
|
||||
restartForStartup(): SessionStartupCatchupHarness {
|
||||
return new SessionStartupCatchupHarness(
|
||||
[],
|
||||
this.indexSessionUpdates,
|
||||
false,
|
||||
this.deferSessionIndex,
|
||||
this.db,
|
||||
);
|
||||
}
|
||||
|
||||
getIndexedSourceState(pathname: string): SourceStateRow | undefined {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT path, hash, mtime, size FROM memory_index_sources WHERE path = ? AND source = 'sessions'`,
|
||||
)
|
||||
.get(pathname) as SourceStateRow | undefined;
|
||||
}
|
||||
|
||||
getSourceMetadataUpdateCount(): number {
|
||||
const row = this.db
|
||||
.prepare(`SELECT COUNT(*) AS count FROM memory_index_source_update_audit`)
|
||||
.get() as { count: number };
|
||||
return row.count;
|
||||
}
|
||||
|
||||
async catchUp(): Promise<string[]> {
|
||||
@@ -172,6 +231,13 @@ class SessionStartupCatchupHarness extends MemoryManagerSyncOps {
|
||||
await this.runSync(params);
|
||||
}
|
||||
|
||||
async runArchiveSyncForTest(): Promise<void> {
|
||||
await this.syncArchiveFiles({
|
||||
needsFullReindex: false,
|
||||
deferIndex: this.deferSessionIndex,
|
||||
});
|
||||
}
|
||||
|
||||
getDirtyArchiveFiles(): string[] {
|
||||
return Array.from(this.sessionsDirtyFiles);
|
||||
}
|
||||
@@ -273,7 +339,10 @@ class SessionStartupCatchupHarness extends MemoryManagerSyncOps {
|
||||
protected async sync(params?: MemorySyncParams): Promise<void> {
|
||||
this.syncCalls.push(params ?? {});
|
||||
this.pendingSyncWork = this.indexSessionUpdates
|
||||
? this.syncArchiveFiles({ needsFullReindex: false }).then(() => undefined)
|
||||
? this.syncArchiveFiles({
|
||||
needsFullReindex: false,
|
||||
deferIndex: this.deferSessionIndex,
|
||||
}).then(() => undefined)
|
||||
: Promise.resolve();
|
||||
await this.pendingSyncWork;
|
||||
}
|
||||
@@ -333,20 +402,29 @@ describe("session startup catch-up", () => {
|
||||
restoreStartupEnv();
|
||||
clearRuntimeConfigSnapshot();
|
||||
clearConfigCache();
|
||||
for (const database of startupHarnessDatabases) {
|
||||
database.close();
|
||||
}
|
||||
startupHarnessDatabases.clear();
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeSessionFile(
|
||||
name: string,
|
||||
content = "startup catchup",
|
||||
timestamp?: string,
|
||||
): Promise<{ filePath: string; size: number; mtimeMs: number }> {
|
||||
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
const filePath = path.join(sessionsDir, name);
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
JSON.stringify({ type: "message", message: { role: "user", content: "startup catchup" } }) +
|
||||
"\n",
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
...(timestamp ? { timestamp } : {}),
|
||||
message: { role: "user", content },
|
||||
}) + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
const stat = await fs.stat(filePath);
|
||||
@@ -533,6 +611,169 @@ describe("session startup catch-up", () => {
|
||||
expect(harness.syncCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("indexes a same-size file transcript whose mtime rolled back", async () => {
|
||||
const archiveName = "thread.jsonl.deleted.2026-08-01T10-00-00.000Z";
|
||||
const original = await writeSessionFile(archiveName, "version before");
|
||||
const originalEntry = await buildSessionEntry(original.filePath);
|
||||
if (!originalEntry) {
|
||||
throw new Error("expected original file transcript entry");
|
||||
}
|
||||
const replacement = await writeSessionFile(archiveName, "version after!");
|
||||
expect(replacement.size).toBe(original.size);
|
||||
const rolledBackMtime = new Date(Math.max(1, original.mtimeMs - 60_000));
|
||||
await fs.utimes(replacement.filePath, rolledBackMtime, rolledBackMtime);
|
||||
const rolledBack = await fs.stat(replacement.filePath);
|
||||
expect(rolledBack.mtimeMs).toBeLessThan(original.mtimeMs);
|
||||
|
||||
const harness = new SessionStartupCatchupHarness(
|
||||
[
|
||||
{
|
||||
path: originalEntry.path,
|
||||
hash: originalEntry.hash,
|
||||
mtime: original.mtimeMs,
|
||||
size: original.size,
|
||||
},
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
await expect(harness.catchUp()).resolves.toEqual([replacement.filePath]);
|
||||
await harness.waitForSessionSync();
|
||||
|
||||
expect(harness.syncCalls).toEqual([{ reason: "session-startup-catchup" }]);
|
||||
expect(harness.indexedPaths).toEqual([`sessions/main/${archiveName}`]);
|
||||
expect(harness.indexedContents).toEqual(["User: version after!"]);
|
||||
});
|
||||
|
||||
it("converges an unchanged file mtime rollback after direct session sync", async () => {
|
||||
const archiveName = "thread.jsonl.deleted.2026-08-01T11-00-00.000Z";
|
||||
const messageTimestamp = "2026-08-01T10:30:00.000Z";
|
||||
const original = await writeSessionFile(archiveName, "unchanged content", messageTimestamp);
|
||||
const originalEntry = await buildSessionEntry(original.filePath);
|
||||
if (!originalEntry) {
|
||||
throw new Error("expected original file transcript entry");
|
||||
}
|
||||
const rolledBackMtime = new Date(Math.max(1, original.mtimeMs - 60_000));
|
||||
await fs.utimes(original.filePath, rolledBackMtime, rolledBackMtime);
|
||||
const restoredEntry = await buildSessionEntry(original.filePath);
|
||||
if (!restoredEntry) {
|
||||
throw new Error("expected restored file transcript entry");
|
||||
}
|
||||
expect(restoredEntry.hash).toBe(originalEntry.hash);
|
||||
|
||||
const harness = new SessionStartupCatchupHarness(
|
||||
[
|
||||
{
|
||||
path: originalEntry.path,
|
||||
hash: originalEntry.hash,
|
||||
mtime: original.mtimeMs,
|
||||
size: original.size,
|
||||
},
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
await expect(harness.catchUp()).resolves.toEqual([original.filePath]);
|
||||
await harness.waitForSessionSync();
|
||||
expect(harness.indexedPaths).toEqual([]);
|
||||
expect(harness.getIndexedSourceState(originalEntry.path)).toEqual({
|
||||
path: originalEntry.path,
|
||||
hash: originalEntry.hash,
|
||||
mtime: restoredEntry.mtimeMs,
|
||||
size: restoredEntry.size,
|
||||
});
|
||||
expect(harness.getSourceMetadataUpdateCount()).toBe(1);
|
||||
|
||||
const restarted = harness.restartForStartup();
|
||||
await expect(restarted.catchUp()).resolves.toEqual([]);
|
||||
expect(restarted.syncCalls).toEqual([]);
|
||||
expect(restarted.indexedPaths).toEqual([]);
|
||||
});
|
||||
|
||||
it("indexes a SQLite transcript whose updatedAt rolled back", async () => {
|
||||
const session = await writeSqliteSession({
|
||||
content: "SQLite rollback",
|
||||
updatedAt: 10,
|
||||
});
|
||||
const state = statSessionEntrySync(session.sessionKey, {
|
||||
agentId: "main",
|
||||
sessionId: session.sessionId,
|
||||
storePath: session.storePath,
|
||||
sessionKey: session.sessionKey,
|
||||
updatedAtMs: 10,
|
||||
});
|
||||
if (!state) {
|
||||
throw new Error("expected SQLite transcript state");
|
||||
}
|
||||
const harness = new SessionStartupCatchupHarness(
|
||||
[
|
||||
{
|
||||
path: state.path,
|
||||
hash: "previous-hash",
|
||||
mtime: 20,
|
||||
size: state.size,
|
||||
},
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
await expect(harness.catchUp()).resolves.toEqual([session.sessionKey]);
|
||||
await harness.waitForSessionSync();
|
||||
|
||||
expect(harness.syncCalls).toEqual([{ reason: "session-startup-catchup" }]);
|
||||
expect(harness.indexedPaths).toEqual([session.corpusPath]);
|
||||
expect(harness.indexedContents).toEqual(["User: SQLite rollback"]);
|
||||
});
|
||||
|
||||
it("converges an unchanged SQLite updatedAt rollback after deferred session sync", async () => {
|
||||
const session = await writeSqliteSession({ updatedAt: 10 });
|
||||
const entry = await buildSessionEntry(session.sessionKey, {
|
||||
agentId: "main",
|
||||
sessionId: session.sessionId,
|
||||
storePath: session.storePath,
|
||||
sessionKey: session.sessionKey,
|
||||
updatedAtMs: 10,
|
||||
sessionKind: "interactive",
|
||||
});
|
||||
if (!entry) {
|
||||
throw new Error("expected SQLite transcript entry");
|
||||
}
|
||||
const harness = new SessionStartupCatchupHarness(
|
||||
[
|
||||
{
|
||||
path: entry.path,
|
||||
hash: entry.hash,
|
||||
mtime: 20,
|
||||
size: entry.size,
|
||||
},
|
||||
],
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
await expect(harness.catchUp()).resolves.toEqual([session.sessionKey]);
|
||||
await harness.waitForSessionSync();
|
||||
|
||||
expect(harness.syncCalls).toEqual([{ reason: "session-startup-catchup" }]);
|
||||
expect(harness.indexedPaths).toEqual([]);
|
||||
expect(harness.indexedContents).toEqual([]);
|
||||
expect(harness.getIndexedSourceState(entry.path)).toEqual({
|
||||
path: entry.path,
|
||||
hash: entry.hash,
|
||||
mtime: entry.mtimeMs,
|
||||
size: entry.size,
|
||||
});
|
||||
expect(harness.getSourceMetadataUpdateCount()).toBe(1);
|
||||
|
||||
const restarted = harness.restartForStartup();
|
||||
await expect(restarted.catchUp()).resolves.toEqual([]);
|
||||
expect(restarted.syncCalls).toEqual([]);
|
||||
expect(restarted.indexedPaths).toEqual([]);
|
||||
await restarted.runArchiveSyncForTest();
|
||||
expect(restarted.getSourceMetadataUpdateCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("does not fall back to full session sync when identity targets normalize away", async () => {
|
||||
await writeSessionFile("thread.jsonl");
|
||||
const harness = new SessionStartupCatchupHarness([]);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Openai tests cover memory embedding adapter plugin behavior.
|
||||
import type { MemoryEmbeddingProvider } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
resolveRemoteEmbeddingBearerClient,
|
||||
type MemoryEmbeddingProvider,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { hashText } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createOpenAiEmbeddingProvider: vi.fn(),
|
||||
@@ -27,6 +31,10 @@ const provider: MemoryEmbeddingProvider = {
|
||||
};
|
||||
|
||||
describe("OpenAI memory embedding adapter", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.createOpenAiEmbeddingProvider.mockReset();
|
||||
mocks.runOpenAiEmbeddingBatches.mockClear();
|
||||
@@ -43,6 +51,105 @@ describe("OpenAI memory embedding adapter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps native OpenAI embedding cache identity stable across OpenClaw versions", async () => {
|
||||
const createForVersion = async (version: string) => {
|
||||
vi.stubEnv("OPENCLAW_VERSION", version);
|
||||
const client = await resolveRemoteEmbeddingBearerClient({
|
||||
provider: "openai",
|
||||
defaultBaseUrl: "https://api.openai.com/v1",
|
||||
options: {
|
||||
config: { models: {} } as never,
|
||||
model: "text-embedding-3-small",
|
||||
remote: { apiKey: "fixture-secret" },
|
||||
},
|
||||
});
|
||||
mocks.createOpenAiEmbeddingProvider.mockResolvedValueOnce({
|
||||
provider,
|
||||
client: { ...client, model: "text-embedding-3-small" },
|
||||
});
|
||||
const result = await openAiMemoryEmbeddingProviderAdapter.create({
|
||||
config: {} as never,
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
fallback: "none",
|
||||
});
|
||||
return { headers: client.headers, cacheKeyData: result.runtime?.cacheKeyData };
|
||||
};
|
||||
|
||||
const previous = await createForVersion("2026.7.1");
|
||||
const current = await createForVersion("2026.7.2");
|
||||
|
||||
expect(previous.headers).toMatchObject({
|
||||
Authorization: "Bearer fixture-secret",
|
||||
version: "2026.7.1",
|
||||
"User-Agent": "openclaw/2026.7.1",
|
||||
});
|
||||
expect(current.headers).toMatchObject({
|
||||
Authorization: "Bearer fixture-secret",
|
||||
version: "2026.7.2",
|
||||
"User-Agent": "openclaw/2026.7.2",
|
||||
});
|
||||
expect(current.cacheKeyData).toEqual(previous.cacheKeyData);
|
||||
expect(hashText(JSON.stringify(current.cacheKeyData))).toBe(
|
||||
hashText(JSON.stringify(previous.cacheKeyData)),
|
||||
);
|
||||
expect(current.cacheKeyData).toMatchObject({
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
model: "text-embedding-3-small",
|
||||
headers: [
|
||||
["Content-Type", "application/json"],
|
||||
["originator", "openclaw"],
|
||||
],
|
||||
});
|
||||
expect(JSON.stringify(current.cacheKeyData)).not.toContain("fixture-secret");
|
||||
});
|
||||
|
||||
it("preserves custom endpoint tenant and version-like cache identity headers", async () => {
|
||||
const createForTenant = async (tenant: string) => {
|
||||
const client = await resolveRemoteEmbeddingBearerClient({
|
||||
provider: "bailian-embedding",
|
||||
defaultBaseUrl: "https://embeddings.example/v1",
|
||||
options: {
|
||||
config: { models: {} } as never,
|
||||
model: "text-embedding-v3",
|
||||
remote: {
|
||||
apiKey: "fixture-secret",
|
||||
headers: {
|
||||
"X-Tenant": tenant,
|
||||
version: "tenant-api-v2",
|
||||
"User-Agent": "tenant-client/2",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
mocks.createOpenAiEmbeddingProvider.mockResolvedValueOnce({
|
||||
provider,
|
||||
client: { ...client, model: "text-embedding-v3" },
|
||||
});
|
||||
return await openAiMemoryEmbeddingProviderAdapter.create({
|
||||
config: {} as never,
|
||||
provider: "bailian-embedding",
|
||||
model: "text-embedding-v3",
|
||||
fallback: "none",
|
||||
});
|
||||
};
|
||||
|
||||
const first = await createForTenant("tenant-a");
|
||||
const second = await createForTenant("tenant-b");
|
||||
const headers = first.runtime?.cacheKeyData?.headers;
|
||||
|
||||
expect(headers).toEqual(
|
||||
expect.arrayContaining([
|
||||
["X-Tenant", "tenant-a"],
|
||||
["version", "tenant-api-v2"],
|
||||
["User-Agent", "tenant-client/2"],
|
||||
]),
|
||||
);
|
||||
expect(first.runtime?.cacheKeyData).not.toEqual(second.runtime?.cacheKeyData);
|
||||
expect(JSON.stringify(first.runtime?.cacheKeyData)).not.toContain("fixture-secret");
|
||||
});
|
||||
|
||||
it("sends document input_type in OpenAI batch embedding requests", async () => {
|
||||
const result = await openAiMemoryEmbeddingProviderAdapter.create({
|
||||
config: {} as never,
|
||||
|
||||
@@ -11,6 +11,23 @@ import {
|
||||
DEFAULT_OPENAI_EMBEDDING_MODEL,
|
||||
} from "./embedding-provider.js";
|
||||
|
||||
function resolveEmbeddingCacheExcludedHeaders(providerId: string, baseUrl: string): string[] {
|
||||
const excludedHeaders = ["authorization"];
|
||||
if (providerId !== "openai") {
|
||||
return excludedHeaders;
|
||||
}
|
||||
try {
|
||||
if (new URL(baseUrl).hostname.toLowerCase().replace(/\.+$/, "") === "api.openai.com") {
|
||||
// Native attribution changes on every upgrade; cache identity must describe embeddings,
|
||||
// not the OpenClaw build that requested them.
|
||||
excludedHeaders.push("version", "user-agent");
|
||||
}
|
||||
} catch {
|
||||
// Invalid URLs are handled by the embedding client; keep existing custom-header identity.
|
||||
}
|
||||
return excludedHeaders;
|
||||
}
|
||||
|
||||
export const openAiMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = {
|
||||
id: "openai",
|
||||
defaultModel: DEFAULT_OPENAI_EMBEDDING_MODEL,
|
||||
@@ -37,7 +54,10 @@ export const openAiMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapte
|
||||
model: client.model,
|
||||
outputDimensionality: client.outputDimensionality,
|
||||
documentInputType: client.documentInputType ?? client.inputType,
|
||||
headers: sanitizeEmbeddingCacheHeaders(client.headers, ["authorization"]),
|
||||
headers: sanitizeEmbeddingCacheHeaders(
|
||||
client.headers,
|
||||
resolveEmbeddingCacheExcludedHeaders(resolvedProvider, client.baseUrl),
|
||||
),
|
||||
},
|
||||
batchEmbed: async (batch) => {
|
||||
const inputType = client.documentInputType ?? client.inputType;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { once } from "node:events";
|
||||
import type { RealtimeVoiceBridge } from "openclaw/plugin-sdk/realtime-voice";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import WebSocket, { type RawData, WebSocketServer } from "ws";
|
||||
import { OpenAIQuicksilverVoiceBridge } from "./realtime-quicksilver-bridge.js";
|
||||
import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js";
|
||||
|
||||
type RealtimeProviderKind = "native" | "gpt-live";
|
||||
|
||||
function parseWebSocketMessage(data: RawData): Record<string, unknown> {
|
||||
const bytes = Buffer.isBuffer(data)
|
||||
? data
|
||||
: Array.isArray(data)
|
||||
? Buffer.concat(data)
|
||||
: Buffer.from(data);
|
||||
return JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function withRealtimeProvider(
|
||||
kind: RealtimeProviderKind,
|
||||
prepareAudio: (bridge: RealtimeVoiceBridge) => void,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
const audioEventType = kind === "native" ? "input_audio_buffer.append" : "input_audio.append";
|
||||
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected an available local realtime WebSocket address");
|
||||
}
|
||||
const received: Array<Record<string, unknown>> = [];
|
||||
server.once("connection", (socket) => {
|
||||
socket.on("message", (payload) => {
|
||||
const event = parseWebSocketMessage(payload);
|
||||
received.push(event);
|
||||
if (event.type === "session.update") {
|
||||
socket.send(
|
||||
JSON.stringify(
|
||||
kind === "native"
|
||||
? { type: "session.updated" }
|
||||
: {
|
||||
type: "session.started",
|
||||
session: { id: "fixture-live", expires_at: Math.floor(Date.now() / 1000) + 60 },
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const endpoint = `http://127.0.0.1:${address.port}`;
|
||||
const bridge =
|
||||
kind === "native"
|
||||
? buildOpenAIRealtimeVoiceProvider().createBridge({
|
||||
providerConfig: {
|
||||
apiKey: "fixture-local", // pragma: allowlist secret
|
||||
azureEndpoint: endpoint,
|
||||
azureDeployment: "fixture-realtime",
|
||||
},
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
})
|
||||
: new OpenAIQuicksilverVoiceBridge({
|
||||
providerConfig: {},
|
||||
model: "gpt-live-1-codex",
|
||||
audioFormat: { encoding: "pcm16", sampleRateHz: 24000, channels: 1 },
|
||||
resolveAuth: async () => ({ type: "api-key", token: "fixture-local" }),
|
||||
webSocketFactory: (_url, options) => new WebSocket(endpoint, options),
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
});
|
||||
|
||||
try {
|
||||
prepareAudio(bridge);
|
||||
await bridge.connect();
|
||||
await vi.waitFor(() => {
|
||||
expect(received.some((event) => event.type === audioEventType)).toBe(true);
|
||||
});
|
||||
return received;
|
||||
} finally {
|
||||
bridge.close();
|
||||
for (const client of server.clients) {
|
||||
client.terminate();
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("OpenAI realtime queued audio buffer ownership", () => {
|
||||
it.each<RealtimeProviderKind>(["native", "gpt-live"])(
|
||||
"%s preserves each reusable producer frame until the real WebSocket is ready",
|
||||
async (kind) => {
|
||||
const audioEventType = kind === "native" ? "input_audio_buffer.append" : "input_audio.append";
|
||||
const received = await withRealtimeProvider(kind, (bridge) => {
|
||||
const producerAllocation = Buffer.alloc(2 * 1024 * 1024, 0x7f);
|
||||
const producerView = producerAllocation.subarray(0, 1);
|
||||
bridge.sendAudio(producerView);
|
||||
producerAllocation[0] = 0x41;
|
||||
bridge.sendAudio(producerView);
|
||||
producerAllocation[0] = 0;
|
||||
});
|
||||
|
||||
expect(received.filter((event) => event.type === audioEventType)).toEqual([
|
||||
{ type: audioEventType, audio: "fw==" },
|
||||
{ type: audioEventType, audio: "QQ==" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each<RealtimeProviderKind>(["native", "gpt-live"])(
|
||||
"%s rejects oversized producer frames before allocating a queued copy",
|
||||
async (kind) => {
|
||||
const audioEventType = kind === "native" ? "input_audio_buffer.append" : "input_audio.append";
|
||||
const received = await withRealtimeProvider(kind, (bridge) => {
|
||||
const oversized = Buffer.alloc(1024 * 1024 + 1);
|
||||
const copyBuffer = vi.spyOn(Buffer, "from");
|
||||
try {
|
||||
bridge.sendAudio(oversized);
|
||||
expect(copyBuffer).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
copyBuffer.mockRestore();
|
||||
}
|
||||
bridge.sendAudio(Buffer.from([0x7f]));
|
||||
});
|
||||
|
||||
expect(received.filter((event) => event.type === audioEventType)).toEqual([
|
||||
{ type: audioEventType, audio: "fw==" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -568,8 +568,10 @@ export class OpenAIQuicksilverVoiceBridge implements RealtimeVoiceBridge {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.pendingAudio.push(audio);
|
||||
this.pendingAudioBytes += audio.byteLength;
|
||||
// Capture transports can recycle caller-owned views before the provider becomes ready.
|
||||
const queuedAudio = Buffer.from(audio);
|
||||
this.pendingAudio.push(queuedAudio);
|
||||
this.pendingAudioBytes += queuedAudio.byteLength;
|
||||
}
|
||||
|
||||
private resetTerminalState(): void {
|
||||
|
||||
@@ -1684,8 +1684,10 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.pendingAudio.push(audio);
|
||||
this.pendingAudioBytes += audio.byteLength;
|
||||
// Capture transports can recycle caller-owned views before the provider becomes ready.
|
||||
const queuedAudio = Buffer.from(audio);
|
||||
this.pendingAudio.push(queuedAudio);
|
||||
this.pendingAudioBytes += queuedAudio.byteLength;
|
||||
}
|
||||
|
||||
private clearPendingAudio(): void {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import type {
|
||||
HealthCheckContext,
|
||||
HealthFinding,
|
||||
HealthRepairContext,
|
||||
HealthRepairResult,
|
||||
} from "openclaw/plugin-sdk/health";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createPolicyScopedChecks } from "./check-factory.js";
|
||||
import { CHECK_IDS } from "./check-ids.js";
|
||||
import type { PolicyEvaluation } from "./types.js";
|
||||
|
||||
describe("policy scoped health checks", () => {
|
||||
const evaluation = {} as PolicyEvaluation;
|
||||
const context = {} as HealthCheckContext;
|
||||
|
||||
it("preserves registration order, descriptions, metadata, and repair capability", () => {
|
||||
const repair = vi.fn(async (): Promise<HealthRepairResult> => ({ changes: [] }));
|
||||
const checks = createPolicyScopedChecks(
|
||||
{
|
||||
evaluatePolicy: vi.fn(async () => evaluation),
|
||||
findingsForCheck: vi.fn(() => []),
|
||||
},
|
||||
[
|
||||
[CHECK_IDS.policyMissingFile, "The policy file exists."],
|
||||
[CHECK_IDS.policyDeniedChannelProvider, "Channels satisfy policy.", repair],
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
checks.map(({ id, description, kind, source }) => ({ id, description, kind, source })),
|
||||
).toEqual([
|
||||
{
|
||||
id: CHECK_IDS.policyMissingFile,
|
||||
description: "The policy file exists.",
|
||||
kind: "plugin",
|
||||
source: "policy",
|
||||
},
|
||||
{
|
||||
id: CHECK_IDS.policyDeniedChannelProvider,
|
||||
description: "Channels satisfy policy.",
|
||||
kind: "plugin",
|
||||
source: "policy",
|
||||
},
|
||||
]);
|
||||
expect(Object.hasOwn(checks[0]!, "repair")).toBe(false);
|
||||
expect(Object.hasOwn(checks[1]!, "repair")).toBe(true);
|
||||
expect(checks[1]).toMatchObject({ repair });
|
||||
});
|
||||
|
||||
it("awaits the policy evaluation before selecting findings for the same check", async () => {
|
||||
const findings: HealthFinding[] = [
|
||||
{ checkId: CHECK_IDS.policyMissingFile, severity: "error", message: "Missing policy." },
|
||||
];
|
||||
let releaseEvaluation!: () => void;
|
||||
const evaluationGate = new Promise<void>((resolve) => {
|
||||
releaseEvaluation = resolve;
|
||||
});
|
||||
const evaluatePolicy = vi.fn(async (received: HealthCheckContext) => {
|
||||
expect(received).toBe(context);
|
||||
await evaluationGate;
|
||||
return evaluation;
|
||||
});
|
||||
const findingsForCheck = vi.fn(() => findings);
|
||||
const [check] = createPolicyScopedChecks({ evaluatePolicy, findingsForCheck }, [
|
||||
[CHECK_IDS.policyMissingFile, "The policy file exists."],
|
||||
]);
|
||||
|
||||
const result = check!.detect(context);
|
||||
expect(evaluatePolicy).toHaveBeenCalledOnce();
|
||||
expect(findingsForCheck).not.toHaveBeenCalled();
|
||||
|
||||
releaseEvaluation();
|
||||
await expect(result).resolves.toBe(findings);
|
||||
expect(findingsForCheck).toHaveBeenCalledExactlyOnceWith(
|
||||
evaluation,
|
||||
CHECK_IDS.policyMissingFile,
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates evaluation failures without selecting findings", async () => {
|
||||
const failure = new Error("policy evaluation failed");
|
||||
const evaluatePolicy = vi.fn(async () => {
|
||||
throw failure;
|
||||
});
|
||||
const findingsForCheck = vi.fn(() => []);
|
||||
const [check] = createPolicyScopedChecks({ evaluatePolicy, findingsForCheck }, [
|
||||
[CHECK_IDS.policyMissingFile, "The policy file exists."],
|
||||
]);
|
||||
|
||||
await expect(check!.detect(context)).rejects.toBe(failure);
|
||||
expect(findingsForCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains the original repair callback and its exact result promise", () => {
|
||||
const repairContext = {} as HealthRepairContext;
|
||||
const findings: HealthFinding[] = [];
|
||||
const pendingRepair = Promise.resolve<HealthRepairResult>({ changes: ["repaired"] });
|
||||
const repair = vi.fn(() => pendingRepair);
|
||||
const [check] = createPolicyScopedChecks(
|
||||
{
|
||||
evaluatePolicy: vi.fn(async () => evaluation),
|
||||
findingsForCheck: vi.fn(() => []),
|
||||
},
|
||||
[[CHECK_IDS.policyDeniedChannelProvider, "Channels satisfy policy.", repair]],
|
||||
);
|
||||
|
||||
expect(check).toMatchObject({ repair });
|
||||
expect(check!.repair!(repairContext, findings)).toBe(pendingRepair);
|
||||
expect(repair).toHaveBeenCalledExactlyOnceWith(repairContext, findings);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { HealthCheck } from "openclaw/plugin-sdk/health";
|
||||
import type { POLICY_CHECK_IDS } from "./check-ids.js";
|
||||
import type { PolicyDoctorCheckDeps } from "./types.js";
|
||||
|
||||
type PolicyDoctorCheckDefinition = readonly [
|
||||
id: (typeof POLICY_CHECK_IDS)[number],
|
||||
description: string,
|
||||
repair?: NonNullable<HealthCheck["repair"]>,
|
||||
];
|
||||
|
||||
export function createPolicyScopedChecks(
|
||||
deps: Pick<PolicyDoctorCheckDeps, "evaluatePolicy" | "findingsForCheck">,
|
||||
definitions: readonly PolicyDoctorCheckDefinition[],
|
||||
): readonly HealthCheck[] {
|
||||
const { evaluatePolicy, findingsForCheck } = deps;
|
||||
return definitions.map(([id, description, repair]) => ({
|
||||
id,
|
||||
kind: "plugin",
|
||||
description,
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), id);
|
||||
},
|
||||
...(repair ? { repair } : {}),
|
||||
}));
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Policy doctor health-check factories for one policy scope.
|
||||
import type { HealthCheck } from "openclaw/plugin-sdk/health";
|
||||
import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js";
|
||||
import { createPolicyScopedChecks } from "../check-factory.js";
|
||||
import { CHECK_IDS } from "../check-ids.js";
|
||||
import type { PolicyDoctorCheckDeps } from "../types.js";
|
||||
|
||||
@@ -10,109 +11,66 @@ export function createPolicyChannelProviderChecks(
|
||||
const {
|
||||
channelIdsFromFindings,
|
||||
disableChannels,
|
||||
evaluatePolicy,
|
||||
findingsForCheck,
|
||||
workspaceRepairsDisabledResult,
|
||||
workspaceRepairsEnabled,
|
||||
} = deps;
|
||||
|
||||
const policyChannelsDeniedProviderCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyDeniedChannelProvider,
|
||||
kind: "plugin",
|
||||
description: "Configured channels satisfy policy deny rules.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyDeniedChannelProvider);
|
||||
},
|
||||
async repair(ctx, findings) {
|
||||
if (!workspaceRepairsEnabled(ctx)) {
|
||||
return workspaceRepairsDisabledResult("channel config");
|
||||
}
|
||||
const channelIds = channelIdsFromFindings(findings);
|
||||
if (channelIds.length === 0) {
|
||||
return createPolicyScopedChecks(deps, [
|
||||
[
|
||||
CHECK_IDS.policyDeniedChannelProvider,
|
||||
"Configured channels satisfy policy deny rules.",
|
||||
async (ctx, findings) => {
|
||||
if (!workspaceRepairsEnabled(ctx)) {
|
||||
return workspaceRepairsDisabledResult("channel config");
|
||||
}
|
||||
const channelIds = channelIdsFromFindings(findings);
|
||||
if (channelIds.length === 0) {
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "no channel findings matched a configurable channel",
|
||||
changes: [],
|
||||
};
|
||||
}
|
||||
const next = disableChannels(ctx.cfg, channelIds);
|
||||
if (next.changed.length === 0) {
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "matching channels were already disabled or missing",
|
||||
changes: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "no channel findings matched a configurable channel",
|
||||
changes: [],
|
||||
config: next.config,
|
||||
changes: next.changed.map(
|
||||
(id) => `Disabled channels.${id}.enabled for policy conformance.`,
|
||||
),
|
||||
};
|
||||
}
|
||||
const next = disableChannels(ctx.cfg, channelIds);
|
||||
if (next.changed.length === 0) {
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "matching channels were already disabled or missing",
|
||||
changes: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
config: next.config,
|
||||
changes: next.changed.map(
|
||||
(id) => `Disabled channels.${id}.enabled for policy conformance.`,
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return [policyChannelsDeniedProviderCheck];
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
export function createPolicyIngressChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
|
||||
const { evaluatePolicy, findingsForCheck } = deps;
|
||||
|
||||
const policyIngressDmPolicyUnapprovedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyIngressDmPolicyUnapproved,
|
||||
kind: "plugin",
|
||||
description: "Channel direct-message access policy matches ingress requirements.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyIngressDmPolicyUnapproved);
|
||||
},
|
||||
};
|
||||
const policyIngressDmScopeUnapprovedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyIngressDmScopeUnapproved,
|
||||
kind: "plugin",
|
||||
description: "Direct-message sessions use the policy-required isolation scope.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyIngressDmScopeUnapproved);
|
||||
},
|
||||
};
|
||||
const policyIngressOpenGroupsDeniedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyIngressOpenGroupsDenied,
|
||||
kind: "plugin",
|
||||
description: "Channel group access does not use open group policy when denied.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyIngressOpenGroupsDenied);
|
||||
},
|
||||
async repair(ctx, findings) {
|
||||
return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyIngressOpenGroupsDenied);
|
||||
},
|
||||
};
|
||||
const policyIngressGroupMentionRequiredCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyIngressGroupMentionRequired,
|
||||
kind: "plugin",
|
||||
description: "Channel group access keeps mention gates enabled when required.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyIngressGroupMentionRequired,
|
||||
);
|
||||
},
|
||||
async repair(ctx, findings) {
|
||||
return repairPolicyAutomaticNarrower(
|
||||
ctx,
|
||||
findings,
|
||||
CHECK_IDS.policyIngressGroupMentionRequired,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
policyIngressDmPolicyUnapprovedCheck,
|
||||
policyIngressDmScopeUnapprovedCheck,
|
||||
policyIngressOpenGroupsDeniedCheck,
|
||||
policyIngressGroupMentionRequiredCheck,
|
||||
];
|
||||
return createPolicyScopedChecks(deps, [
|
||||
[
|
||||
CHECK_IDS.policyIngressDmPolicyUnapproved,
|
||||
"Channel direct-message access policy matches ingress requirements.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyIngressDmScopeUnapproved,
|
||||
"Direct-message sessions use the policy-required isolation scope.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyIngressOpenGroupsDenied,
|
||||
"Channel group access does not use open group policy when denied.",
|
||||
async (ctx, findings) =>
|
||||
repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyIngressOpenGroupsDenied),
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyIngressGroupMentionRequired,
|
||||
"Channel group access keeps mention gates enabled when required.",
|
||||
async (ctx, findings) =>
|
||||
repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyIngressGroupMentionRequired),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,52 +1,17 @@
|
||||
// Policy doctor health-check factories for one policy scope.
|
||||
import type { HealthCheck } from "openclaw/plugin-sdk/health";
|
||||
import { createPolicyScopedChecks } from "../check-factory.js";
|
||||
import { CHECK_IDS } from "../check-ids.js";
|
||||
import type { PolicyDoctorCheckDeps } from "../types.js";
|
||||
|
||||
export function createPolicyCoreChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
|
||||
const { evaluatePolicy, findingsForCheck } = deps;
|
||||
|
||||
const policyMissingFileCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyMissingFile,
|
||||
kind: "plugin",
|
||||
description: "The enabled Policy plugin has a policy file to verify.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingFile);
|
||||
},
|
||||
};
|
||||
const policyHashMismatchCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyHashMismatch,
|
||||
kind: "plugin",
|
||||
description: "The policy file matches the configured expected hash.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyHashMismatch);
|
||||
},
|
||||
};
|
||||
const policyAttestationMismatchCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyAttestationMismatch,
|
||||
kind: "plugin",
|
||||
description: "The current policy check matches the accepted attestation.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyAttestationMismatch);
|
||||
},
|
||||
};
|
||||
const policyInvalidFileCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyInvalidFile,
|
||||
kind: "plugin",
|
||||
description: "The enabled policy file parses before policy checks run.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyInvalidFile);
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
policyMissingFileCheck,
|
||||
policyInvalidFileCheck,
|
||||
policyHashMismatchCheck,
|
||||
policyAttestationMismatchCheck,
|
||||
];
|
||||
return createPolicyScopedChecks(deps, [
|
||||
[CHECK_IDS.policyMissingFile, "The enabled Policy plugin has a policy file to verify."],
|
||||
[CHECK_IDS.policyInvalidFile, "The enabled policy file parses before policy checks run."],
|
||||
[CHECK_IDS.policyHashMismatch, "The policy file matches the configured expected hash."],
|
||||
[
|
||||
CHECK_IDS.policyAttestationMismatch,
|
||||
"The current policy check matches the accepted attestation.",
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,118 +1,49 @@
|
||||
// Policy doctor health-check factories for one policy scope.
|
||||
import type { HealthCheck } from "openclaw/plugin-sdk/health";
|
||||
import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js";
|
||||
import { createPolicyScopedChecks } from "../check-factory.js";
|
||||
import { CHECK_IDS } from "../check-ids.js";
|
||||
import type { PolicyDoctorCheckDeps } from "../types.js";
|
||||
|
||||
export function createPolicyDataAuthChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
|
||||
const { evaluatePolicy, findingsForCheck } = deps;
|
||||
|
||||
const policyDataHandlingTelemetryContentCaptureCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyDataHandlingTelemetryContentCapture,
|
||||
kind: "plugin",
|
||||
description: "Telemetry content capture remains disabled when policy denies it.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyDataHandlingTelemetryContentCapture,
|
||||
);
|
||||
},
|
||||
repair(ctx, findings) {
|
||||
return repairPolicyAutomaticNarrower(
|
||||
ctx,
|
||||
findings,
|
||||
CHECK_IDS.policyDataHandlingTelemetryContentCapture,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyDataHandlingSessionRetentionNotEnforcedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced,
|
||||
kind: "plugin",
|
||||
description: "Session retention maintenance is enforced when policy requires it.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyDataHandlingSessionTranscriptMemoryCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyDataHandlingSessionTranscriptMemory,
|
||||
kind: "plugin",
|
||||
description: "Session transcript memory indexing remains disabled when policy denies it.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyDataHandlingSessionTranscriptMemory,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policySecretsUnmanagedProviderCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySecretsUnmanagedProvider,
|
||||
kind: "plugin",
|
||||
description:
|
||||
return createPolicyScopedChecks(deps, [
|
||||
[
|
||||
CHECK_IDS.policyDataHandlingTelemetryContentCapture,
|
||||
"Telemetry content capture remains disabled when policy denies it.",
|
||||
(ctx, findings) =>
|
||||
repairPolicyAutomaticNarrower(
|
||||
ctx,
|
||||
findings,
|
||||
CHECK_IDS.policyDataHandlingTelemetryContentCapture,
|
||||
),
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced,
|
||||
"Session retention maintenance is enforced when policy requires it.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyDataHandlingSessionTranscriptMemory,
|
||||
"Session transcript memory indexing remains disabled when policy denies it.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policySecretsUnmanagedProvider,
|
||||
"OpenClaw config SecretRefs use configured secret providers when policy requires managed providers.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySecretsUnmanagedProvider);
|
||||
},
|
||||
};
|
||||
const policySecretsDeniedProviderSourceCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySecretsDeniedProviderSource,
|
||||
kind: "plugin",
|
||||
description:
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policySecretsDeniedProviderSource,
|
||||
"OpenClaw config secret providers and SecretRefs do not use sources denied by policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policySecretsDeniedProviderSource,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policySecretsInsecureProviderCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySecretsInsecureProvider,
|
||||
kind: "plugin",
|
||||
description:
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policySecretsInsecureProvider,
|
||||
"Configured secret providers do not opt into insecure posture unless policy allows it.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySecretsInsecureProvider);
|
||||
},
|
||||
};
|
||||
const policyAuthProfileInvalidMetadataCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyAuthProfileInvalidMetadata,
|
||||
kind: "plugin",
|
||||
description: "OpenClaw config auth profiles declare required provider and mode metadata.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyAuthProfileInvalidMetadata,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyAuthProfileUnapprovedModeCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyAuthProfileUnapprovedMode,
|
||||
kind: "plugin",
|
||||
description: "OpenClaw config auth profile modes stay within the policy allowlist.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyAuthProfileUnapprovedMode);
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
policyDataHandlingTelemetryContentCaptureCheck,
|
||||
policyDataHandlingSessionRetentionNotEnforcedCheck,
|
||||
policyDataHandlingSessionTranscriptMemoryCheck,
|
||||
policySecretsUnmanagedProviderCheck,
|
||||
policySecretsDeniedProviderSourceCheck,
|
||||
policySecretsInsecureProviderCheck,
|
||||
policyAuthProfileInvalidMetadataCheck,
|
||||
policyAuthProfileUnapprovedModeCheck,
|
||||
];
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyAuthProfileInvalidMetadata,
|
||||
"OpenClaw config auth profiles declare required provider and mode metadata.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyAuthProfileUnapprovedMode,
|
||||
"OpenClaw config auth profile modes stay within the policy allowlist.",
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,100 +1,40 @@
|
||||
// Policy doctor health-check factories for one policy scope.
|
||||
import type { HealthCheck } from "openclaw/plugin-sdk/health";
|
||||
import { createPolicyScopedChecks } from "../check-factory.js";
|
||||
import { CHECK_IDS } from "../check-ids.js";
|
||||
import type { PolicyDoctorCheckDeps } from "../types.js";
|
||||
|
||||
export function createPolicyExecApprovalChecks(
|
||||
deps: PolicyDoctorCheckDeps,
|
||||
): readonly HealthCheck[] {
|
||||
const { evaluatePolicy, findingsForCheck } = deps;
|
||||
|
||||
const policyExecApprovalsMissingCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyExecApprovalsMissing,
|
||||
kind: "plugin",
|
||||
description: "Required exec approvals artifact is present for policy conformance.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyExecApprovalsMissing);
|
||||
},
|
||||
};
|
||||
const policyExecApprovalsInvalidCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyExecApprovalsInvalid,
|
||||
kind: "plugin",
|
||||
description: "Exec approvals artifact parses before policy checks run.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyExecApprovalsInvalid);
|
||||
},
|
||||
};
|
||||
const policyExecApprovalsDefaultSecurityUnapprovedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved,
|
||||
kind: "plugin",
|
||||
description: "Exec approval defaults use a policy-approved security mode.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyExecApprovalsAgentSecurityUnapprovedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved,
|
||||
kind: "plugin",
|
||||
description: "Per-agent exec approval settings use policy-approved security modes.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyExecApprovalsAutoAllowSkillsEnabledCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled,
|
||||
kind: "plugin",
|
||||
description:
|
||||
return createPolicyScopedChecks(deps, [
|
||||
[
|
||||
CHECK_IDS.policyExecApprovalsMissing,
|
||||
"Required exec approvals artifact is present for policy conformance.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyExecApprovalsInvalid,
|
||||
"Exec approvals artifact parses before policy checks run.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved,
|
||||
"Exec approval defaults use a policy-approved security mode.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved,
|
||||
"Per-agent exec approval settings use policy-approved security modes.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled,
|
||||
"Exec approval agents do not implicitly auto-allow skill CLIs unless policy allows it.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyExecApprovalsAllowlistMissingCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyExecApprovalsAllowlistMissing,
|
||||
kind: "plugin",
|
||||
description: "Exec approval allowlists include every pattern required by policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyExecApprovalsAllowlistMissing,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyExecApprovalsAllowlistUnexpectedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyExecApprovalsAllowlistUnexpected,
|
||||
kind: "plugin",
|
||||
description: "Exec approval allowlists do not contain patterns outside policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyExecApprovalsAllowlistUnexpected,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
policyExecApprovalsMissingCheck,
|
||||
policyExecApprovalsInvalidCheck,
|
||||
policyExecApprovalsDefaultSecurityUnapprovedCheck,
|
||||
policyExecApprovalsAgentSecurityUnapprovedCheck,
|
||||
policyExecApprovalsAutoAllowSkillsEnabledCheck,
|
||||
policyExecApprovalsAllowlistMissingCheck,
|
||||
policyExecApprovalsAllowlistUnexpectedCheck,
|
||||
];
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyExecApprovalsAllowlistMissing,
|
||||
"Exec approval allowlists include every pattern required by policy.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyExecApprovalsAllowlistUnexpected,
|
||||
"Exec approval allowlists do not contain patterns outside policy.",
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -3,140 +3,58 @@ import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime";
|
||||
import type { HealthCheck, HealthFinding } from "openclaw/plugin-sdk/health";
|
||||
import type { PolicyEvidence } from "../../policy-state.js";
|
||||
import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js";
|
||||
import { createPolicyScopedChecks } from "../check-factory.js";
|
||||
import { CHECK_IDS } from "../check-ids.js";
|
||||
import { previewPolicyReviewRequiredRepair } from "../review-required-repairs.js";
|
||||
import type { PolicyDoctorCheckDeps } from "../types.js";
|
||||
import { readPolicyBoolean, readStringList } from "../utils.js";
|
||||
|
||||
export function createPolicyGatewayChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
|
||||
const { evaluatePolicy, findingsForCheck } = deps;
|
||||
|
||||
const policyGatewayNonLoopbackBindCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyGatewayNonLoopbackBind,
|
||||
kind: "plugin",
|
||||
description: "Gateway bind posture matches policy exposure requirements.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayNonLoopbackBind);
|
||||
},
|
||||
repair(ctx, findings) {
|
||||
return previewPolicyReviewRequiredRepair(
|
||||
ctx,
|
||||
findings,
|
||||
CHECK_IDS.policyGatewayNonLoopbackBind,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyGatewayAuthDisabledCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyGatewayAuthDisabled,
|
||||
kind: "plugin",
|
||||
description: "Gateway authentication remains enabled when required by policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayAuthDisabled);
|
||||
},
|
||||
};
|
||||
const policyGatewayRateLimitMissingCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyGatewayRateLimitMissing,
|
||||
kind: "plugin",
|
||||
description: "Gateway authentication rate-limit posture is explicit when required by policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayRateLimitMissing);
|
||||
},
|
||||
};
|
||||
const policyGatewayControlUiInsecureCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyGatewayControlUiInsecure,
|
||||
kind: "plugin",
|
||||
description: "Gateway Control UI insecure exposure toggles remain disabled by policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayControlUiInsecure);
|
||||
},
|
||||
repair(ctx, findings) {
|
||||
return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyGatewayControlUiInsecure);
|
||||
},
|
||||
};
|
||||
const policyGatewayTailscaleFunnelCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyGatewayTailscaleFunnel,
|
||||
kind: "plugin",
|
||||
description: "Gateway Tailscale Funnel exposure matches policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayTailscaleFunnel);
|
||||
},
|
||||
};
|
||||
const policyGatewayRemoteEnabledCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyGatewayRemoteEnabled,
|
||||
kind: "plugin",
|
||||
description: "Remote gateway mode matches policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayRemoteEnabled);
|
||||
},
|
||||
repair(ctx, findings) {
|
||||
return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyGatewayRemoteEnabled);
|
||||
},
|
||||
};
|
||||
const policyGatewayHttpEndpointEnabledCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyGatewayHttpEndpointEnabled,
|
||||
kind: "plugin",
|
||||
description: "Gateway HTTP API endpoints match policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyGatewayHttpEndpointEnabled,
|
||||
);
|
||||
},
|
||||
repair(ctx, findings) {
|
||||
return repairPolicyAutomaticNarrower(
|
||||
ctx,
|
||||
findings,
|
||||
CHECK_IDS.policyGatewayHttpEndpointEnabled,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyGatewayHttpUrlFetchUnrestrictedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted,
|
||||
kind: "plugin",
|
||||
description: "Gateway HTTP URL-fetch inputs have allowlists when required by policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyGatewayNodeCommandDeniedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyGatewayNodeCommandDenied,
|
||||
kind: "plugin",
|
||||
description: "Gateway node command allowlists match policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayNodeCommandDenied);
|
||||
},
|
||||
repair(ctx, findings) {
|
||||
return previewPolicyReviewRequiredRepair(
|
||||
ctx,
|
||||
findings,
|
||||
CHECK_IDS.policyGatewayNodeCommandDenied,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
policyGatewayNonLoopbackBindCheck,
|
||||
policyGatewayAuthDisabledCheck,
|
||||
policyGatewayRateLimitMissingCheck,
|
||||
policyGatewayControlUiInsecureCheck,
|
||||
policyGatewayTailscaleFunnelCheck,
|
||||
policyGatewayRemoteEnabledCheck,
|
||||
policyGatewayHttpEndpointEnabledCheck,
|
||||
policyGatewayHttpUrlFetchUnrestrictedCheck,
|
||||
policyGatewayNodeCommandDeniedCheck,
|
||||
];
|
||||
return createPolicyScopedChecks(deps, [
|
||||
[
|
||||
CHECK_IDS.policyGatewayNonLoopbackBind,
|
||||
"Gateway bind posture matches policy exposure requirements.",
|
||||
(ctx, findings) =>
|
||||
previewPolicyReviewRequiredRepair(ctx, findings, CHECK_IDS.policyGatewayNonLoopbackBind),
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyGatewayAuthDisabled,
|
||||
"Gateway authentication remains enabled when required by policy.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyGatewayRateLimitMissing,
|
||||
"Gateway authentication rate-limit posture is explicit when required by policy.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyGatewayControlUiInsecure,
|
||||
"Gateway Control UI insecure exposure toggles remain disabled by policy.",
|
||||
(ctx, findings) =>
|
||||
repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyGatewayControlUiInsecure),
|
||||
],
|
||||
[CHECK_IDS.policyGatewayTailscaleFunnel, "Gateway Tailscale Funnel exposure matches policy."],
|
||||
[
|
||||
CHECK_IDS.policyGatewayRemoteEnabled,
|
||||
"Remote gateway mode matches policy.",
|
||||
(ctx, findings) =>
|
||||
repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyGatewayRemoteEnabled),
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyGatewayHttpEndpointEnabled,
|
||||
"Gateway HTTP API endpoints match policy.",
|
||||
(ctx, findings) =>
|
||||
repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyGatewayHttpEndpointEnabled),
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted,
|
||||
"Gateway HTTP URL-fetch inputs have allowlists when required by policy.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyGatewayNodeCommandDenied,
|
||||
"Gateway node command allowlists match policy.",
|
||||
(ctx, findings) =>
|
||||
previewPolicyReviewRequiredRepair(ctx, findings, CHECK_IDS.policyGatewayNodeCommandDenied),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
export function gatewayExposureFindings(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { HealthCheck, HealthFinding } from "openclaw/plugin-sdk/health";
|
||||
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import type { PolicyEvidence } from "../../policy-state.js";
|
||||
import { createPolicyScopedChecks } from "../check-factory.js";
|
||||
import { CHECK_IDS } from "../check-ids.js";
|
||||
import type { PolicyDoctorCheckDeps } from "../types.js";
|
||||
import { readPolicyBoolean, readStringList } from "../utils.js";
|
||||
@@ -9,61 +10,25 @@ import { readPolicyBoolean, readStringList } from "../utils.js";
|
||||
export function createPolicyModelNetworkChecks(
|
||||
deps: PolicyDoctorCheckDeps,
|
||||
): readonly HealthCheck[] {
|
||||
const { evaluatePolicy, findingsForCheck } = deps;
|
||||
|
||||
const policyMcpDeniedServerCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyDeniedMcpServer,
|
||||
kind: "plugin",
|
||||
description: "Configured MCP servers do not match policy deny rules.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyDeniedMcpServer);
|
||||
},
|
||||
};
|
||||
const policyMcpUnapprovedServerCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyUnapprovedMcpServer,
|
||||
kind: "plugin",
|
||||
description: "Configured MCP servers do not match policy allow rules.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnapprovedMcpServer);
|
||||
},
|
||||
};
|
||||
const policyModelsDeniedProviderCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyDeniedModelProvider,
|
||||
kind: "plugin",
|
||||
description: "Configured model providers do not match policy deny rules.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyDeniedModelProvider);
|
||||
},
|
||||
};
|
||||
const policyModelsUnapprovedProviderCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyUnapprovedModelProvider,
|
||||
kind: "plugin",
|
||||
description: "Configured model providers do not match policy allow rules.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnapprovedModelProvider);
|
||||
},
|
||||
};
|
||||
const policyNetworkPrivateAccessCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyPrivateNetworkAccess,
|
||||
kind: "plugin",
|
||||
description: "Network SSRF policy settings match private-network requirements.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyPrivateNetworkAccess);
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
policyMcpDeniedServerCheck,
|
||||
policyMcpUnapprovedServerCheck,
|
||||
policyModelsDeniedProviderCheck,
|
||||
policyModelsUnapprovedProviderCheck,
|
||||
policyNetworkPrivateAccessCheck,
|
||||
];
|
||||
return createPolicyScopedChecks(deps, [
|
||||
[CHECK_IDS.policyDeniedMcpServer, "Configured MCP servers do not match policy deny rules."],
|
||||
[
|
||||
CHECK_IDS.policyUnapprovedMcpServer,
|
||||
"Configured MCP servers do not match policy allow rules.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyDeniedModelProvider,
|
||||
"Configured model providers do not match policy deny rules.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyUnapprovedModelProvider,
|
||||
"Configured model providers do not match policy allow rules.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyPrivateNetworkAccess,
|
||||
"Network SSRF policy settings match private-network requirements.",
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
export function mcpServerFindings(
|
||||
|
||||
@@ -1,51 +1,25 @@
|
||||
import type { HealthCheck } from "openclaw/plugin-sdk/health";
|
||||
import { createPolicyScopedChecks } from "../check-factory.js";
|
||||
import { CHECK_IDS } from "../check-ids.js";
|
||||
import type { PolicyDoctorCheckDeps } from "../types.js";
|
||||
|
||||
export function createPolicyRoutingChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
|
||||
const { evaluatePolicy, findingsForCheck } = deps;
|
||||
return [
|
||||
{
|
||||
id: CHECK_IDS.policyRoutingBindingsRequired,
|
||||
kind: "plugin",
|
||||
description: "Routing policy has at least one channel route binding when required.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyRoutingBindingsRequired);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: CHECK_IDS.policyRoutingBindingChannelUnconfigured,
|
||||
kind: "plugin",
|
||||
description: "Route bindings name channels present in configuration.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyRoutingBindingChannelUnconfigured,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: CHECK_IDS.policyRoutingAgentMismatch,
|
||||
kind: "plugin",
|
||||
description: "Authored routing probes resolve to their expected agents.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyRoutingAgentMismatch);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: CHECK_IDS.policyRoutingMatchKindMismatch,
|
||||
kind: "plugin",
|
||||
description: "Authored routing probes match at their expected specificity.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyRoutingMatchKindMismatch,
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
return createPolicyScopedChecks(deps, [
|
||||
[
|
||||
CHECK_IDS.policyRoutingBindingsRequired,
|
||||
"Routing policy has at least one channel route binding when required.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyRoutingBindingChannelUnconfigured,
|
||||
"Route bindings name channels present in configuration.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyRoutingAgentMismatch,
|
||||
"Authored routing probes resolve to their expected agents.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyRoutingMatchKindMismatch,
|
||||
"Authored routing probes match at their expected specificity.",
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,123 +1,43 @@
|
||||
// Policy doctor health-check factories for one policy scope.
|
||||
import type { HealthCheck } from "openclaw/plugin-sdk/health";
|
||||
import { createPolicyScopedChecks } from "../check-factory.js";
|
||||
import { CHECK_IDS } from "../check-ids.js";
|
||||
import type { PolicyDoctorCheckDeps } from "../types.js";
|
||||
|
||||
export function createPolicySandboxChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
|
||||
const { evaluatePolicy, findingsForCheck } = deps;
|
||||
|
||||
const policySandboxModeUnapprovedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySandboxModeUnapproved,
|
||||
kind: "plugin",
|
||||
description: "Sandbox mode config satisfies policy requirements.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySandboxModeUnapproved);
|
||||
},
|
||||
};
|
||||
const policySandboxBackendUnapprovedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySandboxBackendUnapproved,
|
||||
kind: "plugin",
|
||||
description: "Sandbox backend config satisfies policy requirements.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySandboxBackendUnapproved);
|
||||
},
|
||||
};
|
||||
const policySandboxContainerPostureUnobservableCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySandboxContainerPostureUnobservable,
|
||||
kind: "plugin",
|
||||
description: "Sandbox container posture policy only targets observable container backends.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policySandboxContainerPostureUnobservable,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policySandboxContainerHostNetworkDeniedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySandboxContainerHostNetworkDenied,
|
||||
kind: "plugin",
|
||||
description: "Sandbox container config avoids host network mode.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policySandboxContainerHostNetworkDenied,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policySandboxContainerNamespaceJoinDeniedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySandboxContainerNamespaceJoinDenied,
|
||||
kind: "plugin",
|
||||
description: "Sandbox container config avoids joining another container network namespace.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policySandboxContainerNamespaceJoinDenied,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policySandboxContainerMountModeRequiredCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySandboxContainerMountModeRequired,
|
||||
kind: "plugin",
|
||||
description: "Sandbox container mounts are read-only when policy requires it.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policySandboxContainerMountModeRequired,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policySandboxContainerRuntimeSocketMountCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySandboxContainerRuntimeSocketMount,
|
||||
kind: "plugin",
|
||||
description: "Sandbox container mounts avoid host container runtime sockets.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policySandboxContainerRuntimeSocketMount,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policySandboxContainerUnconfinedProfileCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySandboxContainerUnconfinedProfile,
|
||||
kind: "plugin",
|
||||
description: "Sandbox container profile config avoids unconfined profiles.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policySandboxContainerUnconfinedProfile,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policySandboxBrowserCdpSourceRangeMissingCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing,
|
||||
kind: "plugin",
|
||||
description: "Sandbox browser CDP config includes a source range when policy requires it.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
policySandboxModeUnapprovedCheck,
|
||||
policySandboxBackendUnapprovedCheck,
|
||||
policySandboxContainerPostureUnobservableCheck,
|
||||
policySandboxContainerHostNetworkDeniedCheck,
|
||||
policySandboxContainerNamespaceJoinDeniedCheck,
|
||||
policySandboxContainerMountModeRequiredCheck,
|
||||
policySandboxContainerRuntimeSocketMountCheck,
|
||||
policySandboxContainerUnconfinedProfileCheck,
|
||||
policySandboxBrowserCdpSourceRangeMissingCheck,
|
||||
];
|
||||
return createPolicyScopedChecks(deps, [
|
||||
[CHECK_IDS.policySandboxModeUnapproved, "Sandbox mode config satisfies policy requirements."],
|
||||
[
|
||||
CHECK_IDS.policySandboxBackendUnapproved,
|
||||
"Sandbox backend config satisfies policy requirements.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policySandboxContainerPostureUnobservable,
|
||||
"Sandbox container posture policy only targets observable container backends.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policySandboxContainerHostNetworkDenied,
|
||||
"Sandbox container config avoids host network mode.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policySandboxContainerNamespaceJoinDenied,
|
||||
"Sandbox container config avoids joining another container network namespace.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policySandboxContainerMountModeRequired,
|
||||
"Sandbox container mounts are read-only when policy requires it.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policySandboxContainerRuntimeSocketMount,
|
||||
"Sandbox container mounts avoid host container runtime sockets.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policySandboxContainerUnconfinedProfile,
|
||||
"Sandbox container profile config avoids unconfined profiles.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing,
|
||||
"Sandbox browser CDP config includes a source range when policy requires it.",
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,211 +1,77 @@
|
||||
// Policy doctor health-check factories for one policy scope.
|
||||
import type { HealthCheck } from "openclaw/plugin-sdk/health";
|
||||
import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js";
|
||||
import { createPolicyScopedChecks } from "../check-factory.js";
|
||||
import { CHECK_IDS } from "../check-ids.js";
|
||||
import type { PolicyDoctorCheckDeps } from "../types.js";
|
||||
|
||||
export function createPolicyAgentToolChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] {
|
||||
const { evaluatePolicy, findingsForCheck } = deps;
|
||||
|
||||
const policyAgentsWorkspaceAccessDeniedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyAgentsWorkspaceAccessDenied,
|
||||
kind: "plugin",
|
||||
description: "Agent sandbox workspace access matches policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyAgentsWorkspaceAccessDenied,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyAgentsToolNotDeniedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyAgentsToolNotDenied,
|
||||
kind: "plugin",
|
||||
description: "Agent workspace mutation/runtime tools are denied when policy requires it.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyAgentsToolNotDenied);
|
||||
},
|
||||
repair(ctx, findings) {
|
||||
return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyAgentsToolNotDenied);
|
||||
},
|
||||
};
|
||||
const policyToolsProfileUnapprovedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyToolsProfileUnapproved,
|
||||
kind: "plugin",
|
||||
description: "Configured tool profiles match policy allow rules.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsProfileUnapproved);
|
||||
},
|
||||
};
|
||||
const policyToolsFsWorkspaceOnlyRequiredCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyToolsFsWorkspaceOnlyRequired,
|
||||
kind: "plugin",
|
||||
description: "Filesystem tools use workspace-only posture when policy requires it.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyToolsFsWorkspaceOnlyRequired,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyToolsExecSecurityUnapprovedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyToolsExecSecurityUnapproved,
|
||||
kind: "plugin",
|
||||
description: "Exec tool security mode matches policy allow rules.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(
|
||||
await evaluatePolicy(ctx),
|
||||
CHECK_IDS.policyToolsExecSecurityUnapproved,
|
||||
);
|
||||
},
|
||||
};
|
||||
const policyToolsExecAskUnapprovedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyToolsExecAskUnapproved,
|
||||
kind: "plugin",
|
||||
description: "Exec tool ask mode matches policy allow rules.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsExecAskUnapproved);
|
||||
},
|
||||
};
|
||||
const policyToolsExecHostUnapprovedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyToolsExecHostUnapproved,
|
||||
kind: "plugin",
|
||||
description: "Exec tool host routing matches policy allow rules.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsExecHostUnapproved);
|
||||
},
|
||||
};
|
||||
const policyToolsElevatedEnabledCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyToolsElevatedEnabled,
|
||||
kind: "plugin",
|
||||
description: "Elevated tool mode remains disabled when policy requires it.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsElevatedEnabled);
|
||||
},
|
||||
repair(ctx, findings) {
|
||||
return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyToolsElevatedEnabled);
|
||||
},
|
||||
};
|
||||
const policyToolsAlsoAllowMissingCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyToolsAlsoAllowMissing,
|
||||
kind: "plugin",
|
||||
description: "Configured tools.alsoAllow entries include policy expected lists.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsAlsoAllowMissing);
|
||||
},
|
||||
};
|
||||
const policyToolsAlsoAllowUnexpectedCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyToolsAlsoAllowUnexpected,
|
||||
kind: "plugin",
|
||||
description: "Configured tools.alsoAllow entries match policy expected lists.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsAlsoAllowUnexpected);
|
||||
},
|
||||
};
|
||||
const policyToolsRequiredDenyMissingCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyToolsRequiredDenyMissing,
|
||||
kind: "plugin",
|
||||
description: "Configured tool deny lists include tools required by policy.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsRequiredDenyMissing);
|
||||
},
|
||||
repair(ctx, findings) {
|
||||
return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyToolsRequiredDenyMissing);
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
policyAgentsWorkspaceAccessDeniedCheck,
|
||||
policyAgentsToolNotDeniedCheck,
|
||||
policyToolsProfileUnapprovedCheck,
|
||||
policyToolsFsWorkspaceOnlyRequiredCheck,
|
||||
policyToolsExecSecurityUnapprovedCheck,
|
||||
policyToolsExecAskUnapprovedCheck,
|
||||
policyToolsExecHostUnapprovedCheck,
|
||||
policyToolsElevatedEnabledCheck,
|
||||
policyToolsAlsoAllowMissingCheck,
|
||||
policyToolsAlsoAllowUnexpectedCheck,
|
||||
policyToolsRequiredDenyMissingCheck,
|
||||
];
|
||||
return createPolicyScopedChecks(deps, [
|
||||
[CHECK_IDS.policyAgentsWorkspaceAccessDenied, "Agent sandbox workspace access matches policy."],
|
||||
[
|
||||
CHECK_IDS.policyAgentsToolNotDenied,
|
||||
"Agent workspace mutation/runtime tools are denied when policy requires it.",
|
||||
(ctx, findings) =>
|
||||
repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyAgentsToolNotDenied),
|
||||
],
|
||||
[CHECK_IDS.policyToolsProfileUnapproved, "Configured tool profiles match policy allow rules."],
|
||||
[
|
||||
CHECK_IDS.policyToolsFsWorkspaceOnlyRequired,
|
||||
"Filesystem tools use workspace-only posture when policy requires it.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyToolsExecSecurityUnapproved,
|
||||
"Exec tool security mode matches policy allow rules.",
|
||||
],
|
||||
[CHECK_IDS.policyToolsExecAskUnapproved, "Exec tool ask mode matches policy allow rules."],
|
||||
[CHECK_IDS.policyToolsExecHostUnapproved, "Exec tool host routing matches policy allow rules."],
|
||||
[
|
||||
CHECK_IDS.policyToolsElevatedEnabled,
|
||||
"Elevated tool mode remains disabled when policy requires it.",
|
||||
(ctx, findings) =>
|
||||
repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyToolsElevatedEnabled),
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyToolsAlsoAllowMissing,
|
||||
"Configured tools.alsoAllow entries include policy expected lists.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyToolsAlsoAllowUnexpected,
|
||||
"Configured tools.alsoAllow entries match policy expected lists.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyToolsRequiredDenyMissing,
|
||||
"Configured tool deny lists include tools required by policy.",
|
||||
(ctx, findings) =>
|
||||
repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyToolsRequiredDenyMissing),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
export function createPolicyToolMetadataChecks(
|
||||
deps: PolicyDoctorCheckDeps,
|
||||
): readonly HealthCheck[] {
|
||||
const { evaluatePolicy, findingsForCheck } = deps;
|
||||
|
||||
const policyUnmigratedToolsFileCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyUnmigratedToolsFile,
|
||||
kind: "plugin",
|
||||
description: "Governed tool declarations have been migrated from TOOLS.md into AGENTS.md.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnmigratedToolsFile);
|
||||
},
|
||||
};
|
||||
const policyToolsMissingRiskCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyMissingToolRisk,
|
||||
kind: "plugin",
|
||||
description: "AGENTS.md tool policy entries declare explicit risk levels.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingToolRisk);
|
||||
},
|
||||
};
|
||||
const policyToolsUnknownRiskCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyUnknownToolRisk,
|
||||
kind: "plugin",
|
||||
description: "AGENTS.md tool policy entries use known risk levels.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnknownToolRisk);
|
||||
},
|
||||
};
|
||||
const policyToolsMissingSensitivityCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyMissingToolSensitivity,
|
||||
kind: "plugin",
|
||||
description: "AGENTS.md tool policy entries declare default artifact sensitivity.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingToolSensitivity);
|
||||
},
|
||||
};
|
||||
const policyToolsUnknownSensitivityCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyUnknownToolSensitivity,
|
||||
kind: "plugin",
|
||||
description: "AGENTS.md tool policy entries use known sensitivity levels.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnknownToolSensitivity);
|
||||
},
|
||||
};
|
||||
const policyToolsMissingOwnerCheck: HealthCheck = {
|
||||
id: CHECK_IDS.policyMissingToolOwner,
|
||||
kind: "plugin",
|
||||
description: "AGENTS.md tool policy entries declare an accountable owner.",
|
||||
source: "policy",
|
||||
async detect(ctx) {
|
||||
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingToolOwner);
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
policyUnmigratedToolsFileCheck,
|
||||
policyToolsMissingRiskCheck,
|
||||
policyToolsUnknownRiskCheck,
|
||||
policyToolsMissingSensitivityCheck,
|
||||
policyToolsMissingOwnerCheck,
|
||||
policyToolsUnknownSensitivityCheck,
|
||||
];
|
||||
return createPolicyScopedChecks(deps, [
|
||||
[
|
||||
CHECK_IDS.policyUnmigratedToolsFile,
|
||||
"Governed tool declarations have been migrated from TOOLS.md into AGENTS.md.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyMissingToolRisk,
|
||||
"AGENTS.md tool policy entries declare explicit risk levels.",
|
||||
],
|
||||
[CHECK_IDS.policyUnknownToolRisk, "AGENTS.md tool policy entries use known risk levels."],
|
||||
[
|
||||
CHECK_IDS.policyMissingToolSensitivity,
|
||||
"AGENTS.md tool policy entries declare default artifact sensitivity.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyMissingToolOwner,
|
||||
"AGENTS.md tool policy entries declare an accountable owner.",
|
||||
],
|
||||
[
|
||||
CHECK_IDS.policyUnknownToolSensitivity,
|
||||
"AGENTS.md tool policy entries use known sensitivity levels.",
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -462,6 +462,100 @@ describe("startWhatsAppQaDriverSession", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it.each([
|
||||
...[
|
||||
{ name: "captionless video note", message: { ptvMessage: {} } },
|
||||
{
|
||||
name: "ephemeral captionless video note",
|
||||
message: { ephemeralMessage: { message: { ptvMessage: {} } } },
|
||||
},
|
||||
{
|
||||
name: "edited captionless video note",
|
||||
message: { editedMessage: { message: { ptvMessage: {} } } },
|
||||
},
|
||||
].map(({ name, message }) => ({
|
||||
name,
|
||||
message,
|
||||
expected: { kind: "media", mediaType: "video/mp4", text: "" },
|
||||
})),
|
||||
...[
|
||||
"pollCreationMessage",
|
||||
"pollCreationMessageV2",
|
||||
"pollCreationMessageV3",
|
||||
"pollCreationMessageV5",
|
||||
].flatMap((pollKey) => {
|
||||
const poll = {
|
||||
[pollKey]: {
|
||||
name: "Choose a time",
|
||||
options: [{ optionName: "Morning" }, { optionName: "Afternoon" }],
|
||||
},
|
||||
};
|
||||
const expected = {
|
||||
kind: "poll",
|
||||
poll: { question: "Choose a time", options: ["Morning", "Afternoon"] },
|
||||
};
|
||||
return [
|
||||
{ name: pollKey, message: poll, expected },
|
||||
{
|
||||
name: `ephemeral ${pollKey}`,
|
||||
message: { ephemeralMessage: { message: poll } },
|
||||
expected,
|
||||
},
|
||||
];
|
||||
}),
|
||||
...["pollCreationMessageV3", "pollCreationMessageV5"].flatMap((pollKey) => {
|
||||
const poll = {
|
||||
[pollKey]: {
|
||||
name: "Choose a time",
|
||||
options: [{ optionName: "Morning" }, { optionName: "Afternoon" }],
|
||||
},
|
||||
};
|
||||
const wrappedPoll = { pollCreationMessageV4: { message: poll } };
|
||||
const expected = {
|
||||
kind: "poll",
|
||||
poll: { question: "Choose a time", options: ["Morning", "Afternoon"] },
|
||||
};
|
||||
return [
|
||||
{ name: `future-proof version-4 ${pollKey}`, message: wrappedPoll, expected },
|
||||
{
|
||||
name: `ephemeral future-proof version-4 ${pollKey}`,
|
||||
message: { ephemeralMessage: { message: wrappedPoll } },
|
||||
expected,
|
||||
},
|
||||
{ name: `edited ${pollKey}`, message: { editedMessage: { message: poll } }, expected },
|
||||
];
|
||||
}),
|
||||
])("resolves live ingress waiters for $name", async ({ message, expected }) => {
|
||||
const sock = createMockSocket();
|
||||
mocks.createWaSocket.mockResolvedValue(sock);
|
||||
mocks.waitForWaConnection.mockResolvedValue(undefined);
|
||||
mocks.jidToE164.mockReturnValue("+15551234567");
|
||||
|
||||
const session = await startWhatsAppQaDriverSession({
|
||||
authDir: "/tmp/openclaw-whatsapp-auth",
|
||||
});
|
||||
|
||||
try {
|
||||
const observed = session.waitForMessage({
|
||||
timeoutMs: 150,
|
||||
match: (candidate) => candidate.kind === expected.kind,
|
||||
});
|
||||
sock.ev.emit("messages.upsert", {
|
||||
messages: [
|
||||
{
|
||||
key: { fromMe: false, id: "observed-message", remoteJid: "12345@lid" },
|
||||
message,
|
||||
} as WAMessage,
|
||||
],
|
||||
});
|
||||
|
||||
await expect(observed).resolves.toMatchObject(expected);
|
||||
expect(session.getObservedMessages()).toHaveLength(1);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses canonical WhatsApp media MIME defaults when Baileys omits MIME", async () => {
|
||||
const sock = createMockSocket();
|
||||
mocks.createWaSocket.mockResolvedValue(sock);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Whatsapp plugin module implements qa driver behavior.
|
||||
import type { ConnectionState, proto, WAMessage } from "baileys";
|
||||
import { getContentType, type ConnectionState, type proto, type WAMessage } from "baileys";
|
||||
import { formatLocationText } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import {
|
||||
describeReplyContext,
|
||||
@@ -180,19 +180,10 @@ function findMessageSection(
|
||||
if (current.depth >= 4) {
|
||||
continue;
|
||||
}
|
||||
for (const wrapperName of [
|
||||
"botInvokeMessage",
|
||||
"documentWithCaptionMessage",
|
||||
"ephemeralMessage",
|
||||
"groupMentionedMessage",
|
||||
"viewOnceMessage",
|
||||
"viewOnceMessageV2",
|
||||
"viewOnceMessageV2Extension",
|
||||
]) {
|
||||
const wrapper = current.value[wrapperName];
|
||||
if (isRecord(wrapper) && isRecord(wrapper.message)) {
|
||||
queue.push({ depth: current.depth + 1, value: wrapper.message });
|
||||
}
|
||||
const contentType = getContentType(current.value as proto.IMessage);
|
||||
const wrapper = contentType ? current.value[contentType] : undefined;
|
||||
if (isRecord(wrapper) && isRecord(wrapper.message)) {
|
||||
queue.push({ depth: current.depth + 1, value: wrapper.message });
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
@@ -218,6 +209,7 @@ function readPoll(message: unknown): WhatsAppQaDriverObservedPoll | undefined {
|
||||
"pollCreationMessage",
|
||||
"pollCreationMessageV2",
|
||||
"pollCreationMessageV3",
|
||||
"pollCreationMessageV5",
|
||||
]);
|
||||
if (!poll) {
|
||||
return undefined;
|
||||
@@ -244,6 +236,7 @@ function readMedia(message: unknown):
|
||||
const mediaSections = [
|
||||
"imageMessage",
|
||||
"videoMessage",
|
||||
"ptvMessage",
|
||||
"audioMessage",
|
||||
"documentMessage",
|
||||
"stickerMessage",
|
||||
|
||||
@@ -12,6 +12,7 @@ type CommandCase = {
|
||||
name: string;
|
||||
args: string[];
|
||||
presets: readonly string[];
|
||||
stateScope?: "case" | "sample";
|
||||
expectedExitCodes?: readonly number[];
|
||||
expectedNonzeroOutputIncludes?: readonly string[];
|
||||
firstOutputBudgetMs?: number;
|
||||
@@ -444,6 +445,19 @@ const COMMAND_CASES: readonly CommandCase[] = [
|
||||
expectedExitCodes: [0, 1],
|
||||
expectedNonzeroOutputIncludes: ['"ok"', '"gateway_transport_error"'],
|
||||
},
|
||||
{
|
||||
id: "gatewayHealthJsonConnected",
|
||||
name: "gateway health --json (connected)",
|
||||
args: ["gateway", "health", "--json"],
|
||||
presets: [],
|
||||
stateScope: "case",
|
||||
},
|
||||
{
|
||||
id: "gatewayHealthJsonFirstDevice",
|
||||
name: "gateway health --json (first device)",
|
||||
args: ["gateway", "health", "--json"],
|
||||
presets: [],
|
||||
},
|
||||
{
|
||||
id: "configGetGatewayPort",
|
||||
name: "config get gateway.port",
|
||||
@@ -649,6 +663,8 @@ function buildConfigFixture(commandCase: CommandCase): Record<string, unknown> |
|
||||
if (
|
||||
commandCase.id !== "configGetGatewayPort" &&
|
||||
commandCase.id !== "gatewayHealthJson" &&
|
||||
commandCase.id !== "gatewayHealthJsonConnected" &&
|
||||
commandCase.id !== "gatewayHealthJsonFirstDevice" &&
|
||||
commandCase.id !== "health" &&
|
||||
commandCase.id !== "healthJson"
|
||||
) {
|
||||
@@ -717,8 +733,10 @@ async function runSample(params: {
|
||||
cpuProfDir?: string;
|
||||
heapProfDir?: string;
|
||||
rssHookPath: string;
|
||||
runRoot?: string;
|
||||
}): Promise<Sample> {
|
||||
const runRoot = mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-bench-home-"));
|
||||
const runRoot = params.runRoot ?? mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-bench-home-"));
|
||||
const ownsRunRoot = params.runRoot == null;
|
||||
const stateDir = path.join(runRoot, ".openclaw");
|
||||
const configPath = path.join(stateDir, "openclaw.json");
|
||||
const configFixture = buildConfigFixture(params.commandCase);
|
||||
@@ -849,7 +867,9 @@ async function runSample(params: {
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
rmSync(runRoot, { recursive: true, force: true });
|
||||
if (ownsRunRoot) {
|
||||
rmSync(runRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -939,14 +959,24 @@ async function runCase(params: {
|
||||
}): Promise<Sample[]> {
|
||||
const samples: Sample[] = [];
|
||||
const totalRuns = params.warmup + params.runs;
|
||||
for (let i = 0; i < totalRuns; i += 1) {
|
||||
const sample = await runSample(params);
|
||||
if (i < params.warmup) {
|
||||
continue;
|
||||
const caseRunRoot =
|
||||
params.commandCase.stateScope === "case"
|
||||
? mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-bench-home-"))
|
||||
: undefined;
|
||||
try {
|
||||
for (let i = 0; i < totalRuns; i += 1) {
|
||||
const sample = await runSample({ ...params, runRoot: caseRunRoot });
|
||||
if (i < params.warmup) {
|
||||
continue;
|
||||
}
|
||||
samples.push(sample);
|
||||
}
|
||||
return samples;
|
||||
} finally {
|
||||
if (caseRunRoot) {
|
||||
rmSync(caseRunRoot, { recursive: true, force: true });
|
||||
}
|
||||
samples.push(sample);
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
function tailLines(value: string, maxLines: number): string {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const STATE_SCHEMA_INLINE_PLUGIN_NAME: string;
|
||||
|
||||
export function createStateSchemaInlinePlugin(rootDir?: string): {
|
||||
name: string;
|
||||
load(
|
||||
this: { addWatchFile(id: string): void },
|
||||
id: string,
|
||||
): { code: string; moduleType: "js" } | null;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export const STATE_SCHEMA_INLINE_PLUGIN_NAME = "openclaw:inline-state-schemas";
|
||||
|
||||
const STATE_SCHEMA_MODULES = [
|
||||
{
|
||||
modulePath: "src/state/openclaw-state-schema.ts",
|
||||
schemaPath: "src/state/openclaw-state-schema.sql",
|
||||
exportName: "OPENCLAW_STATE_SCHEMA_SQL",
|
||||
},
|
||||
{
|
||||
modulePath: "src/state/openclaw-agent-schema.ts",
|
||||
schemaPath: "src/state/openclaw-agent-schema.sql",
|
||||
exportName: "OPENCLAW_AGENT_SCHEMA_SQL",
|
||||
},
|
||||
];
|
||||
|
||||
/** Inline canonical schema bytes so bundled consumers need no SQL asset. */
|
||||
export function createStateSchemaInlinePlugin(rootDir = process.cwd()) {
|
||||
const schemasByModulePath = new Map(
|
||||
STATE_SCHEMA_MODULES.map((schema) => [path.resolve(rootDir, schema.modulePath), schema]),
|
||||
);
|
||||
|
||||
return {
|
||||
name: STATE_SCHEMA_INLINE_PLUGIN_NAME,
|
||||
load(id) {
|
||||
const schema = schemasByModulePath.get(path.resolve(id));
|
||||
if (!schema) {
|
||||
return null;
|
||||
}
|
||||
const schemaPath = path.resolve(rootDir, schema.schemaPath);
|
||||
this.addWatchFile(schemaPath);
|
||||
return {
|
||||
code: `export const ${schema.exportName} = ${JSON.stringify(fs.readFileSync(schemaPath, "utf8"))};\n`,
|
||||
moduleType: "js",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -118,7 +118,7 @@ describe("plugins cli policy mutations", () => {
|
||||
plugins: { allow: ["other-plugin"] },
|
||||
reason: "blocked by allowlist",
|
||||
},
|
||||
])("does not mutate plugin state when $policy blocks enablement", async ({ plugins, reason }) => {
|
||||
])("fails without mutations when $policy blocks enablement", async ({ plugins, reason }) => {
|
||||
const sourceConfig = { plugins } as OpenClawConfig;
|
||||
loadConfig.mockReturnValue(sourceConfig);
|
||||
enablePluginInConfig.mockReturnValue({
|
||||
@@ -129,11 +129,13 @@ describe("plugins cli policy mutations", () => {
|
||||
});
|
||||
mockPluginRegistry(["alpha"]);
|
||||
|
||||
await runPluginsCommand(["plugins", "enable", "alpha"]);
|
||||
await expect(runPluginsCommand(["plugins", "enable", "alpha"])).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(replaceConfigFile).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(refreshPluginRegistry).not.toHaveBeenCalled();
|
||||
expect(runtimeLogs).toContain(`Plugin "alpha" could not be enabled (${reason}).`);
|
||||
expect(runtimeErrors).toContain(`Plugin "alpha" could not be enabled (${reason}).`);
|
||||
expect(runtimeLogs).not.toContain(`Plugin "alpha" could not be enabled (${reason}).`);
|
||||
});
|
||||
|
||||
it("refuses plugin enablement in Nix mode before config mutation", async () => {
|
||||
|
||||
@@ -205,12 +205,10 @@ async function runPluginsEnableCommandUnlocked(idInput: string): Promise<void> {
|
||||
});
|
||||
// A blocked request must not displace the active slot or rewrite persisted state.
|
||||
if (!enableResult.enabled) {
|
||||
defaultRuntime.log(
|
||||
theme.warn(
|
||||
`Plugin "${id}" could not be enabled (${enableResult.reason ?? "unknown reason"}).`,
|
||||
),
|
||||
defaultRuntime.error(
|
||||
`Plugin "${id}" could not be enabled (${enableResult.reason ?? "unknown reason"}).`,
|
||||
);
|
||||
return;
|
||||
return defaultRuntime.exit(1);
|
||||
}
|
||||
|
||||
const { applySlotSelectionForPlugin } = await loadPluginSlotSelection();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Plugins list command tests cover plugin list command execution and output.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { OutputRuntimeEnv } from "../runtime.js";
|
||||
|
||||
function createJsonRuntime(writes: unknown[]): OutputRuntimeEnv {
|
||||
@@ -14,6 +15,65 @@ function createJsonRuntime(writes: unknown[]): OutputRuntimeEnv {
|
||||
};
|
||||
}
|
||||
|
||||
type SnapshotPlugin = {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
commands?: string[];
|
||||
agentHarnessIds?: string[];
|
||||
};
|
||||
|
||||
function mockPluginListSnapshot(plugins: SnapshotPlugin[], config: OpenClawConfig = {}): void {
|
||||
vi.doMock("../config/config.js", () => ({
|
||||
getRuntimeConfig: () => config,
|
||||
}));
|
||||
vi.doMock("../plugins/status-snapshot.js", () => ({
|
||||
buildPluginRegistrySnapshotReport: () => ({
|
||||
workspaceDir: "/workspace",
|
||||
registrySource: "config",
|
||||
registryDiagnostics: [],
|
||||
plugins,
|
||||
diagnostics: [],
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
function mockHumanListModules(importedModules: string[] = []): void {
|
||||
vi.doMock("../plugins/source-display.js", () => {
|
||||
importedModules.push("source-display");
|
||||
return {
|
||||
formatPluginSourceForTable: vi.fn(),
|
||||
resolvePluginSourceRoots: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.doMock("../../packages/terminal-core/src/table.js", () => {
|
||||
importedModules.push("table");
|
||||
return {
|
||||
getTerminalTableWidth: vi.fn(),
|
||||
renderTable: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.doMock("../../packages/terminal-core/src/theme.js", () => {
|
||||
importedModules.push("theme");
|
||||
return {
|
||||
theme: {
|
||||
muted: (value: string) => value,
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.doMock("./command-format.js", () => {
|
||||
importedModules.push("command-format");
|
||||
return {
|
||||
formatCliCommand: (value: string) => `formatted(${value})`,
|
||||
};
|
||||
});
|
||||
vi.doMock("./plugins-list-format.js", () => {
|
||||
importedModules.push("plugins-list-format");
|
||||
return {
|
||||
formatPluginLine: vi.fn(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
describe("runPluginsListCommand", () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock("../config/config.js");
|
||||
@@ -22,6 +82,8 @@ describe("runPluginsListCommand", () => {
|
||||
vi.doUnmock("../plugins/source-display.js");
|
||||
vi.doUnmock("../terminal/table.js");
|
||||
vi.doUnmock("../terminal/theme.js");
|
||||
vi.doUnmock("../../packages/terminal-core/src/table.js");
|
||||
vi.doUnmock("../../packages/terminal-core/src/theme.js");
|
||||
vi.doUnmock("./command-format.js");
|
||||
vi.doUnmock("./plugins-list-format.js");
|
||||
vi.resetModules();
|
||||
@@ -114,4 +176,93 @@ describe("runPluginsListCommand", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "normal", options: { enabled: true } },
|
||||
{ label: "verbose", options: { enabled: true, verbose: true } },
|
||||
])(
|
||||
"explains an empty enabled-only $label list when plugins are installed",
|
||||
async ({ options }) => {
|
||||
mockPluginListSnapshot([{ id: "disabled-plugin", enabled: false }]);
|
||||
mockHumanListModules();
|
||||
const { runPluginsListCommand } = await import("./plugins-list-command.js");
|
||||
const writes: unknown[] = [];
|
||||
|
||||
await runPluginsListCommand(options, createJsonRuntime(writes));
|
||||
|
||||
expect(writes).toEqual([
|
||||
"No enabled plugins found. Run formatted(openclaw plugins list) to inspect installed plugins.",
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ label: "normal", options: { enabled: true } },
|
||||
{ label: "verbose", options: { enabled: true, verbose: true } },
|
||||
])("explains a globally disabled $label plugin inventory", async ({ options }) => {
|
||||
mockPluginListSnapshot([{ id: "disabled-plugin", enabled: false }], {
|
||||
plugins: { enabled: false },
|
||||
});
|
||||
mockHumanListModules();
|
||||
const { runPluginsListCommand } = await import("./plugins-list-command.js");
|
||||
const writes: unknown[] = [];
|
||||
|
||||
await runPluginsListCommand(options, createJsonRuntime(writes));
|
||||
|
||||
expect(writes).toEqual([
|
||||
"No enabled plugins found. Plugins are globally disabled. Run formatted(openclaw plugins list) to inspect installed plugins.",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "denylist", config: { plugins: { deny: ["disabled-plugin"] } } },
|
||||
{
|
||||
label: "allowlist",
|
||||
config: { plugins: { allow: ["allowed-plugin"] } },
|
||||
},
|
||||
])("does not suggest a blocked mutation for a $label", async ({ config }) => {
|
||||
mockPluginListSnapshot([{ id: "disabled-plugin", enabled: false }], config);
|
||||
mockHumanListModules();
|
||||
const { runPluginsListCommand } = await import("./plugins-list-command.js");
|
||||
const writes: unknown[] = [];
|
||||
|
||||
await runPluginsListCommand({ enabled: true }, createJsonRuntime(writes));
|
||||
|
||||
expect(writes).toEqual([
|
||||
"No enabled plugins found. Run formatted(openclaw plugins list) to inspect installed plugins.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps install guidance when an enabled-only list has no installed plugins", async () => {
|
||||
mockPluginListSnapshot([]);
|
||||
mockHumanListModules();
|
||||
const { runPluginsListCommand } = await import("./plugins-list-command.js");
|
||||
const writes: unknown[] = [];
|
||||
|
||||
await runPluginsListCommand({ enabled: true }, createJsonRuntime(writes));
|
||||
|
||||
expect(writes).toEqual([
|
||||
"No plugins found. Run formatted(openclaw plugins install <plugin>) to add one, or formatted(openclaw plugins list --json) to inspect raw discovery state.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps empty enabled-only JSON lazy when every installed plugin is disabled", async () => {
|
||||
const importedHumanModules: string[] = [];
|
||||
mockPluginListSnapshot([{ id: "disabled-plugin", enabled: false }]);
|
||||
mockHumanListModules(importedHumanModules);
|
||||
const { runPluginsListCommand } = await import("./plugins-list-command.js");
|
||||
const writes: unknown[] = [];
|
||||
|
||||
await runPluginsListCommand({ enabled: true, json: true }, createJsonRuntime(writes));
|
||||
|
||||
expect(importedHumanModules).toEqual([]);
|
||||
expect(writes).toEqual([
|
||||
{
|
||||
workspaceDir: "/workspace",
|
||||
registry: { source: "config", diagnostics: [] },
|
||||
plugins: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,11 +76,15 @@ export async function runPluginsListCommand(
|
||||
} = await loadHumanListModules();
|
||||
|
||||
if (list.length === 0) {
|
||||
runtime.log(
|
||||
theme.muted(
|
||||
`No plugins found. Run ${formatCliCommand("openclaw plugins install <plugin>")} to add one, or ${formatCliCommand("openclaw plugins list --json")} to inspect raw discovery state.`,
|
||||
),
|
||||
);
|
||||
const message =
|
||||
opts.enabled && report.plugins.length > 0
|
||||
? `${
|
||||
cfg.plugins?.enabled === false
|
||||
? "No enabled plugins found. Plugins are globally disabled."
|
||||
: "No enabled plugins found."
|
||||
} Run ${formatCliCommand("openclaw plugins list")} to inspect installed plugins.`
|
||||
: `No plugins found. Run ${formatCliCommand("openclaw plugins install <plugin>")} to add one, or ${formatCliCommand("openclaw plugins list --json")} to inspect raw discovery state.`;
|
||||
runtime.log(theme.muted(message));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ function appendClawHubHint(output: string, json?: boolean): string {
|
||||
if (json) {
|
||||
return output;
|
||||
}
|
||||
return `${output}\n\nTip: use \`openclaw skills search\`, \`openclaw skills install\`, and \`openclaw skills update\` for ClawHub-backed skills.`;
|
||||
const command = formatCliCommand("openclaw skills");
|
||||
return `${output}\n\nTip: use \`${command} search\`, \`${command} install\`, and \`${command} update\` for ClawHub-backed skills.`;
|
||||
}
|
||||
|
||||
function formatSkillStatus(skill: SkillStatusEntry): string {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Skills CLI tests cover skill listing, install, and command output behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SkillStatusEntry, SkillStatusReport } from "../skills/discovery/status.js";
|
||||
import { createEmptyInstallChecks } from "./requirements-test-fixtures.js";
|
||||
import { formatSkillInfo, formatSkillsCheck, formatSkillsList } from "./skills-cli.format.js";
|
||||
@@ -51,6 +51,66 @@ function createMockReport(skills: SkillStatusEntry[]): SkillStatusReport {
|
||||
}
|
||||
|
||||
describe("skills-cli", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("ClawHub command hints", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "named profile",
|
||||
profile: "work",
|
||||
container: "",
|
||||
prefix: "openclaw --profile work",
|
||||
},
|
||||
{
|
||||
name: "managed container",
|
||||
profile: "",
|
||||
container: "demo",
|
||||
prefix: "openclaw --container demo",
|
||||
},
|
||||
{
|
||||
name: "default profile",
|
||||
profile: "default",
|
||||
container: "",
|
||||
prefix: "openclaw",
|
||||
},
|
||||
])("preserves the $name on every human skill surface", ({ profile, container, prefix }) => {
|
||||
vi.stubEnv("OPENCLAW_PROFILE", profile);
|
||||
vi.stubEnv("OPENCLAW_CONTAINER_HINT", container);
|
||||
const report = createMockReport([]);
|
||||
const outputs = [
|
||||
formatSkillsList(report, {}),
|
||||
formatSkillInfo(report, "missing-skill", {}),
|
||||
formatSkillsCheck(report, {}),
|
||||
];
|
||||
|
||||
for (const output of outputs) {
|
||||
for (const action of ["search", "install", "update"]) {
|
||||
expect(output).toContain(`${prefix} skills ${action}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps profile and container guidance out of machine-readable skill output", () => {
|
||||
vi.stubEnv("OPENCLAW_PROFILE", "work");
|
||||
vi.stubEnv("OPENCLAW_CONTAINER_HINT", "demo");
|
||||
const report = createMockReport([]);
|
||||
const outputs = [
|
||||
formatSkillsList(report, { json: true }),
|
||||
formatSkillInfo(report, "missing-skill", { json: true }),
|
||||
formatSkillsCheck(report, { json: true }),
|
||||
];
|
||||
|
||||
for (const output of outputs) {
|
||||
expect(() => JSON.parse(output)).not.toThrow();
|
||||
expect(output).not.toContain("Tip:");
|
||||
expect(output).not.toContain("openclaw --profile");
|
||||
expect(output).not.toContain("openclaw --container");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSkillsList", () => {
|
||||
it("formats empty skills list", () => {
|
||||
const report = createMockReport([]);
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { RuntimeEnv } from "../runtime.js";
|
||||
import { createRunningTaskRun as createRunningTaskRunOrNull } from "../tasks/task-executor.js";
|
||||
import { createManagedTaskFlow as createManagedTaskFlowOrNull } from "../tasks/task-flow-registry.js";
|
||||
import type { TaskFlowRecord } from "../tasks/task-flow-registry.types.js";
|
||||
import { markTaskLostById, markTaskTerminalById } from "../tasks/task-registry.js";
|
||||
import type { TaskRecord } from "../tasks/task-registry.types.js";
|
||||
import {
|
||||
resetTaskFlowRegistryForTests,
|
||||
@@ -282,6 +283,237 @@ describe("flows commands", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["failed", "timed_out", "lost"] as const)(
|
||||
"shows the persisted failure reason for linked %s tasks",
|
||||
async (status) => {
|
||||
await withTaskFlowCommandStateDir(async () => {
|
||||
const flow = createManagedTaskFlow({
|
||||
ownerKey: "agent:main:main",
|
||||
controllerId: "tests/flows-command-failure-detail",
|
||||
goal: "Inspect child task failures",
|
||||
status: "running",
|
||||
});
|
||||
const task = createRunningTaskRun({
|
||||
runtime: "subagent",
|
||||
ownerKey: "agent:main:main",
|
||||
scopeKind: "session",
|
||||
parentFlowId: flow.flowId,
|
||||
childSessionKey: `agent:main:flow-child-${status}`,
|
||||
runId: `run-flow-child-${status}`,
|
||||
label: "Inspect linked child",
|
||||
task: "Inspect linked child",
|
||||
notifyPolicy: "silent",
|
||||
startedAt: Date.now(),
|
||||
progressSummary: "Outdated child progress",
|
||||
});
|
||||
const error = `${status}: linked provider credentials need attention`;
|
||||
const endedAt = Date.now();
|
||||
|
||||
if (status === "lost") {
|
||||
markTaskLostById({ taskId: task.taskId, endedAt, error });
|
||||
} else {
|
||||
markTaskTerminalById({
|
||||
taskId: task.taskId,
|
||||
status,
|
||||
endedAt,
|
||||
error,
|
||||
terminalSummary: "Generic child completion summary",
|
||||
});
|
||||
}
|
||||
|
||||
const runtime = createRuntime();
|
||||
await flowsShowCommand({ lookup: flow.flowId }, runtime);
|
||||
|
||||
const lines = vi.mocked(runtime.log).mock.calls.map(([line]) => String(line));
|
||||
const linkedTaskLine = lines.find((line) => line.startsWith(`- ${task.taskId} `));
|
||||
expect(linkedTaskLine).toContain("Inspect linked child");
|
||||
expect(linkedTaskLine).toContain(error);
|
||||
expect(linkedTaskLine).not.toContain("Outdated child progress");
|
||||
expect(linkedTaskLine).not.toContain("Generic child completion summary");
|
||||
|
||||
const jsonRuntime = createRuntime();
|
||||
await flowsShowCommand({ lookup: flow.flowId, json: true }, jsonRuntime);
|
||||
expect(vi.mocked(jsonRuntime.writeJson).mock.calls[0]?.[0]).toMatchObject({
|
||||
tasks: [expect.objectContaining({ status, error })],
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("includes running progress and terminal completion summaries for linked tasks", async () => {
|
||||
await withTaskFlowCommandStateDir(async () => {
|
||||
const flow = createManagedTaskFlow({
|
||||
ownerKey: "agent:main:main",
|
||||
controllerId: "tests/flows-command-task-progress",
|
||||
goal: "Inspect child task updates",
|
||||
status: "running",
|
||||
});
|
||||
const running = createRunningTaskRun({
|
||||
runtime: "subagent",
|
||||
ownerKey: "agent:main:main",
|
||||
scopeKind: "session",
|
||||
parentFlowId: flow.flowId,
|
||||
childSessionKey: "agent:main:flow-child-running",
|
||||
runId: "run-flow-child-running",
|
||||
label: "Inspect running child",
|
||||
task: "Inspect running child",
|
||||
notifyPolicy: "silent",
|
||||
startedAt: Date.now(),
|
||||
progressSummary: "Downloading provider metadata",
|
||||
});
|
||||
const completed = createRunningTaskRun({
|
||||
runtime: "subagent",
|
||||
ownerKey: "agent:main:main",
|
||||
scopeKind: "session",
|
||||
parentFlowId: flow.flowId,
|
||||
childSessionKey: "agent:main:flow-child-completed",
|
||||
runId: "run-flow-child-completed",
|
||||
label: "Inspect completed child",
|
||||
task: "Inspect completed child",
|
||||
notifyPolicy: "silent",
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
markTaskTerminalById({
|
||||
taskId: completed.taskId,
|
||||
status: "succeeded",
|
||||
endedAt: Date.now(),
|
||||
terminalSummary: "Provider metadata refreshed",
|
||||
});
|
||||
|
||||
const runtime = createRuntime();
|
||||
await flowsShowCommand({ lookup: flow.flowId }, runtime);
|
||||
|
||||
const lines = vi.mocked(runtime.log).mock.calls.map(([line]) => String(line));
|
||||
expect(lines.find((line) => line.startsWith(`- ${running.taskId} `))).toContain(
|
||||
"Downloading provider metadata",
|
||||
);
|
||||
expect(lines.find((line) => line.startsWith(`- ${completed.taskId} `))).toContain(
|
||||
"Provider metadata refreshed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes linked task failure reasons before terminal display", async () => {
|
||||
await withTaskFlowCommandStateDir(async () => {
|
||||
const flow = createManagedTaskFlow({
|
||||
ownerKey: "agent:main:main",
|
||||
controllerId: "tests/flows-command-task-safety",
|
||||
goal: "Inspect unsafe child error",
|
||||
status: "running",
|
||||
});
|
||||
const task = createRunningTaskRun({
|
||||
runtime: "subagent",
|
||||
ownerKey: "agent:main:main",
|
||||
scopeKind: "session",
|
||||
parentFlowId: flow.flowId,
|
||||
childSessionKey: "agent:main:flow-child-safety",
|
||||
runId: "run-flow-child-safety",
|
||||
label: "Inspect child safely",
|
||||
task: "Inspect child safely",
|
||||
notifyPolicy: "silent",
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
markTaskTerminalById({
|
||||
taskId: task.taskId,
|
||||
status: "failed",
|
||||
endedAt: Date.now(),
|
||||
error: "Provider \u001b[31mrejected\nforged: yes",
|
||||
});
|
||||
|
||||
const runtime = createRuntime();
|
||||
await flowsShowCommand({ lookup: flow.flowId }, runtime);
|
||||
|
||||
const lines = vi.mocked(runtime.log).mock.calls.map(([line]) => String(line));
|
||||
const linkedTaskLine = lines.find((line) => line.startsWith(`- ${task.taskId} `));
|
||||
expect(linkedTaskLine).toContain("Provider rejected forged: yes");
|
||||
expect(linkedTaskLine).not.toContain("\u001b");
|
||||
expect(linkedTaskLine).not.toContain("\n");
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes persisted linked task identifiers while preserving raw flow JSON", async () => {
|
||||
await withTaskFlowCommandStateDir(async () => {
|
||||
const unsafe = "\u001b]52;c;Zm9yZ2Vk\u0007\nforged: yes";
|
||||
const flow = createManagedTaskFlow({
|
||||
ownerKey: "agent:main:main",
|
||||
controllerId: `controller${unsafe}`,
|
||||
goal: `goal${unsafe}`,
|
||||
currentStep: `step${unsafe}`,
|
||||
status: "running",
|
||||
});
|
||||
const task = createRunningTaskRun({
|
||||
runtime: "subagent",
|
||||
ownerKey: "agent:main:main",
|
||||
scopeKind: "session",
|
||||
parentFlowId: flow.flowId,
|
||||
childSessionKey: `agent:main:child${unsafe}`,
|
||||
runId: `run${unsafe}`,
|
||||
label: `label${unsafe}`,
|
||||
task: `prompt${unsafe}`,
|
||||
notifyPolicy: "silent",
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
markTaskTerminalById({
|
||||
taskId: task.taskId,
|
||||
status: "failed",
|
||||
endedAt: Date.now(),
|
||||
error: `error${unsafe}`,
|
||||
});
|
||||
|
||||
const humanRuntime = createRuntime();
|
||||
await flowsShowCommand({ lookup: flow.flowId }, humanRuntime);
|
||||
|
||||
const lines = vi.mocked(humanRuntime.log).mock.calls.map(([line]) => String(line));
|
||||
const linkedTaskLine = lines.find((line) => line.startsWith(`- ${task.taskId} `));
|
||||
expect(linkedTaskLine).toContain("label");
|
||||
expect(linkedTaskLine).toContain("error");
|
||||
for (const line of lines) {
|
||||
expect(line).not.toContain("\u001b");
|
||||
expect(line).not.toContain("\u0007");
|
||||
expect(line).not.toContain("\n");
|
||||
}
|
||||
|
||||
const jsonRuntime = createRuntime();
|
||||
await flowsShowCommand({ lookup: flow.flowId, json: true }, jsonRuntime);
|
||||
expect(vi.mocked(jsonRuntime.writeJson).mock.calls[0]?.[0]).toMatchObject({
|
||||
goal: `goal${unsafe}`,
|
||||
currentStep: `step${unsafe}`,
|
||||
tasks: [
|
||||
expect.objectContaining({
|
||||
childSessionKey: `agent:main:child${unsafe}`,
|
||||
runId: `run${unsafe}`,
|
||||
label: `label${unsafe}`,
|
||||
task: `prompt${unsafe}`,
|
||||
error: `error${unsafe}`,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes untrusted TaskFlow filters and lookup errors", async () => {
|
||||
await withTaskFlowCommandStateDir(async () => {
|
||||
const unsafe = "\u001b]52;c;Zm9yZ2Vk\u0007\nforged: yes";
|
||||
const filterRuntime = createRuntime();
|
||||
await flowsListCommand({ status: `running${unsafe}` }, filterRuntime);
|
||||
|
||||
const lookupRuntime = createRuntime();
|
||||
await flowsShowCommand({ lookup: `missing${unsafe}` }, lookupRuntime);
|
||||
|
||||
const lines = [
|
||||
...vi.mocked(filterRuntime.log).mock.calls.map(([line]) => String(line)),
|
||||
...vi.mocked(lookupRuntime.error).mock.calls.map(([line]) => String(line)),
|
||||
];
|
||||
expect(lines.some((line) => line.includes("Status filter: running"))).toBe(true);
|
||||
expect(lines.some((line) => line.includes("TaskFlow not found: missing"))).toBe(true);
|
||||
for (const line of lines) {
|
||||
expect(line).not.toContain("\u001b");
|
||||
expect(line).not.toContain("\u0007");
|
||||
expect(line).not.toContain("\n");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("shows TaskFlows with Date-invalid timestamps without crashing", async () => {
|
||||
await withTaskFlowCommandStateDir(async () => {
|
||||
const flow = createManagedTaskFlow({
|
||||
|
||||
+21
-12
@@ -17,6 +17,7 @@ import {
|
||||
listTaskFlowRecords,
|
||||
resolveTaskFlowForLookupToken,
|
||||
} from "../tasks/task-flow-runtime-internal.js";
|
||||
import { formatTaskStatusDetail } from "../tasks/task-status.js";
|
||||
|
||||
const ID_PAD = 10;
|
||||
const STATUS_PAD = 10;
|
||||
@@ -25,7 +26,7 @@ const REV_PAD = 6;
|
||||
const CTRL_PAD = 20;
|
||||
|
||||
function formatFlowLookupMiss(lookup: string): string {
|
||||
return `TaskFlow not found: ${lookup}. Run ${formatCliCommand("openclaw tasks flow list")} to see recent flow ids.`;
|
||||
return `TaskFlow not found: ${sanitizeTerminalText(lookup)}. Run ${formatCliCommand("openclaw tasks flow list")} to see recent flow ids.`;
|
||||
}
|
||||
|
||||
function truncate(value: string, maxChars: number) {
|
||||
@@ -47,11 +48,7 @@ function safeFlowDisplayText(value: string | undefined, maxChars?: number): stri
|
||||
}
|
||||
|
||||
function shortToken(value: string | undefined, maxChars = ID_PAD): string {
|
||||
const trimmed = normalizeOptionalString(value);
|
||||
if (!trimmed) {
|
||||
return "n/a";
|
||||
}
|
||||
return truncate(trimmed, maxChars);
|
||||
return safeFlowDisplayText(normalizeOptionalString(value), maxChars);
|
||||
}
|
||||
|
||||
function formatFlowTimestamp(value: number | undefined | null): string {
|
||||
@@ -178,7 +175,7 @@ export async function flowsListCommand(
|
||||
runtime.log(info(`TaskFlows: ${flows.length}`));
|
||||
runtime.log(info(`TaskFlow pressure: ${formatFlowListSummary(flows)}`));
|
||||
if (statusFilter) {
|
||||
runtime.log(info(`Status filter: ${statusFilter}`));
|
||||
runtime.log(info(`Status filter: ${sanitizeTerminalText(statusFilter)}`));
|
||||
}
|
||||
if (flows.length === 0) {
|
||||
runtime.log(
|
||||
@@ -234,7 +231,7 @@ export async function flowsShowCommand(
|
||||
`tasks: ${taskSummary.total} total · ${taskSummary.active} active · ${taskSummary.failures} issues`,
|
||||
];
|
||||
for (const line of lines) {
|
||||
runtime.log(line);
|
||||
runtime.log(sanitizeTerminalText(line));
|
||||
}
|
||||
if (tasks.length === 0) {
|
||||
runtime.log("Linked tasks: none");
|
||||
@@ -243,7 +240,13 @@ export async function flowsShowCommand(
|
||||
runtime.log("Linked tasks:");
|
||||
for (const task of tasks) {
|
||||
const safeLabel = safeFlowDisplayText(task.label ?? task.task);
|
||||
runtime.log(`- ${task.taskId} ${task.status} ${task.runId ?? "n/a"} ${safeLabel}`);
|
||||
const detail = formatTaskStatusDetail(task);
|
||||
const safeDetail = detail ? ` · ${safeFlowDisplayText(detail)}` : "";
|
||||
runtime.log(
|
||||
sanitizeTerminalText(
|
||||
`- ${task.taskId} ${task.status} ${safeFlowDisplayText(task.runId)} ${safeLabel}${safeDetail}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,15 +263,21 @@ export async function flowsCancelCommand(opts: { lookup: string }, runtime: Runt
|
||||
flowId: flow.flowId,
|
||||
});
|
||||
if (!result.found) {
|
||||
runtime.error(result.reason ?? formatFlowLookupMiss(opts.lookup));
|
||||
runtime.error(sanitizeTerminalText(result.reason ?? formatFlowLookupMiss(opts.lookup)));
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (!result.cancelled) {
|
||||
runtime.error(result.reason ?? `Could not cancel TaskFlow: ${opts.lookup}`);
|
||||
runtime.error(
|
||||
sanitizeTerminalText(result.reason ?? `Could not cancel TaskFlow: ${opts.lookup}`),
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const updated = getTaskFlowById(flow.flowId) ?? result.flow ?? flow;
|
||||
runtime.log(`Cancelled ${updated.flowId} (${updated.syncMode}) with status ${updated.status}.`);
|
||||
runtime.log(
|
||||
sanitizeTerminalText(
|
||||
`Cancelled ${updated.flowId} (${updated.syncMode}) with status ${updated.status}.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
// Status scan shared tests cover gateway probe snapshots, Tailscale URLs, and shared scan helpers.
|
||||
import { once } from "node:events";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { cleanupTempDirs, makeTempDir } from "../../test/helpers/temp-dir.js";
|
||||
import { parseStatusRouteArgs } from "../cli/program/route-args.js";
|
||||
import {
|
||||
buildMinimalGatewayHelloOkPayload,
|
||||
closeMinimalGatewayServer,
|
||||
parseMinimalGatewayRequestFrame,
|
||||
sendMinimalGatewayConnectChallenge,
|
||||
sendMinimalGatewayResponse,
|
||||
} from "../gateway/minimal-gateway.test-helpers.js";
|
||||
import {
|
||||
buildTailscaleHttpsUrl,
|
||||
resolveGatewayProbeSnapshot,
|
||||
@@ -325,39 +336,118 @@ describe("resolveGatewayProbeSnapshot", () => {
|
||||
expect(gatewayCall.timeoutMs).toBe(2000);
|
||||
});
|
||||
|
||||
it("does not raise an explicit local status RPC fallback timeout", async () => {
|
||||
it.each([1, 50, 999, 1000, 2000, 8000])(
|
||||
"does not raise an explicit local status RPC fallback timeout (%i ms)",
|
||||
async (timeoutMs) => {
|
||||
mocks.resolveGatewayProbeTarget.mockReturnValue({
|
||||
mode: "local",
|
||||
gatewayMode: "local",
|
||||
remoteUrlMissing: false,
|
||||
});
|
||||
mocks.probeGateway.mockResolvedValue({
|
||||
ok: false,
|
||||
url: "ws://127.0.0.1:18789",
|
||||
connectLatencyMs: null,
|
||||
error: "timeout",
|
||||
close: null,
|
||||
auth: {
|
||||
role: null,
|
||||
scopes: [],
|
||||
capability: "unknown",
|
||||
},
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
});
|
||||
mocks.callGateway.mockResolvedValue({ sessions: 1 });
|
||||
|
||||
await resolveGatewayProbeSnapshot({
|
||||
cfg: {},
|
||||
opts: { timeoutMs },
|
||||
});
|
||||
|
||||
const probeCall = readProbeCall();
|
||||
expect(probeCall).not.toHaveProperty("preauthHandshakeTimeoutMs");
|
||||
expect(probeCall.timeoutMs).toBe(timeoutMs);
|
||||
expect(readGatewayCall().timeoutMs).toBe(Math.min(2000, timeoutMs));
|
||||
},
|
||||
);
|
||||
|
||||
it("enforces an explicit CLI timeout against a real local fallback status RPC", async () => {
|
||||
const gateway = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
||||
await once(gateway, "listening");
|
||||
const address = gateway.address() as AddressInfo;
|
||||
const url = `ws://127.0.0.1:${address.port}`;
|
||||
const observedMethods: string[] = [];
|
||||
gateway.on("connection", (socket) => {
|
||||
sendMinimalGatewayConnectChallenge(socket);
|
||||
socket.on("message", (data) => {
|
||||
const frame = parseMinimalGatewayRequestFrame(data);
|
||||
if (frame.type !== "req" || !frame.id || !frame.method) {
|
||||
return;
|
||||
}
|
||||
const requestId = frame.id;
|
||||
if (frame.method === "connect") {
|
||||
sendMinimalGatewayResponse(
|
||||
socket,
|
||||
requestId,
|
||||
buildMinimalGatewayHelloOkPayload({
|
||||
methods: ["system-presence", "status"],
|
||||
auth: { role: "operator", scopes: ["operator.read"] },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
observedMethods.push(frame.method);
|
||||
if (frame.method === "status") {
|
||||
const responseTimer = setTimeout(() => {
|
||||
if (socket.readyState === socket.OPEN) {
|
||||
sendMinimalGatewayResponse(socket, requestId, { sessions: 1 });
|
||||
}
|
||||
}, 400);
|
||||
responseTimer.unref();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
mocks.buildGatewayConnectionDetailsWithResolvers.mockReturnValue({
|
||||
url,
|
||||
urlSource: "local loopback",
|
||||
message: `Gateway target: ${url}`,
|
||||
});
|
||||
mocks.resolveGatewayProbeTarget.mockReturnValue({
|
||||
mode: "local",
|
||||
gatewayMode: "local",
|
||||
remoteUrlMissing: false,
|
||||
});
|
||||
mocks.probeGateway.mockResolvedValue({
|
||||
ok: false,
|
||||
url: "ws://127.0.0.1:18789",
|
||||
connectLatencyMs: null,
|
||||
error: "timeout",
|
||||
close: null,
|
||||
auth: {
|
||||
role: null,
|
||||
scopes: [],
|
||||
capability: "unknown",
|
||||
},
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
mocks.probeGateway.mockImplementation(async (...args: unknown[]) => {
|
||||
const { probeGateway } =
|
||||
await vi.importActual<typeof import("../gateway/probe.js")>("../gateway/probe.js");
|
||||
return await probeGateway(...(args as Parameters<typeof probeGateway>));
|
||||
});
|
||||
mocks.callGateway.mockResolvedValue({ sessions: 1 });
|
||||
|
||||
await resolveGatewayProbeSnapshot({
|
||||
cfg: {},
|
||||
opts: { timeoutMs: 1000 },
|
||||
mocks.callGateway.mockImplementation(async (...args: unknown[]) => {
|
||||
const { callGateway } =
|
||||
await vi.importActual<typeof import("../gateway/call.js")>("../gateway/call.js");
|
||||
return await callGateway(...(args as Parameters<typeof callGateway>));
|
||||
});
|
||||
const parsed = parseStatusRouteArgs(["node", "openclaw", "status", "--timeout", "250"]);
|
||||
expect(parsed?.timeoutMs).toBe(250);
|
||||
|
||||
const probeCall = readProbeCall();
|
||||
expect(probeCall).not.toHaveProperty("preauthHandshakeTimeoutMs");
|
||||
expect(probeCall.timeoutMs).toBe(1000);
|
||||
expect(readGatewayCall().timeoutMs).toBe(1000);
|
||||
try {
|
||||
const result = await resolveGatewayProbeSnapshot({
|
||||
cfg: { gateway: { auth: { mode: "none" } } },
|
||||
opts: { timeoutMs: parsed?.timeoutMs },
|
||||
});
|
||||
|
||||
expect(readProbeCall().timeoutMs).toBe(250);
|
||||
expect(readGatewayCall().timeoutMs).toBe(250);
|
||||
expect(observedMethods).toEqual(["system-presence", "status"]);
|
||||
expect(result.gatewayProbe?.ok).toBe(false);
|
||||
expect(result.gatewayProbe?.error).toContain("timeout");
|
||||
} finally {
|
||||
await closeMinimalGatewayServer(gateway);
|
||||
}
|
||||
});
|
||||
|
||||
it("lets callGateway reuse paired-device auth for local status RPC fallback", async () => {
|
||||
|
||||
@@ -208,7 +208,11 @@ async function applyLocalStatusRpcFallback(params: {
|
||||
if (!shouldTryLocalStatusRpcFallback(params)) {
|
||||
return params.gatewayProbe;
|
||||
}
|
||||
const boundedFallbackTimeoutMs = Math.min(2000, Math.max(1000, params.timeoutMs));
|
||||
// Explicit probe budgets are operator-owned; only implicit fallback defaults get a floor.
|
||||
const boundedFallbackTimeoutMs = Math.min(
|
||||
2000,
|
||||
params.timeoutMsExplicit ? params.timeoutMs : Math.max(1000, params.timeoutMs),
|
||||
);
|
||||
// The fallback uses the gateway status RPC because it can succeed after probe handshake ambiguity.
|
||||
const status = await loadGatewayCallModule()
|
||||
.then(({ callGateway }) =>
|
||||
|
||||
+194
-24
@@ -12,6 +12,8 @@ import type { TaskFlowRecord } from "../tasks/task-flow-registry.types.js";
|
||||
import {
|
||||
createTaskRecord as createTaskRecordOrNull,
|
||||
getTaskById,
|
||||
markTaskLostById,
|
||||
markTaskTerminalById,
|
||||
reloadTaskRegistryFromStore,
|
||||
} from "../tasks/task-registry.js";
|
||||
import * as taskRegistryMaintenance from "../tasks/task-registry.maintenance.js";
|
||||
@@ -79,6 +81,28 @@ function jsonRoundTrip<T>(value: T): T {
|
||||
return JSON.parse(serialized) as T;
|
||||
}
|
||||
|
||||
const UNSAFE_TASK_TERMINAL_TEXT = "\u001b]52;c;Zm9yZ2Vk\u0007\nforged: yes";
|
||||
|
||||
function createInspectableTask(params: Partial<Parameters<typeof createTaskRecord>[0]> = {}) {
|
||||
return createTaskRecord({
|
||||
runtime: "cli",
|
||||
ownerKey: "agent:main:main",
|
||||
scopeKind: "session",
|
||||
status: "running",
|
||||
notifyPolicy: "silent",
|
||||
task: "Inspect a background task",
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
function expectSafeTaskOutput(runtime: RuntimeEnv, channel: "log" | "error" = "log") {
|
||||
for (const [line] of vi.mocked(runtime[channel]).mock.calls) {
|
||||
for (const control of ["\u001b", "\u0007", "\n", "\r"]) {
|
||||
expect(String(line)).not.toContain(control);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const zeroTaskAuditCounts = {
|
||||
delivery_failed: 0,
|
||||
inconsistent_timestamps: 0,
|
||||
@@ -97,31 +121,28 @@ async function writeSessionEntries(
|
||||
}
|
||||
}
|
||||
|
||||
function resetTaskCommandRuntime() {
|
||||
taskRegistryMaintenance.stopTaskRegistryMaintenance();
|
||||
taskRegistryMaintenance.resetTaskRegistryMaintenanceRuntimeForTests();
|
||||
resetConfigRuntimeState();
|
||||
resetDetachedTaskLifecycleRuntimeForTests();
|
||||
resetTaskRegistryDeliveryRuntimeForTests();
|
||||
resetTaskRegistryForTests({ persist: false });
|
||||
resetTaskFlowRegistryForTests({ persist: false });
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
}
|
||||
|
||||
async function withTaskCommandStateDir(
|
||||
run: (state: OpenClawTestState) => Promise<void>,
|
||||
): Promise<void> {
|
||||
await withOpenClawTestState(
|
||||
{ layout: "state-only", prefix: "openclaw-tasks-command-" },
|
||||
async (state) => {
|
||||
taskRegistryMaintenance.stopTaskRegistryMaintenance();
|
||||
taskRegistryMaintenance.resetTaskRegistryMaintenanceRuntimeForTests();
|
||||
resetConfigRuntimeState();
|
||||
resetDetachedTaskLifecycleRuntimeForTests();
|
||||
resetTaskRegistryDeliveryRuntimeForTests();
|
||||
resetTaskRegistryForTests({ persist: false });
|
||||
resetTaskFlowRegistryForTests({ persist: false });
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
resetTaskCommandRuntime();
|
||||
try {
|
||||
await run(state);
|
||||
} finally {
|
||||
taskRegistryMaintenance.stopTaskRegistryMaintenance();
|
||||
taskRegistryMaintenance.resetTaskRegistryMaintenanceRuntimeForTests();
|
||||
resetConfigRuntimeState();
|
||||
resetDetachedTaskLifecycleRuntimeForTests();
|
||||
resetTaskRegistryDeliveryRuntimeForTests();
|
||||
resetTaskRegistryForTests({ persist: false });
|
||||
resetTaskFlowRegistryForTests({ persist: false });
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
resetTaskCommandRuntime();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -134,14 +155,7 @@ describe("tasks commands", () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
taskRegistryMaintenance.stopTaskRegistryMaintenance();
|
||||
taskRegistryMaintenance.resetTaskRegistryMaintenanceRuntimeForTests();
|
||||
resetConfigRuntimeState();
|
||||
resetDetachedTaskLifecycleRuntimeForTests();
|
||||
resetTaskRegistryDeliveryRuntimeForTests();
|
||||
resetTaskRegistryForTests({ persist: false });
|
||||
resetTaskFlowRegistryForTests({ persist: false });
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
resetTaskCommandRuntime();
|
||||
mocks.callGateway.mockReset();
|
||||
});
|
||||
|
||||
@@ -390,6 +404,54 @@ describe("tasks commands", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["gateway", "local"] as const)(
|
||||
"sanitizes untrusted %s task cancellation output",
|
||||
async (owner) => {
|
||||
await withTaskCommandStateDir(async () => {
|
||||
const unsafe = UNSAFE_TASK_TERMINAL_TEXT;
|
||||
const gatewayOwned = owner === "gateway";
|
||||
const task = createInspectableTask({
|
||||
runtime: gatewayOwned ? "cron" : "cli",
|
||||
ownerKey: gatewayOwned ? "" : "agent:main:main",
|
||||
scopeKind: gatewayOwned ? "system" : "session",
|
||||
runId: `run${unsafe}`,
|
||||
});
|
||||
if (gatewayOwned) {
|
||||
mocks.callGateway.mockResolvedValueOnce({
|
||||
found: true,
|
||||
cancelled: true,
|
||||
task: {
|
||||
taskId: `${task.taskId}${unsafe}`,
|
||||
runtime: `cron${unsafe}`,
|
||||
runId: task.runId,
|
||||
},
|
||||
});
|
||||
}
|
||||
const runtime = createRuntime();
|
||||
await tasksCancelCommand({ lookup: task.taskId }, runtime);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`Cancelled ${task.taskId}`),
|
||||
);
|
||||
expectSafeTaskOutput(runtime);
|
||||
if (!gatewayOwned) {
|
||||
expect(getTaskById(task.taskId)).toMatchObject({
|
||||
status: "cancelled",
|
||||
runId: `run${unsafe}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
mocks.callGateway.mockResolvedValueOnce({
|
||||
found: true,
|
||||
cancelled: false,
|
||||
reason: `gateway refused${unsafe}`,
|
||||
});
|
||||
const failureRuntime = createRuntime();
|
||||
await tasksCancelCommand({ lookup: task.taskId }, failureRuntime);
|
||||
expectSafeTaskOutput(failureRuntime, "error");
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("fails ACP task cancellation loudly when the live gateway is unavailable", async () => {
|
||||
await withTaskCommandStateDir(async () => {
|
||||
const task = createTaskRecord({
|
||||
@@ -680,6 +742,110 @@ describe("tasks commands", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes every persisted task surface while preserving raw task JSON", async () => {
|
||||
await withTaskCommandStateDir(async () => {
|
||||
const unsafe = UNSAFE_TASK_TERMINAL_TEXT;
|
||||
const task = createInspectableTask({
|
||||
sourceId: `source${unsafe}`,
|
||||
childSessionKey: `agent:main:child${unsafe}`,
|
||||
parentTaskId: `parent${unsafe}`,
|
||||
agentId: `worker${unsafe}`,
|
||||
runId: `run${unsafe}`,
|
||||
label: `label${unsafe}`,
|
||||
task: `prompt${unsafe}`,
|
||||
progressSummary: `progress${unsafe}`,
|
||||
terminalSummary: `summary${unsafe}`,
|
||||
});
|
||||
markTaskLostById({ taskId: task.taskId, endedAt: Date.now(), error: `error${unsafe}` });
|
||||
const showRuntime = createRuntime();
|
||||
const listRuntime = createRuntime();
|
||||
const auditRuntime = createRuntime();
|
||||
await tasksShowCommand({ lookup: task.taskId }, showRuntime);
|
||||
await tasksListCommand({}, listRuntime);
|
||||
await tasksAuditCommand({}, auditRuntime);
|
||||
for (const runtime of [showRuntime, listRuntime, auditRuntime]) {
|
||||
expectSafeTaskOutput(runtime);
|
||||
}
|
||||
const shown = vi
|
||||
.mocked(showRuntime.log)
|
||||
.mock.calls.map(([line]) => String(line))
|
||||
.join("|");
|
||||
for (const field of [
|
||||
"sourceId",
|
||||
"childSessionKey",
|
||||
"parentTaskId",
|
||||
"agentId",
|
||||
"runId",
|
||||
"label",
|
||||
"task",
|
||||
"error",
|
||||
"progressSummary",
|
||||
"terminalSummary",
|
||||
]) {
|
||||
expect(shown).toContain(`${field}:`);
|
||||
}
|
||||
expect(vi.mocked(listRuntime.log).mock.calls.flat().join("|")).toContain("error");
|
||||
expect(vi.mocked(auditRuntime.log).mock.calls.flat().join("|")).toContain("error");
|
||||
const jsonRuntime = createRuntime();
|
||||
await tasksShowCommand({ lookup: task.taskId, json: true }, jsonRuntime);
|
||||
expect(readFirstJsonLog(jsonRuntime)).toEqual(jsonRoundTrip(getTaskById(task.taskId)));
|
||||
expect(getTaskById(task.taskId)).toMatchObject({
|
||||
runId: `run${unsafe}`,
|
||||
error: `error${unsafe}`,
|
||||
});
|
||||
const filteredListRuntime = createRuntime();
|
||||
await tasksListCommand(
|
||||
{ runtime: `cron${unsafe}`, status: `running${unsafe}` },
|
||||
filteredListRuntime,
|
||||
);
|
||||
const filteredAuditRuntime = createRuntime();
|
||||
await tasksAuditCommand(
|
||||
{
|
||||
severity: `warn${unsafe}` as TaskSystemAuditSeverity,
|
||||
code: `lost${unsafe}` as TaskSystemAuditCode,
|
||||
},
|
||||
filteredAuditRuntime,
|
||||
);
|
||||
for (const runtime of [filteredListRuntime, filteredAuditRuntime]) {
|
||||
expectSafeTaskOutput(runtime);
|
||||
}
|
||||
const lookupRuntime = createRuntime();
|
||||
await tasksShowCommand({ lookup: `missing${unsafe}` }, lookupRuntime);
|
||||
expectSafeTaskOutput(lookupRuntime, "error");
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["failed", "timed_out", "lost"] as const)(
|
||||
"shows the persisted failure reason for %s tasks in list summaries",
|
||||
async (status) => {
|
||||
await withTaskCommandStateDir(async () => {
|
||||
const task = createInspectableTask({
|
||||
runId: `task-list-${status}`,
|
||||
label: "Original task title",
|
||||
progressSummary: "Outdated running progress",
|
||||
terminalSummary: "Generic terminal summary",
|
||||
});
|
||||
const error = `${status}: upstream credentials need attention`;
|
||||
const terminal = { taskId: task.taskId, endedAt: Date.now(), error };
|
||||
if (status === "lost") {
|
||||
markTaskLostById(terminal);
|
||||
} else {
|
||||
markTaskTerminalById({
|
||||
...terminal,
|
||||
status,
|
||||
terminalSummary: "Generic terminal summary",
|
||||
});
|
||||
}
|
||||
const runtime = createRuntime();
|
||||
await tasksListCommand({}, runtime);
|
||||
const output = vi.mocked(runtime.log).mock.calls.flat().join("|");
|
||||
expect(output).toContain(error);
|
||||
expect(output).not.toContain("Outdated running progress");
|
||||
expect(output).not.toContain("Generic terminal summary");
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps task list summaries within their UTF-16 column limit", async () => {
|
||||
await withTaskCommandStateDir(async () => {
|
||||
createTaskRecord({
|
||||
@@ -691,6 +857,8 @@ describe("tasks commands", () => {
|
||||
task: "Inspect task summary",
|
||||
terminalSummary: `${"y".repeat(78)}🚀xx`,
|
||||
});
|
||||
createInspectableTask({ progressSummary: "Fetching provider credentials" });
|
||||
createInspectableTask({ status: "succeeded", label: "Human-readable task title" });
|
||||
const runtime = createRuntime();
|
||||
|
||||
await tasksListCommand({}, runtime);
|
||||
@@ -701,6 +869,8 @@ describe("tasks commands", () => {
|
||||
.join("\n");
|
||||
expect(output).toContain(`${"y".repeat(78)}…`);
|
||||
expect(output).not.toContain("🚀");
|
||||
expect(output).toContain("Fetching provider credentials");
|
||||
expect(output).toContain("Human-readable task title");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+33
-22
@@ -4,6 +4,7 @@
|
||||
import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
|
||||
import { isRich, theme } from "../../packages/terminal-core/src/theme.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { formatLookupMiss } from "../cli/error-format.js";
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
} from "../tasks/task-registry.reconcile.js";
|
||||
import { summarizeTaskRecords } from "../tasks/task-registry.summary.js";
|
||||
import type { TaskNotifyPolicy, TaskRecord } from "../tasks/task-registry.types.js";
|
||||
import { formatTaskStatusDetail } from "../tasks/task-status.js";
|
||||
import {
|
||||
buildTaskSystemAuditJsonPayload,
|
||||
buildTaskSystemAuditFindings,
|
||||
@@ -62,7 +64,7 @@ const info = theme.info;
|
||||
function formatTaskLookupMiss(lookup: string): string {
|
||||
return formatLookupMiss({
|
||||
noun: "Task",
|
||||
value: lookup,
|
||||
value: sanitizeTerminalText(lookup),
|
||||
listCommand: "openclaw tasks list",
|
||||
valueLabel: "task id",
|
||||
});
|
||||
@@ -208,11 +210,11 @@ function truncate(value: string, maxChars: number) {
|
||||
}
|
||||
|
||||
function shortToken(value: string | undefined, maxChars = ID_PAD): string {
|
||||
const trimmed = normalizeOptionalString(value);
|
||||
if (!trimmed) {
|
||||
const sanitized = sanitizeTerminalText(normalizeOptionalString(value) ?? "").trim();
|
||||
if (!sanitized) {
|
||||
return "n/a";
|
||||
}
|
||||
return truncate(trimmed, maxChars);
|
||||
return truncate(sanitized, maxChars);
|
||||
}
|
||||
|
||||
function formatTaskStatusCell(status: string, rich: boolean) {
|
||||
@@ -245,10 +247,9 @@ function formatTaskRows(tasks: TaskRecord[], rich: boolean) {
|
||||
const lines = [rich ? theme.heading(header) : header];
|
||||
for (const task of tasks) {
|
||||
const summary = truncate(
|
||||
normalizeOptionalString(task.terminalSummary) ||
|
||||
normalizeOptionalString(task.progressSummary) ||
|
||||
normalizeOptionalString(task.label) ||
|
||||
task.task.trim(),
|
||||
sanitizeTerminalText(
|
||||
formatTaskStatusDetail(task) || normalizeOptionalString(task.label) || task.task.trim(),
|
||||
),
|
||||
80,
|
||||
);
|
||||
const line = [
|
||||
@@ -257,7 +258,7 @@ function formatTaskRows(tasks: TaskRecord[], rich: boolean) {
|
||||
formatTaskStatusCell(task.status, rich),
|
||||
task.deliveryStatus.padEnd(DELIVERY_PAD),
|
||||
shortToken(task.runId, RUN_PAD).padEnd(RUN_PAD),
|
||||
truncate(normalizeOptionalString(task.childSessionKey) || "n/a", 36).padEnd(36),
|
||||
shortToken(task.childSessionKey, 36).padEnd(36),
|
||||
summary,
|
||||
].join(" ");
|
||||
lines.push(line.trimEnd());
|
||||
@@ -318,7 +319,7 @@ function formatAuditRows(findings: TaskSystemAuditFinding[], rich: boolean) {
|
||||
shortToken(finding.token).padEnd(ID_PAD),
|
||||
status,
|
||||
formatAgeMs(finding.ageMs).padEnd(8),
|
||||
truncate(finding.detail, 88),
|
||||
truncate(sanitizeTerminalText(finding.detail), 88),
|
||||
]
|
||||
.join(" ")
|
||||
.trimEnd(),
|
||||
@@ -372,10 +373,10 @@ export async function tasksListCommand(
|
||||
runtime.log(info(`Background tasks: ${tasks.length}`));
|
||||
runtime.log(info(`Task pressure: ${formatTaskListSummary(tasks)}`));
|
||||
if (runtimeFilter) {
|
||||
runtime.log(info(`Runtime filter: ${runtimeFilter}`));
|
||||
runtime.log(info(`Runtime filter: ${sanitizeTerminalText(runtimeFilter)}`));
|
||||
}
|
||||
if (statusFilter) {
|
||||
runtime.log(info(`Status filter: ${statusFilter}`));
|
||||
runtime.log(info(`Status filter: ${sanitizeTerminalText(statusFilter)}`));
|
||||
}
|
||||
if (tasks.length === 0) {
|
||||
runtime.log(
|
||||
@@ -432,7 +433,7 @@ export async function tasksShowCommand(
|
||||
...(task.terminalSummary ? [`terminalSummary: ${task.terminalSummary}`] : []),
|
||||
];
|
||||
for (const line of lines) {
|
||||
runtime.log(line);
|
||||
runtime.log(sanitizeTerminalText(line));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,7 +457,9 @@ export async function tasksNotifyCommand(
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
runtime.log(`Updated ${updated.taskId} notify policy to ${updated.notifyPolicy}.`);
|
||||
runtime.log(
|
||||
sanitizeTerminalText(`Updated ${updated.taskId} notify policy to ${updated.notifyPolicy}.`),
|
||||
);
|
||||
}
|
||||
|
||||
/** Cancels a detached task run by lookup token. */
|
||||
@@ -470,18 +473,24 @@ export async function tasksCancelCommand(opts: { lookup: string }, runtime: Runt
|
||||
const gatewayResult = await tryCancelGatewayOwnedTaskViaGateway(task);
|
||||
if (gatewayResult) {
|
||||
if (!gatewayResult.found) {
|
||||
runtime.error(gatewayResult.reason ?? formatTaskLookupMiss(opts.lookup));
|
||||
runtime.error(
|
||||
sanitizeTerminalText(gatewayResult.reason ?? formatTaskLookupMiss(opts.lookup)),
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (!gatewayResult.cancelled) {
|
||||
runtime.error(gatewayResult.reason ?? `Could not cancel task: ${opts.lookup}`);
|
||||
runtime.error(
|
||||
sanitizeTerminalText(gatewayResult.reason ?? `Could not cancel task: ${opts.lookup}`),
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const updated = gatewayResult.task;
|
||||
runtime.log(
|
||||
`Cancelled ${updated?.taskId ?? updated?.id ?? task.taskId} (${updated?.runtime ?? task.runtime})${updated?.runId ? ` run ${updated.runId}` : ""}.`,
|
||||
sanitizeTerminalText(
|
||||
`Cancelled ${updated?.taskId ?? updated?.id ?? task.taskId} (${updated?.runtime ?? task.runtime})${updated?.runId ? ` run ${updated.runId}` : ""}.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -490,18 +499,20 @@ export async function tasksCancelCommand(opts: { lookup: string }, runtime: Runt
|
||||
taskId: task.taskId,
|
||||
});
|
||||
if (!result.found) {
|
||||
runtime.error(result.reason ?? formatTaskLookupMiss(opts.lookup));
|
||||
runtime.error(sanitizeTerminalText(result.reason ?? formatTaskLookupMiss(opts.lookup)));
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
if (!result.cancelled) {
|
||||
runtime.error(result.reason ?? `Could not cancel task: ${opts.lookup}`);
|
||||
runtime.error(sanitizeTerminalText(result.reason ?? `Could not cancel task: ${opts.lookup}`));
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const updated = getTaskById(task.taskId);
|
||||
runtime.log(
|
||||
`Cancelled ${updated?.taskId ?? task.taskId} (${updated?.runtime ?? task.runtime})${updated?.runId ? ` run ${updated.runId}` : ""}.`,
|
||||
sanitizeTerminalText(
|
||||
`Cancelled ${updated?.taskId ?? task.taskId} (${updated?.runtime ?? task.runtime})${updated?.runId ? ` run ${updated.runId}` : ""}.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -549,10 +560,10 @@ export async function tasksAuditCommand(
|
||||
runtime.log(info(`Showing ${filteredFindings.length} matching findings.`));
|
||||
}
|
||||
if (severityFilter) {
|
||||
runtime.log(info(`Severity filter: ${severityFilter}`));
|
||||
runtime.log(info(`Severity filter: ${sanitizeTerminalText(severityFilter)}`));
|
||||
}
|
||||
if (codeFilter) {
|
||||
runtime.log(info(`Code filter: ${codeFilter}`));
|
||||
runtime.log(info(`Code filter: ${sanitizeTerminalText(codeFilter)}`));
|
||||
}
|
||||
if (limit) {
|
||||
runtime.log(info(`Limit: ${limit}`));
|
||||
|
||||
@@ -123,8 +123,11 @@ vi.mock("../agents/subagent-registry.js", () => ({
|
||||
scheduleSubagentOrphanRecovery: hoisted.scheduleSubagentOrphanRecovery,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/main-session-restart-recovery.js", () => ({
|
||||
vi.mock("../agents/main-session-restart-recovery-marking.js", () => ({
|
||||
markStartupOrphanedMainSessionsForRecovery: hoisted.markStartupOrphanedMainSessionsForRecovery,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/main-session-restart-recovery.js", () => ({
|
||||
scheduleRestartAbortedMainSessionRecovery: hoisted.scheduleRestartAbortedMainSessionRecovery,
|
||||
}));
|
||||
|
||||
@@ -1812,9 +1815,12 @@ describe("startGatewayPostAttachRuntime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("marks startup main-session orphans before channel startup", async () => {
|
||||
it("marks startup main-session orphans before model runtime and channel startup", async () => {
|
||||
const events: string[] = [];
|
||||
let releaseMarking: (() => void) | undefined;
|
||||
const prewarmPrimaryModel = vi.fn(async () => {
|
||||
events.push("model-runtime");
|
||||
});
|
||||
const startChannels = vi.fn(async () => {
|
||||
events.push("channels");
|
||||
});
|
||||
@@ -1835,6 +1841,7 @@ describe("startGatewayPostAttachRuntime", () => {
|
||||
defaultWorkspaceDir: "/tmp/openclaw-workspace",
|
||||
deps: {} as never,
|
||||
startChannels,
|
||||
prewarmPrimaryModel,
|
||||
log: { warn: vi.fn() },
|
||||
logHooks: {
|
||||
info: vi.fn(),
|
||||
@@ -1858,11 +1865,54 @@ describe("startGatewayPostAttachRuntime", () => {
|
||||
releaseMarking();
|
||||
await sidecars;
|
||||
|
||||
expect(events).toEqual(["main-session-mark:start", "main-session-mark:done", "channels"]);
|
||||
expect(events).toEqual([
|
||||
"main-session-mark:start",
|
||||
"main-session-mark:done",
|
||||
"model-runtime",
|
||||
"channels",
|
||||
]);
|
||||
expect(prewarmPrimaryModel).toHaveBeenCalledTimes(1);
|
||||
expect(startChannels).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.scheduleRestartAbortedMainSessionRecovery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks startup main-session orphans before propagating model runtime failure", async () => {
|
||||
const modelRuntimeError = new Error("model runtime unavailable");
|
||||
const startChannels = vi.fn(async () => {});
|
||||
const prewarmPrimaryModel = vi.fn(async () => {
|
||||
throw modelRuntimeError;
|
||||
});
|
||||
hoisted.markStartupOrphanedMainSessionsForRecovery.mockResolvedValueOnce({
|
||||
marked: 1,
|
||||
skipped: 0,
|
||||
});
|
||||
|
||||
await expect(
|
||||
startGatewaySidecars({
|
||||
cfg: { hooks: { internal: { enabled: false } } } as never,
|
||||
pluginRegistry: createPostAttachParams().pluginRegistry,
|
||||
defaultWorkspaceDir: "/tmp/openclaw-workspace",
|
||||
deps: {} as never,
|
||||
startChannels,
|
||||
prewarmPrimaryModel,
|
||||
log: { warn: vi.fn() },
|
||||
logHooks: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
logChannels: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}),
|
||||
).rejects.toBe(modelRuntimeError);
|
||||
|
||||
expect(hoisted.markStartupOrphanedMainSessionsForRecovery).toHaveBeenCalledTimes(1);
|
||||
expect(prewarmPrimaryModel).toHaveBeenCalledTimes(1);
|
||||
expect(startChannels).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs startup main-session marker failures and still starts channels", async () => {
|
||||
const log = { warn: vi.fn() };
|
||||
const startChannels = vi.fn(async () => {});
|
||||
|
||||
@@ -55,6 +55,10 @@ type GatewayMemoryStartupPolicy =
|
||||
const loadMainSessionRestartRecoveryModule = createLazyRuntimeModule(
|
||||
() => import("../agents/main-session-restart-recovery.js"),
|
||||
);
|
||||
// Startup only needs orphan marking; keep resume and delivery runtime out of the pre-channel path.
|
||||
const loadMainSessionRestartRecoveryMarkingModule = createLazyRuntimeModule(
|
||||
() => import("../agents/main-session-restart-recovery-marking.js"),
|
||||
);
|
||||
|
||||
const loadAgentDefaultsModule = createLazyRuntimeModule(() => import("../agents/defaults.js"));
|
||||
|
||||
@@ -658,6 +662,24 @@ export async function startGatewaySidecars(params: {
|
||||
const skipChannels =
|
||||
isTruthyEnvValue(process.env.OPENCLAW_SKIP_CHANNELS) ||
|
||||
isTruthyEnvValue(process.env.OPENCLAW_SKIP_PROVIDERS);
|
||||
// These runs were orphaned by the previous Gateway lifecycle. Record that fact
|
||||
// even if this process later fails model preparation and never starts channels.
|
||||
await measureStartup(params.startupTrace, "sidecars.main-session-recovery", async () => {
|
||||
try {
|
||||
const { markStartupOrphanedMainSessionsForRecovery } = await measureStartup(
|
||||
params.startupTrace,
|
||||
"sidecars.main-session-recovery-load",
|
||||
loadMainSessionRestartRecoveryMarkingModule,
|
||||
);
|
||||
await measureStartup(params.startupTrace, "sidecars.main-session-recovery-scan", () =>
|
||||
markStartupOrphanedMainSessionsForRecovery({ cfg: params.cfg }),
|
||||
);
|
||||
} catch (err) {
|
||||
params.log.warn(
|
||||
`main-session startup orphan marking failed before channel startup: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
// Agent RPC remains available when transports are disabled. Publish configured/static facts before
|
||||
// accepting work; live provider catalogs stay advisory and never enter the Gateway lifecycle.
|
||||
await measureStartup(params.startupTrace, "sidecars.model-runtime", () =>
|
||||
@@ -671,17 +693,6 @@ export async function startGatewaySidecars(params: {
|
||||
params.prewarmPrimaryModel,
|
||||
),
|
||||
);
|
||||
await measureStartup(params.startupTrace, "sidecars.main-session-recovery", async () => {
|
||||
try {
|
||||
const { markStartupOrphanedMainSessionsForRecovery } =
|
||||
await loadMainSessionRestartRecoveryModule();
|
||||
await markStartupOrphanedMainSessionsForRecovery({ cfg: params.cfg });
|
||||
} catch (err) {
|
||||
params.log.warn(
|
||||
`main-session startup orphan marking failed before channel startup: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
await measureStartup(params.startupTrace, "sidecars.channels", async () => {
|
||||
if (!skipChannels) {
|
||||
try {
|
||||
|
||||
@@ -80,8 +80,8 @@ vi.mock("./manifest-registry.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./plugin-registry.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./plugin-registry.js")>();
|
||||
vi.mock("./plugin-registry-snapshot.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./plugin-registry-snapshot.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadPluginRegistrySnapshot: mocks.loadPluginRegistrySnapshot,
|
||||
|
||||
@@ -59,25 +59,3 @@ export function safeFileSignature(filePath: string): InstalledPluginFileSignatur
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Compares current file metadata with a stored installed-plugin file signature. */
|
||||
export function fileSignatureMatches(
|
||||
filePath: string,
|
||||
signature: InstalledPluginFileSignature | undefined,
|
||||
): boolean | undefined {
|
||||
if (!signature) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof signature.ctimeMs !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
const current = safeFileSignature(filePath);
|
||||
if (!current) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
current.size === signature.size &&
|
||||
current.mtimeMs === signature.mtimeMs &&
|
||||
current.ctimeMs === signature.ctimeMs
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ vi.mock("./active-runtime-registry.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./plugin-registry.js", () => ({
|
||||
vi.mock("./plugin-registry-snapshot.js", () => ({
|
||||
loadPluginRegistrySnapshot: mocks.loadPluginRegistrySnapshot,
|
||||
loadPluginRegistrySnapshotWithMetadata: mocks.loadPluginRegistrySnapshotWithMetadata,
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Verifies current plugin registry contribution snapshots.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { setCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
|
||||
import { clearCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-state.js";
|
||||
@@ -8,6 +9,7 @@ import type { InstalledPluginIndex } from "./installed-plugin-index.js";
|
||||
import type { PluginManifestRecord } from "./manifest-registry.js";
|
||||
import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js";
|
||||
import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry-contributions.js";
|
||||
import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry-snapshot.js";
|
||||
|
||||
afterEach(() => {
|
||||
clearCurrentPluginMetadataSnapshot();
|
||||
@@ -141,7 +143,7 @@ describe("loadPluginManifestRegistryForPluginRegistry current snapshot", () => {
|
||||
expect(loadPluginManifestRegistryForPluginRegistry({ config, env }).plugins).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not reuse current metadata for explicit registry inputs or diagnostics", () => {
|
||||
it("keeps explicit registry inputs authoritative and reuses current diagnostics", () => {
|
||||
const config: OpenClawConfig = {};
|
||||
const env = {
|
||||
HOME: "/tmp/openclaw-test-home",
|
||||
@@ -190,11 +192,26 @@ describe("loadPluginManifestRegistryForPluginRegistry current snapshot", () => {
|
||||
}),
|
||||
{ config, env, workspaceDir },
|
||||
);
|
||||
const readDirectory = vi.spyOn(fs, "readdirSync");
|
||||
const readFile = vi.spyOn(fs, "readFileSync");
|
||||
const statFile = vi.spyOn(fs, "statSync");
|
||||
|
||||
expect(
|
||||
loadPluginManifestRegistryForPluginRegistry({ config, env, workspaceDir }).plugins.map(
|
||||
(plugin) => plugin.id,
|
||||
),
|
||||
).toEqual([]);
|
||||
).toEqual(["enabled"]);
|
||||
expect(
|
||||
loadPluginRegistrySnapshotWithMetadata({ config, env, workspaceDir }).diagnostics,
|
||||
).toEqual([
|
||||
{
|
||||
level: "info",
|
||||
code: "persisted-registry-missing",
|
||||
message: "missing",
|
||||
},
|
||||
]);
|
||||
expect(readDirectory).not.toHaveBeenCalled();
|
||||
expect(readFile).not.toHaveBeenCalled();
|
||||
expect(statFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getCurrentPluginMetadataSnapshotState,
|
||||
setCurrentPluginMetadataSnapshotState,
|
||||
} from "./current-plugin-metadata-state.js";
|
||||
import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js";
|
||||
import "./plugin-registry-snapshot.js";
|
||||
|
||||
vi.mock("./current-plugin-metadata-snapshot.js", () => ({
|
||||
getCurrentPluginMetadataSnapshot: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
clearPluginMetadataLifecycleCaches();
|
||||
});
|
||||
|
||||
describe("plugin registry snapshot lifecycle", () => {
|
||||
it("clears registry metadata when the snapshot facade is mocked", () => {
|
||||
setCurrentPluginMetadataSnapshotState({ plugins: [] }, "mocked-snapshot-facade");
|
||||
|
||||
expect(() => clearPluginMetadataLifecycleCaches()).not.toThrow();
|
||||
expect(getCurrentPluginMetadataSnapshotState().snapshot).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from "./installed-plugin-index.js";
|
||||
import { markRetainedManagedNpmInstall } from "./managed-npm-retention.js";
|
||||
import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js";
|
||||
import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js";
|
||||
import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js";
|
||||
import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry-snapshot.js";
|
||||
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
|
||||
@@ -275,7 +274,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat diagnostic current metadata as provided registry input", () => {
|
||||
it("reuses diagnostic current metadata without promoting its registry source", () => {
|
||||
const env = {
|
||||
...createHermeticEnv(makeTempDir()),
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
|
||||
@@ -300,6 +299,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
|
||||
configFingerprint: "",
|
||||
workspaceDir,
|
||||
index,
|
||||
registrySource: "derived",
|
||||
registryDiagnostics: [
|
||||
{
|
||||
level: "info",
|
||||
@@ -333,10 +333,27 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
|
||||
},
|
||||
{ config, env, workspaceDir },
|
||||
);
|
||||
const readDirectory = vi.spyOn(fs, "readdirSync");
|
||||
const readFile = vi.spyOn(fs, "readFileSync");
|
||||
const statFile = vi.spyOn(fs, "statSync");
|
||||
|
||||
const result = loadPluginRegistrySnapshotWithMetadata({ config, env, workspaceDir });
|
||||
|
||||
expect(result.source).not.toBe("provided");
|
||||
expect(result).toEqual({
|
||||
snapshot: index,
|
||||
source: "derived",
|
||||
diagnostics: [
|
||||
{
|
||||
level: "info",
|
||||
code: "persisted-registry-missing",
|
||||
message: "missing",
|
||||
},
|
||||
],
|
||||
manifestRegistry: { plugins: [], diagnostics: [] },
|
||||
});
|
||||
expect(readDirectory).not.toHaveBeenCalled();
|
||||
expect(readFile).not.toHaveBeenCalled();
|
||||
expect(statFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reuse current metadata when explicit derivation inputs are supplied", () => {
|
||||
@@ -559,75 +576,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
|
||||
expect(result.diagnostics).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("reuses a memoized registry without polling plugin files", () => {
|
||||
const tempRoot = makeTempDir();
|
||||
const workspaceDir = path.join(tempRoot, "workspace");
|
||||
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
|
||||
const config = {};
|
||||
const first = loadPluginRegistrySnapshotWithMetadata({ config, env, workspaceDir });
|
||||
const readDirectory = vi.spyOn(fs, "readdirSync");
|
||||
const readFile = vi.spyOn(fs, "readFileSync");
|
||||
const statFile = vi.spyOn(fs, "statSync");
|
||||
|
||||
expect(loadPluginRegistrySnapshotWithMetadata({ config, env, workspaceDir })).toBe(first);
|
||||
expect(readDirectory).not.toHaveBeenCalled();
|
||||
expect(readFile).not.toHaveBeenCalled();
|
||||
expect(statFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains only the current process-lifecycle registry graph", () => {
|
||||
const tempRoot = makeTempDir();
|
||||
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
|
||||
const firstWorkspace = path.join(tempRoot, "first-workspace");
|
||||
const secondWorkspace = path.join(tempRoot, "second-workspace");
|
||||
|
||||
const first = loadPluginRegistrySnapshotWithMetadata({
|
||||
config: {},
|
||||
env,
|
||||
workspaceDir: firstWorkspace,
|
||||
});
|
||||
const second = loadPluginRegistrySnapshotWithMetadata({
|
||||
config: {},
|
||||
env,
|
||||
workspaceDir: secondWorkspace,
|
||||
});
|
||||
const refreshedFirst = loadPluginRegistrySnapshotWithMetadata({
|
||||
config: {},
|
||||
env,
|
||||
workspaceDir: firstWorkspace,
|
||||
});
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(refreshedFirst).not.toBe(first);
|
||||
expect(
|
||||
loadPluginRegistrySnapshotWithMetadata({
|
||||
config: {},
|
||||
env,
|
||||
workspaceDir: firstWorkspace,
|
||||
}),
|
||||
).toBe(refreshedFirst);
|
||||
});
|
||||
|
||||
it("refreshes workspace plugin discovery on explicit metadata invalidation", () => {
|
||||
const tempRoot = makeTempDir();
|
||||
const workspaceDir = path.join(tempRoot, "workspace");
|
||||
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
|
||||
|
||||
const first = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, workspaceDir });
|
||||
expect(first.snapshot.plugins.map((plugin) => plugin.pluginId)).not.toContain("demo");
|
||||
|
||||
writePackagePlugin(path.join(workspaceDir, ".openclaw", "extensions", "demo"));
|
||||
|
||||
const second = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, workspaceDir });
|
||||
expect(second).toBe(first);
|
||||
|
||||
clearPluginMetadataLifecycleCaches();
|
||||
|
||||
const refreshed = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, workspaceDir });
|
||||
expect(refreshed.snapshot.plugins.map((plugin) => plugin.pluginId)).toContain("demo");
|
||||
});
|
||||
|
||||
it("ignores malformed load paths while memoizing snapshots", () => {
|
||||
it("ignores malformed load paths while deriving snapshots", () => {
|
||||
const tempRoot = makeTempDir();
|
||||
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
|
||||
const config = {
|
||||
@@ -673,6 +622,36 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
|
||||
expect(result.diagnostics).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("rebuilds when an explicit candidate moves identical package metadata", () => {
|
||||
const tempRoot = makeTempDir();
|
||||
const rootDir = path.join(tempRoot, "workspace");
|
||||
const stateDir = path.join(tempRoot, "state");
|
||||
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
|
||||
const packageContents = JSON.stringify({ name: "demo", version: "1.0.0" });
|
||||
const baseCandidate = createCandidate(rootDir);
|
||||
fs.writeFileSync(path.join(rootDir, "package.json"), packageContents, "utf8");
|
||||
const persisted = loadInstalledPluginIndex({
|
||||
candidates: [{ ...baseCandidate, packageDir: rootDir }],
|
||||
config: {},
|
||||
env,
|
||||
});
|
||||
writePersistedInstalledPluginIndexSync(persisted, { stateDir });
|
||||
const nestedPackageDir = path.join(rootDir, "nested");
|
||||
fs.mkdirSync(nestedPackageDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(nestedPackageDir, "package.json"), packageContents, "utf8");
|
||||
|
||||
const result = loadPluginRegistrySnapshotWithMetadata({
|
||||
candidates: [{ ...baseCandidate, packageDir: nestedPackageDir }],
|
||||
config: {},
|
||||
env,
|
||||
stateDir,
|
||||
});
|
||||
|
||||
expect(result.source).toBe("derived");
|
||||
expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source");
|
||||
expect(result.snapshot.plugins[0]?.packageJson?.path).toBe("nested/package.json");
|
||||
});
|
||||
|
||||
it("derives a complete index when a configured load-path plugin is missing", () => {
|
||||
const tempRoot = makeTempDir();
|
||||
const firstRoot = path.join(tempRoot, "first");
|
||||
@@ -810,7 +789,24 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
|
||||
const metaDir = path.join(rootDir, "..meta");
|
||||
fs.mkdirSync(metaDir, { recursive: true });
|
||||
const packageJsonPath = path.join(metaDir, "package.json");
|
||||
fs.writeFileSync(packageJsonPath, JSON.stringify({ name: "demo", version: "1.0.0" }), "utf8");
|
||||
fs.writeFileSync(
|
||||
packageJsonPath,
|
||||
JSON.stringify({
|
||||
name: "demo",
|
||||
version: "1.0.0",
|
||||
openclaw: {
|
||||
channel: {
|
||||
id: "demo",
|
||||
label: "Demo",
|
||||
commands: {
|
||||
nativeCommandsAutoEnabled: true,
|
||||
nativeSkillsAutoEnabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const index = loadInstalledPluginIndex({ config, env });
|
||||
const [plugin] = index.plugins;
|
||||
if (!plugin) {
|
||||
@@ -842,6 +838,17 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
|
||||
|
||||
expect(result.source).toBe("persisted");
|
||||
expect(result.diagnostics).toStrictEqual([]);
|
||||
expect(result.manifestRegistry).toBeUndefined();
|
||||
const registry = loadPluginManifestRegistryForInstalledIndex({
|
||||
index: result.snapshot,
|
||||
config,
|
||||
env,
|
||||
includeDisabled: true,
|
||||
});
|
||||
expect(registry.plugins[0]?.channelCatalogMeta?.commands).toEqual({
|
||||
nativeCommandsAutoEnabled: true,
|
||||
nativeSkillsAutoEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
@@ -857,6 +864,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
|
||||
const config = {
|
||||
plugins: {
|
||||
load: { paths: [rootDir] },
|
||||
entries: { demo: { enabled: false } },
|
||||
},
|
||||
};
|
||||
writePackagePlugin(rootDir);
|
||||
@@ -902,6 +910,72 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects dangling root, source, and manifest links for disabled records",
|
||||
() => {
|
||||
for (const artifact of ["root", "source", "manifest"] as const) {
|
||||
const tempRoot = makeTempDir();
|
||||
const rootDir = path.join(tempRoot, "workspace");
|
||||
const stateDir = path.join(tempRoot, "state");
|
||||
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
|
||||
const config = {
|
||||
plugins: {
|
||||
load: { paths: [rootDir] },
|
||||
entries: { demo: { enabled: false } },
|
||||
},
|
||||
};
|
||||
writePackagePlugin(rootDir);
|
||||
writePersistedInstalledPluginIndexSync(loadInstalledPluginIndex({ config, env }), {
|
||||
stateDir,
|
||||
});
|
||||
const artifactPath =
|
||||
artifact === "root"
|
||||
? rootDir
|
||||
: path.join(rootDir, artifact === "source" ? "index.ts" : "openclaw.plugin.json");
|
||||
fs.rmSync(artifactPath, { recursive: artifact === "root" });
|
||||
fs.symlinkSync(path.join(tempRoot, "missing"), artifactPath);
|
||||
|
||||
const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir });
|
||||
|
||||
expect([artifact, result.source]).toEqual([artifact, "derived"]);
|
||||
expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects escaped missing package metadata for disabled records", () => {
|
||||
const tempRoot = makeTempDir();
|
||||
const rootDir = path.join(tempRoot, "workspace");
|
||||
const stateDir = path.join(tempRoot, "state");
|
||||
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
|
||||
const config = {
|
||||
plugins: {
|
||||
load: { paths: [rootDir] },
|
||||
entries: { demo: { enabled: false } },
|
||||
},
|
||||
};
|
||||
writePackagePlugin(rootDir);
|
||||
const index = loadInstalledPluginIndex({ config, env });
|
||||
const plugin = requirePluginRecord(index.plugins, "demo");
|
||||
writePersistedInstalledPluginIndexSync(
|
||||
{
|
||||
...index,
|
||||
plugins: [
|
||||
{
|
||||
...plugin,
|
||||
packageJson: { path: "../gone/package.json", hash: "missing" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ stateDir },
|
||||
);
|
||||
|
||||
const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir });
|
||||
|
||||
expect(result.source).toBe("derived");
|
||||
expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source");
|
||||
});
|
||||
|
||||
it("detects same-size same-mtime manifest replacements", () => {
|
||||
const tempRoot = makeTempDir();
|
||||
const rootDir = path.join(tempRoot, "workspace");
|
||||
@@ -1048,10 +1122,10 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
|
||||
expect(result.snapshot.plugins.map((plugin) => plugin.pluginId)).toEqual(["codex", "whatsapp"]);
|
||||
});
|
||||
|
||||
it("resolves a persisted bundled root only once per registry load", () => {
|
||||
it("keeps missing disabled bundled records under the trusted bundled root", () => {
|
||||
const tempRoot = makeTempDir();
|
||||
const packageRoot = path.join(tempRoot, "openclaw");
|
||||
const bundledRoot = path.join(packageRoot, "dist", "extensions");
|
||||
const bundledRoot = path.join(tempRoot, "dist", "extensions");
|
||||
const pluginRoot = path.join(bundledRoot, "whatsapp");
|
||||
const stateDir = path.join(tempRoot, "state");
|
||||
const env = {
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
|
||||
@@ -1059,22 +1133,41 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
|
||||
OPENCLAW_VERSION: "2026.4.26",
|
||||
VITEST: "true",
|
||||
};
|
||||
const pluginIds = ["bundled-one", "bundled-two", "bundled-three", "bundled-four"];
|
||||
|
||||
for (const pluginId of pluginIds) {
|
||||
writeBundledPlugin(path.join(bundledRoot, pluginId), pluginId, "index.js");
|
||||
}
|
||||
const index = loadInstalledPluginIndex({ config: {}, env, stateDir });
|
||||
const config = { plugins: { entries: { whatsapp: { enabled: false } } } };
|
||||
writeBundledPlugin(pluginRoot, "whatsapp", "index.js");
|
||||
const index = loadInstalledPluginIndex({ config, env, stateDir });
|
||||
writePersistedInstalledPluginIndexSync(index, { stateDir });
|
||||
const realpathSpy = vi.spyOn(fs, "realpathSync");
|
||||
fs.rmSync(pluginRoot, { recursive: true });
|
||||
|
||||
const result = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, stateDir });
|
||||
const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir });
|
||||
|
||||
expect(result.source).toBe("persisted");
|
||||
expect(result.snapshot.plugins.map((plugin) => plugin.pluginId).toSorted()).toEqual(
|
||||
pluginIds.toSorted(),
|
||||
);
|
||||
expect(realpathSpy.mock.calls.filter(([filePath]) => filePath === bundledRoot)).toHaveLength(1);
|
||||
expect(result.snapshot.plugins.map((plugin) => plugin.pluginId)).toEqual(["whatsapp"]);
|
||||
expect(result.snapshot.plugins[0]?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps missing disabled inventory beside unchanged configured plugins", () => {
|
||||
const tempRoot = makeTempDir();
|
||||
const liveRoot = path.join(tempRoot, "live");
|
||||
const missingRoot = path.join(tempRoot, "missing");
|
||||
const stateDir = path.join(tempRoot, "state");
|
||||
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
|
||||
const config = {
|
||||
plugins: {
|
||||
load: { paths: [liveRoot, missingRoot] },
|
||||
entries: { missing: { enabled: false } },
|
||||
},
|
||||
};
|
||||
writePackagePlugin(liveRoot, { pluginId: "live" });
|
||||
writePackagePlugin(missingRoot, { pluginId: "missing" });
|
||||
const index = loadInstalledPluginIndex({ config, env });
|
||||
writePersistedInstalledPluginIndexSync(index, { stateDir });
|
||||
fs.rmSync(missingRoot, { recursive: true });
|
||||
|
||||
const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir });
|
||||
|
||||
expect(result.source).toBe("persisted");
|
||||
expect(result.snapshot.plugins.map((plugin) => plugin.pluginId)).toEqual(["live", "missing"]);
|
||||
});
|
||||
|
||||
it("treats a persisted source bundled root as stale once its built peer appears", () => {
|
||||
@@ -1112,6 +1205,49 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces a persisted built root when its source plugin opts out of bundled output", () => {
|
||||
const tempRoot = makeTempDir();
|
||||
const packageRoot = path.join(tempRoot, "openclaw");
|
||||
const bundledRoot = path.join(packageRoot, "dist", "extensions");
|
||||
const sourcePluginDir = path.join(packageRoot, "extensions", "whatsapp");
|
||||
const stateDir = path.join(tempRoot, "state");
|
||||
const env = {
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_VERSION: "2026.4.26",
|
||||
VITEST: "true",
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
|
||||
fs.writeFileSync(path.join(packageRoot, ".git"), "gitdir: /tmp/mock\n", "utf8");
|
||||
fs.writeFileSync(path.join(packageRoot, "pnpm-workspace.yaml"), "packages: []\n", "utf8");
|
||||
writeBundledPlugin(sourcePluginDir, "whatsapp", "index.ts");
|
||||
writeBundledPlugin(path.join(bundledRoot, "whatsapp"), "whatsapp", "index.js");
|
||||
|
||||
const builtIndex = loadInstalledPluginIndex({ config: {}, env, stateDir });
|
||||
expect(builtIndex.plugins.map((plugin) => plugin.rootDir)).toEqual([
|
||||
fs.realpathSync(path.join(bundledRoot, "whatsapp")),
|
||||
]);
|
||||
writePersistedInstalledPluginIndexSync(builtIndex, { stateDir });
|
||||
fs.writeFileSync(
|
||||
path.join(sourcePluginDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@openclaw/whatsapp",
|
||||
version: "1.0.0",
|
||||
openclaw: { extensions: ["./index.ts"], build: { bundledDist: false } },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, stateDir });
|
||||
|
||||
expect(result.source).toBe("derived");
|
||||
expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source");
|
||||
expect(result.snapshot.plugins.map((plugin) => plugin.rootDir)).toEqual([
|
||||
fs.realpathSync(sourcePluginDir),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a persisted bind-mounted source overlay when its built peer exists", () => {
|
||||
const tempRoot = makeTempDir();
|
||||
const packageRoot = path.join(tempRoot, "openclaw");
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
// Builds stable snapshots of plugin registry contributions.
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope-config.js";
|
||||
import { tryReadJsonSync } from "../infra/json-files.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveCompatibilityHostVersion } from "../version.js";
|
||||
import { resolveBundledPluginsDir } from "./bundled-dir.js";
|
||||
import { buildLegacyBundledRootPath } from "./bundled-load-path-aliases.js";
|
||||
import { listBundledSourceOverlayDirs } from "./bundled-source-overlays.js";
|
||||
import { normalizePluginsConfig } from "./config-state.js";
|
||||
import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
|
||||
import { clearCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-state.js";
|
||||
import { discoverConfiguredPluginLoadPaths, type PluginDiscoveryResult } from "./discovery.js";
|
||||
import { resolveActivePluginInstallRoots } from "./install-root-context.js";
|
||||
import { fileSignatureMatches, hashJson } from "./installed-plugin-index-hash.js";
|
||||
import type { PluginDiscoveryResult } from "./discovery.js";
|
||||
import { safeFileSignature, safeHashFile } from "./installed-plugin-index-hash.js";
|
||||
import { hasOptionalMissingPluginManifestFile } from "./installed-plugin-index-manifest.js";
|
||||
import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js";
|
||||
import {
|
||||
@@ -26,7 +22,6 @@ import {
|
||||
} from "./installed-plugin-index-store.js";
|
||||
import {
|
||||
getInstalledPluginRecord,
|
||||
extractPluginInstallRecordsFromInstalledPluginIndex,
|
||||
hasMissingConfigPathActivationMetadata,
|
||||
isInstalledPluginEnabled,
|
||||
loadInstalledPluginIndexWithDiscovery,
|
||||
@@ -36,12 +31,67 @@ import {
|
||||
type LoadInstalledPluginIndexParams,
|
||||
type RefreshInstalledPluginIndexParams,
|
||||
} from "./installed-plugin-index.js";
|
||||
import { loadPluginManifestRegistry, type PluginManifestRegistry } from "./manifest-registry.js";
|
||||
import type { PluginManifestRegistry } from "./manifest-registry.js";
|
||||
import { getPackageManifestMetadata, type PackageManifest } from "./manifest.js";
|
||||
import { safeRealpathSync } from "./path-safety.js";
|
||||
import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js";
|
||||
import { isPathInside, safeRealpathSync } from "./path-safety.js";
|
||||
import type { PluginRegistrySnapshotSource } from "./plugin-registry-snapshot.types.js";
|
||||
|
||||
function resolvePluginRegistryContent(
|
||||
index: InstalledPluginIndex,
|
||||
comparePackageJsonPath: boolean,
|
||||
excludedPlugins?: ReadonlyMap<string, string>,
|
||||
): unknown {
|
||||
const {
|
||||
generatedAtMs: _generatedAtMs,
|
||||
refreshReason: _refreshReason,
|
||||
warning: _warning,
|
||||
...content
|
||||
} = index;
|
||||
const excludedRoots = [...(excludedPlugins?.values() ?? [])].map((root) => path.resolve(root));
|
||||
const exclusionPathCache = new Map<string, string>();
|
||||
return {
|
||||
...content,
|
||||
diagnostics: excludedPlugins
|
||||
? content.diagnostics.filter(
|
||||
(diagnostic) =>
|
||||
!(
|
||||
(diagnostic.pluginId && excludedPlugins.has(diagnostic.pluginId)) ||
|
||||
(diagnostic.source &&
|
||||
excludedRoots.some((root) =>
|
||||
isContainedPluginPath(root, diagnostic.source!, exclusionPathCache),
|
||||
))
|
||||
),
|
||||
)
|
||||
: content.diagnostics,
|
||||
installRecords: excludedPlugins
|
||||
? Object.fromEntries(
|
||||
Object.entries(content.installRecords).filter(
|
||||
([pluginId]) => !excludedPlugins.has(pluginId),
|
||||
),
|
||||
)
|
||||
: content.installRecords,
|
||||
plugins: content.plugins
|
||||
.filter((plugin) => !excludedPlugins?.has(plugin.pluginId))
|
||||
.map((plugin) => {
|
||||
const { manifestFile: _manifestFile, packageJson, ...record } = plugin;
|
||||
if (!packageJson) {
|
||||
return record;
|
||||
}
|
||||
if (!comparePackageJsonPath) {
|
||||
return record;
|
||||
}
|
||||
const {
|
||||
fileSignature: _fileSignature,
|
||||
path: packageJsonPath,
|
||||
...stablePackageJson
|
||||
} = packageJson;
|
||||
return Object.assign(record, {
|
||||
packageJson: Object.assign(stablePackageJson, { path: packageJsonPath }),
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export type PluginRegistrySnapshot = InstalledPluginIndex;
|
||||
export type PluginRegistryRecord = InstalledPluginIndexRecord;
|
||||
type PluginRegistryInspection = InstalledPluginIndexStoreInspection;
|
||||
@@ -65,36 +115,6 @@ type PluginRegistrySnapshotResult = {
|
||||
manifestRegistry?: PluginManifestRegistry;
|
||||
};
|
||||
|
||||
const REGISTRY_SNAPSHOT_MEMO_ENV_KEYS = [
|
||||
"APPDATA",
|
||||
"HOME",
|
||||
"OPENCLAW_BUNDLED_PLUGINS_DIR",
|
||||
"OPENCLAW_COMPATIBILITY_HOST_VERSION",
|
||||
"OPENCLAW_CONFIG_PATH",
|
||||
"OPENCLAW_DISABLE_BUNDLED_PLUGINS",
|
||||
"OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS",
|
||||
"OPENCLAW_HOME",
|
||||
"OPENCLAW_NIX_MODE",
|
||||
"OPENCLAW_STATE_DIR",
|
||||
"USERPROFILE",
|
||||
"XDG_CONFIG_HOME",
|
||||
] as const;
|
||||
|
||||
type PluginRegistrySnapshotMemo = {
|
||||
key: string;
|
||||
result: PluginRegistrySnapshotResult;
|
||||
};
|
||||
|
||||
let pluginRegistrySnapshotMemo: PluginRegistrySnapshotMemo | undefined;
|
||||
|
||||
function clearLoadPluginRegistrySnapshotMemo(): void {
|
||||
pluginRegistrySnapshotMemo = undefined;
|
||||
// A retired registry must not leave its published metadata graph behind.
|
||||
clearCurrentPluginMetadataSnapshot();
|
||||
}
|
||||
|
||||
registerPluginMetadataProcessMemoLifecycleClear(clearLoadPluginRegistrySnapshotMemo);
|
||||
|
||||
export type LoadPluginRegistryParams = LoadInstalledPluginIndexParams &
|
||||
InstalledPluginIndexStoreOptions & {
|
||||
index?: PluginRegistrySnapshot;
|
||||
@@ -105,68 +125,6 @@ type GetPluginRecordParams = LoadPluginRegistryParams & {
|
||||
pluginId: string;
|
||||
};
|
||||
|
||||
function pickRegistrySnapshotMemoEnv(env: NodeJS.ProcessEnv): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
REGISTRY_SNAPSHOT_MEMO_ENV_KEYS.flatMap((key) => {
|
||||
const value = env[key];
|
||||
return value === undefined ? [] : [[key, value]];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function canMemoizePluginRegistrySnapshot(params: LoadPluginRegistryParams): boolean {
|
||||
return (
|
||||
params.index === undefined &&
|
||||
params.candidates === undefined &&
|
||||
params.diagnostics === undefined &&
|
||||
params.discovery === undefined &&
|
||||
params.installRecords === undefined &&
|
||||
params.now === undefined &&
|
||||
params.filePath === undefined &&
|
||||
params.pluginIndexFilePath === undefined
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePluginRegistrySnapshotMemoKey(
|
||||
params: LoadPluginRegistryParams,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): string | undefined {
|
||||
if (!canMemoizePluginRegistrySnapshot(params)) {
|
||||
return undefined;
|
||||
}
|
||||
return hashJson({
|
||||
config: params.config ?? null,
|
||||
cwd: process.cwd(),
|
||||
env: pickRegistrySnapshotMemoEnv(env),
|
||||
installRoots: resolveActivePluginInstallRoots(env),
|
||||
hostContractVersion: resolveCompatibilityHostVersion(env),
|
||||
preferPersisted: params.preferPersisted ?? null,
|
||||
// Install, reload, and persisted-index writes clear this memo explicitly.
|
||||
// Polling roots or SQLite here would put discovery back on every hot lookup.
|
||||
stateDir: params.stateDir ? resolveUserPath(params.stateDir, env) : null,
|
||||
workspaceDir: params.workspaceDir ? resolveUserPath(params.workspaceDir, env) : null,
|
||||
});
|
||||
}
|
||||
|
||||
function findPluginRegistrySnapshotMemo(
|
||||
key: string | undefined,
|
||||
): PluginRegistrySnapshotResult | undefined {
|
||||
return key && pluginRegistrySnapshotMemo?.key === key
|
||||
? pluginRegistrySnapshotMemo.result
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function rememberPluginRegistrySnapshotMemo(
|
||||
key: string | undefined,
|
||||
result: PluginRegistrySnapshotResult,
|
||||
): PluginRegistrySnapshotResult {
|
||||
if (!key) {
|
||||
return result;
|
||||
}
|
||||
pluginRegistrySnapshotMemo = { key, result };
|
||||
return result;
|
||||
}
|
||||
|
||||
function canReuseCurrentPluginMetadataSnapshot(params: LoadPluginRegistryParams): boolean {
|
||||
return (
|
||||
params.preferPersisted !== false &&
|
||||
@@ -176,6 +134,7 @@ function canReuseCurrentPluginMetadataSnapshot(params: LoadPluginRegistryParams)
|
||||
params.installRecords === undefined &&
|
||||
params.candidates === undefined &&
|
||||
params.diagnostics === undefined &&
|
||||
params.discovery === undefined &&
|
||||
params.now === undefined
|
||||
);
|
||||
}
|
||||
@@ -186,266 +145,194 @@ function loadCurrentPluginRegistrySnapshotResult(
|
||||
if (!canReuseCurrentPluginMetadataSnapshot(params)) {
|
||||
return undefined;
|
||||
}
|
||||
const env = params.env ?? process.env;
|
||||
const current = getCurrentPluginMetadataSnapshot({
|
||||
config: params.config,
|
||||
env,
|
||||
env: params.env ?? process.env,
|
||||
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
|
||||
});
|
||||
if (!current || current.registryDiagnostics.length > 0) {
|
||||
if (!current) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
snapshot: current.index,
|
||||
source: "provided",
|
||||
source:
|
||||
current.registrySource ?? (current.registryDiagnostics.length > 0 ? "derived" : "provided"),
|
||||
diagnostics: current.registryDiagnostics,
|
||||
...(current.discovery ? { discovery: current.discovery } : {}),
|
||||
manifestRegistry: current.manifestRegistry,
|
||||
};
|
||||
}
|
||||
|
||||
function hasMissingPersistedPluginSource(index: InstalledPluginIndex): boolean {
|
||||
function fileContentMatches(
|
||||
filePath: string,
|
||||
hash: string,
|
||||
signature?: InstalledPluginIndexRecord["manifestFile"],
|
||||
trustSignature = true,
|
||||
): boolean {
|
||||
const current = safeFileSignature(filePath);
|
||||
if (!current) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
trustSignature &&
|
||||
signature?.ctimeMs !== undefined &&
|
||||
current.size === signature.size &&
|
||||
current.mtimeMs === signature.mtimeMs &&
|
||||
current.ctimeMs === signature.ctimeMs
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return safeHashFile({ filePath, diagnostics: [], required: false }) === hash;
|
||||
}
|
||||
|
||||
function isContainedPluginPath(
|
||||
rootPath: string,
|
||||
targetPath: string,
|
||||
cache: Map<string, string>,
|
||||
): boolean {
|
||||
// Project unresolved suffixes from the nearest real ancestor so missing disabled
|
||||
// artifacts stay inspectable without accepting symlink or path-alias escapes.
|
||||
const resolveProjectedPath = (inputPath: string): string | null => {
|
||||
const target = path.resolve(inputPath);
|
||||
for (let cursor = target; ; cursor = path.dirname(cursor)) {
|
||||
try {
|
||||
fs.lstatSync(cursor);
|
||||
const realCursor = safeRealpathSync(cursor, cache);
|
||||
return realCursor ? path.resolve(realCursor, path.relative(cursor, target)) : null;
|
||||
} catch {
|
||||
if (cursor === path.dirname(cursor)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const root = resolveProjectedPath(rootPath);
|
||||
const target = resolveProjectedPath(targetPath);
|
||||
return Boolean(root && target && isPathInside(root, target));
|
||||
}
|
||||
|
||||
function hasStalePersistedPluginFiles(index: InstalledPluginIndex): boolean {
|
||||
const realpathCache = new Map<string, string>();
|
||||
return index.plugins.some((plugin) => {
|
||||
if (!plugin.enabled) {
|
||||
if (!isContainedPluginPath(plugin.rootDir, plugin.rootDir, realpathCache)) {
|
||||
return true;
|
||||
}
|
||||
if (!fs.existsSync(plugin.rootDir) && plugin.enabled) {
|
||||
return true;
|
||||
}
|
||||
for (const artifactPath of [plugin.source, plugin.setupSource, plugin.manifestPath]) {
|
||||
if (artifactPath && !isContainedPluginPath(plugin.rootDir, artifactPath, realpathCache)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (
|
||||
plugin.enabled &&
|
||||
((plugin.source ? !fs.existsSync(plugin.source) : false) ||
|
||||
(plugin.setupSource ? !fs.existsSync(plugin.setupSource) : false))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (!hasOptionalMissingPluginManifestFile(plugin)) {
|
||||
if (!fs.existsSync(plugin.manifestPath)) {
|
||||
if (plugin.enabled) {
|
||||
return true;
|
||||
}
|
||||
} else if (
|
||||
!fileContentMatches(plugin.manifestPath, plugin.manifestHash, plugin.manifestFile)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!plugin.packageJson) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
!fs.existsSync(plugin.rootDir) ||
|
||||
(!hasOptionalMissingPluginManifestFile(plugin) && !fs.existsSync(plugin.manifestPath)) ||
|
||||
(plugin.source ? !fs.existsSync(plugin.source) : false) ||
|
||||
(plugin.setupSource ? !fs.existsSync(plugin.setupSource) : false)
|
||||
const packageJsonPath = path.resolve(plugin.rootDir, plugin.packageJson.path);
|
||||
if (!isContainedPluginPath(plugin.rootDir, packageJsonPath, realpathCache)) {
|
||||
return true;
|
||||
}
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
return plugin.enabled;
|
||||
}
|
||||
if (!isRealPathInside(plugin.rootDir, packageJsonPath, realpathCache)) {
|
||||
return true;
|
||||
}
|
||||
return !fileContentMatches(
|
||||
packageJsonPath,
|
||||
plugin.packageJson.hash,
|
||||
plugin.packageJson.fileSignature,
|
||||
plugin.origin === "bundled",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function hasMismatchedPersistedConfigPathPlugins(
|
||||
index: InstalledPluginIndex,
|
||||
params: LoadPluginRegistryParams,
|
||||
env: NodeJS.ProcessEnv,
|
||||
realpathCache: Map<string, string>,
|
||||
): boolean {
|
||||
const loadPaths = normalizePluginsConfig(params.config?.plugins).loadPaths;
|
||||
const discovery = discoverConfiguredPluginLoadPaths({
|
||||
loadPaths,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env,
|
||||
});
|
||||
const configuredRoots = loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env,
|
||||
candidates: discovery.candidates,
|
||||
diagnostics: discovery.diagnostics,
|
||||
installRecords: extractPluginInstallRecordsFromInstalledPluginIndex(index),
|
||||
}).plugins.map((plugin) => resolveComparablePath(plugin.rootDir, realpathCache));
|
||||
const persistedRoots = index.plugins
|
||||
.filter((plugin) => plugin.origin === "config")
|
||||
.map((plugin) => resolveComparablePath(plugin.rootDir, realpathCache));
|
||||
if (configuredRoots.length !== persistedRoots.length) {
|
||||
return true;
|
||||
}
|
||||
return configuredRoots.some((rootDir, position) => rootDir !== persistedRoots[position]);
|
||||
}
|
||||
|
||||
function resolveComparablePath(filePath: string, realpathCache: Map<string, string>): string {
|
||||
return safeRealpathSync(filePath, realpathCache) ?? path.resolve(filePath);
|
||||
}
|
||||
|
||||
function isRelativePathInsideOrEqual(relativePath: string): boolean {
|
||||
return (
|
||||
relativePath === "" ||
|
||||
(relativePath !== ".." &&
|
||||
!relativePath.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relativePath))
|
||||
);
|
||||
}
|
||||
|
||||
function isPathInsideOrEqual(
|
||||
childPath: string,
|
||||
function isRealPathInside(
|
||||
parentPath: string,
|
||||
realpathCache: Map<string, string>,
|
||||
childPath: string,
|
||||
cache: Map<string, string>,
|
||||
): boolean {
|
||||
const relative = path.relative(
|
||||
resolveComparablePath(parentPath, realpathCache),
|
||||
resolveComparablePath(childPath, realpathCache),
|
||||
);
|
||||
return isRelativePathInsideOrEqual(relative);
|
||||
const parent = safeRealpathSync(parentPath, cache);
|
||||
const child = safeRealpathSync(childPath, cache);
|
||||
return Boolean(parent && child && isPathInside(parent, child));
|
||||
}
|
||||
|
||||
function hasMismatchedPersistedBundledPluginRoot(
|
||||
function hasMismatchedPersistedBundledRoot(
|
||||
index: InstalledPluginIndex,
|
||||
env: NodeJS.ProcessEnv,
|
||||
realpathCache: Map<string, string>,
|
||||
): boolean {
|
||||
const bundledPluginsDir = resolveBundledPluginsDir(env);
|
||||
if (!bundledPluginsDir) {
|
||||
const bundledRoot = resolveBundledPluginsDir(env);
|
||||
if (!bundledRoot) {
|
||||
return false;
|
||||
}
|
||||
let sourceOverlayDirs: string[] | undefined;
|
||||
const realpathCache = new Map<string, string>();
|
||||
const overlays = listBundledSourceOverlayDirs({ bundledRoot, env });
|
||||
const legacyRoot = buildLegacyBundledRootPath(bundledRoot);
|
||||
const sourceCheckout =
|
||||
legacyRoot &&
|
||||
fs.existsSync(path.join(path.dirname(legacyRoot), ".git")) &&
|
||||
fs.existsSync(path.join(path.dirname(legacyRoot), "pnpm-workspace.yaml")) &&
|
||||
fs.existsSync(path.join(path.dirname(legacyRoot), "src"));
|
||||
return index.plugins.some((plugin) => {
|
||||
if (plugin.origin !== "bundled") {
|
||||
return false;
|
||||
}
|
||||
sourceOverlayDirs ??= listBundledSourceOverlayDirs({
|
||||
bundledRoot: bundledPluginsDir,
|
||||
env,
|
||||
});
|
||||
return !isAllowedPersistedBundledPluginRoot(
|
||||
plugin,
|
||||
bundledPluginsDir,
|
||||
sourceOverlayDirs,
|
||||
realpathCache,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function isAllowedPersistedBundledPluginRoot(
|
||||
plugin: InstalledPluginIndexRecord,
|
||||
bundledPluginsDir: string,
|
||||
sourceOverlayDirs: readonly string[],
|
||||
realpathCache: Map<string, string>,
|
||||
): boolean {
|
||||
const pluginRootDir = plugin.rootDir;
|
||||
const legacyRoot = buildLegacyBundledRootPath(bundledPluginsDir);
|
||||
if (isPathInsideOrEqual(pluginRootDir, bundledPluginsDir, realpathCache)) {
|
||||
if (!legacyRoot || !isSourceCheckoutBundledPluginRoot(legacyRoot)) {
|
||||
return true;
|
||||
}
|
||||
const relativePluginRoot = path.relative(
|
||||
resolveComparablePath(bundledPluginsDir, realpathCache),
|
||||
resolveComparablePath(pluginRootDir, realpathCache),
|
||||
);
|
||||
return !sourcePluginOptsOutOfBundledDist(path.join(legacyRoot, relativePluginRoot));
|
||||
}
|
||||
if (
|
||||
sourceOverlayDirs.some((overlayDir) =>
|
||||
isPathInsideOrEqual(pluginRootDir, overlayDir, realpathCache),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (!legacyRoot || !isSourceCheckoutBundledPluginRoot(legacyRoot)) {
|
||||
return false;
|
||||
}
|
||||
const relativePluginRoot = path.relative(
|
||||
resolveComparablePath(legacyRoot, realpathCache),
|
||||
resolveComparablePath(pluginRootDir, realpathCache),
|
||||
);
|
||||
if (!isRelativePathInsideOrEqual(relativePluginRoot)) {
|
||||
return false;
|
||||
}
|
||||
if (plugin.packageBuild?.bundledDist === false) {
|
||||
return true;
|
||||
}
|
||||
if (sourcePluginOptsOutOfBundledDist(path.join(legacyRoot, relativePluginRoot))) {
|
||||
// Older index records lack packageBuild. Re-derive once so runtime loading
|
||||
// and OpenClaw fingerprint the same source-only artifact.
|
||||
return false;
|
||||
}
|
||||
// Discovery prefers a built plugin whenever the same child exists in the
|
||||
// packaged root. Keep source-only bundled plugins, but invalidate stale
|
||||
// source records once their built peer appears.
|
||||
return !fs.existsSync(path.join(bundledPluginsDir, relativePluginRoot));
|
||||
}
|
||||
|
||||
function sourcePluginOptsOutOfBundledDist(pluginRootDir: string): boolean {
|
||||
const packageJson = tryReadJsonSync<PackageManifest>(path.join(pluginRootDir, "package.json"));
|
||||
return getPackageManifestMetadata(packageJson ?? undefined)?.build?.bundledDist === false;
|
||||
}
|
||||
|
||||
function isSourceCheckoutBundledPluginRoot(extensionsDir: string): boolean {
|
||||
const packageRoot = path.dirname(extensionsDir);
|
||||
return (
|
||||
fs.existsSync(extensionsDir) &&
|
||||
fs.existsSync(path.join(packageRoot, ".git")) &&
|
||||
fs.existsSync(path.join(packageRoot, "pnpm-workspace.yaml")) &&
|
||||
fs.existsSync(path.join(packageRoot, "src"))
|
||||
);
|
||||
}
|
||||
|
||||
function hashExistingFile(filePath: string): string | null {
|
||||
try {
|
||||
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRecordPackageJsonPath(
|
||||
plugin: InstalledPluginIndexRecord,
|
||||
realpathCache: Map<string, string>,
|
||||
): string | null {
|
||||
const packageJsonPath = plugin.packageJson?.path;
|
||||
if (!packageJsonPath) {
|
||||
return null;
|
||||
}
|
||||
const rootDir = plugin.rootDir || path.dirname(plugin.manifestPath);
|
||||
const resolved = path.resolve(rootDir, packageJsonPath);
|
||||
const relative = path.relative(rootDir, resolved);
|
||||
if (!isRelativePathInsideOrEqual(relative)) {
|
||||
return null;
|
||||
}
|
||||
const realRelative = path.relative(
|
||||
resolveComparablePath(rootDir, realpathCache),
|
||||
resolveComparablePath(resolved, realpathCache),
|
||||
);
|
||||
return isRelativePathInsideOrEqual(realRelative) ? resolved : null;
|
||||
}
|
||||
|
||||
function hasStalePersistedPluginDiagnostics(index: InstalledPluginIndex): boolean {
|
||||
return index.diagnostics.some((diag) => {
|
||||
const source = diag.source;
|
||||
return (
|
||||
typeof diag.pluginId === "string" &&
|
||||
diag.pluginId.trim().length > 0 &&
|
||||
typeof source === "string" &&
|
||||
path.isAbsolute(source) &&
|
||||
!fs.existsSync(source)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function hasStalePersistedPluginMetadata(
|
||||
index: InstalledPluginIndex,
|
||||
realpathCache: Map<string, string>,
|
||||
): boolean {
|
||||
return index.plugins.some((plugin) => {
|
||||
if (!hasOptionalMissingPluginManifestFile(plugin)) {
|
||||
const manifestSignatureMatches = fileSignatureMatches(
|
||||
plugin.manifestPath,
|
||||
plugin.manifestFile,
|
||||
if (!plugin.enabled && !fs.existsSync(plugin.rootDir)) {
|
||||
const allowedRoots = [bundledRoot, ...overlays, ...(legacyRoot ? [legacyRoot] : [])];
|
||||
return !allowedRoots.some((root) =>
|
||||
isContainedPluginPath(root, plugin.rootDir, realpathCache),
|
||||
);
|
||||
if (manifestSignatureMatches !== true) {
|
||||
const manifestHash = hashExistingFile(plugin.manifestPath);
|
||||
if (manifestHash && manifestHash !== plugin.manifestHash) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (isRealPathInside(bundledRoot, plugin.rootDir, realpathCache)) {
|
||||
if (!sourceCheckout) {
|
||||
return false;
|
||||
}
|
||||
const resolvedBundledRoot = safeRealpathSync(bundledRoot, realpathCache) ?? bundledRoot;
|
||||
const resolvedPluginRoot = safeRealpathSync(plugin.rootDir, realpathCache) ?? plugin.rootDir;
|
||||
const sourcePackage = tryReadJsonSync<PackageManifest>(
|
||||
path.join(
|
||||
legacyRoot,
|
||||
path.relative(resolvedBundledRoot, resolvedPluginRoot),
|
||||
"package.json",
|
||||
),
|
||||
);
|
||||
return getPackageManifestMetadata(sourcePackage ?? undefined)?.build?.bundledDist === false;
|
||||
}
|
||||
const packageJsonPath = resolveRecordPackageJsonPath(plugin, realpathCache);
|
||||
if (!plugin.packageJson?.hash) {
|
||||
return false;
|
||||
}
|
||||
if (!packageJsonPath) {
|
||||
return true;
|
||||
}
|
||||
const packageJsonSignatureMatches = fileSignatureMatches(
|
||||
packageJsonPath,
|
||||
plugin.packageJson.fileSignature,
|
||||
return (
|
||||
!overlays.some((root) => isRealPathInside(root, plugin.rootDir, realpathCache)) &&
|
||||
!(
|
||||
plugin.packageBuild?.bundledDist === false &&
|
||||
legacyRoot &&
|
||||
isRealPathInside(legacyRoot, plugin.rootDir, realpathCache)
|
||||
)
|
||||
);
|
||||
if (packageJsonSignatureMatches === true && plugin.origin === "bundled") {
|
||||
return false;
|
||||
}
|
||||
if (packageJsonSignatureMatches === false) {
|
||||
return hashExistingFile(packageJsonPath) !== plugin.packageJson.hash;
|
||||
}
|
||||
// Fast same-size rewrites can preserve observable stat fields on some filesystems.
|
||||
const packageJsonHash = hashExistingFile(packageJsonPath);
|
||||
return packageJsonHash !== plugin.packageJson.hash;
|
||||
});
|
||||
}
|
||||
|
||||
function loadSnapshotInstallRecords(params: LoadPluginRegistryParams, env: NodeJS.ProcessEnv) {
|
||||
return loadInstalledPluginIndexInstallRecordsSync({
|
||||
function hasRecoveredInstallRecordsMissingFromPersistedIndex(
|
||||
index: InstalledPluginIndex,
|
||||
params: LoadPluginRegistryParams,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): boolean {
|
||||
const installRecords = loadInstalledPluginIndexInstallRecordsSync({
|
||||
env,
|
||||
...(params.stateDir ? { stateDir: params.stateDir } : {}),
|
||||
...(params.filePath
|
||||
@@ -454,28 +341,32 @@ function loadSnapshotInstallRecords(params: LoadPluginRegistryParams, env: NodeJ
|
||||
? { filePath: params.pluginIndexFilePath }
|
||||
: {}),
|
||||
});
|
||||
const pluginIds = new Set(index.plugins.map((plugin) => plugin.pluginId));
|
||||
return Object.keys(installRecords).some(
|
||||
(pluginId) => !index.installRecords?.[pluginId] || !pluginIds.has(pluginId),
|
||||
);
|
||||
}
|
||||
|
||||
function hasRecoveredInstallRecordsMissingFromPersistedIndex(
|
||||
function requiresDerivedRegistryValidation(
|
||||
index: InstalledPluginIndex,
|
||||
installRecords: ReturnType<typeof loadInstalledPluginIndexInstallRecordsSync>,
|
||||
params: LoadPluginRegistryParams,
|
||||
env: NodeJS.ProcessEnv,
|
||||
hasStalePluginFiles: () => boolean,
|
||||
): boolean {
|
||||
const persistedRecords = extractPluginInstallRecordsFromInstalledPluginIndex(index);
|
||||
const persistedPluginIds = new Set(index.plugins.map((plugin) => plugin.pluginId));
|
||||
return Object.entries(installRecords).some(([pluginId, record]) => {
|
||||
if (persistedRecords[pluginId] && persistedPluginIds.has(pluginId)) {
|
||||
return false;
|
||||
}
|
||||
const installPaths = [record.installPath, record.sourcePath].filter(
|
||||
(candidate): candidate is string =>
|
||||
typeof candidate === "string" && candidate.trim().length > 0,
|
||||
);
|
||||
if (installPaths.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return installPaths.some((installPath) => fs.existsSync(resolveUserPath(installPath, env)));
|
||||
});
|
||||
return (
|
||||
params.candidates !== undefined ||
|
||||
params.discovery !== undefined ||
|
||||
params.diagnostics !== undefined ||
|
||||
params.installRecords !== undefined ||
|
||||
normalizePluginsConfig(params.config?.plugins).loadPaths.length > 0 ||
|
||||
hasMissingConfigPathActivationMetadata(index) ||
|
||||
index.diagnostics.some(({ pluginId, source }) =>
|
||||
Boolean(pluginId && source && path.isAbsolute(source) && !fs.existsSync(source)),
|
||||
) ||
|
||||
hasMismatchedPersistedBundledRoot(index, env) ||
|
||||
hasStalePluginFiles() ||
|
||||
hasRecoveredInstallRecordsMissingFromPersistedIndex(index, params, env)
|
||||
);
|
||||
}
|
||||
|
||||
export function loadPluginRegistrySnapshotWithMetadata(
|
||||
@@ -494,96 +385,117 @@ export function loadPluginRegistrySnapshotWithMetadata(
|
||||
}
|
||||
|
||||
const env = params.env ?? process.env;
|
||||
const memoKey = resolvePluginRegistrySnapshotMemoKey(params, env);
|
||||
const memo = findPluginRegistrySnapshotMemo(memoKey);
|
||||
if (memo) {
|
||||
return memo;
|
||||
}
|
||||
// Bound canonical paths to this registry build; lifecycle changes must
|
||||
// never reuse security-sensitive symlink or plugin-root resolutions.
|
||||
const realpathCache = new Map<string, string>();
|
||||
const diagnostics: PluginRegistrySnapshotDiagnostic[] = [];
|
||||
const persistedReadsEnabled = params.preferPersisted !== false;
|
||||
const pushStaleSourceDiagnostic = (message: string): void => {
|
||||
diagnostics.push({ level: "warn", code: "persisted-registry-stale-source", message });
|
||||
};
|
||||
if (persistedReadsEnabled) {
|
||||
const persistedIndex = readPersistedInstalledPluginIndexSync(params);
|
||||
if (persistedIndex) {
|
||||
if (
|
||||
params.config &&
|
||||
persistedIndex.policyHash !== resolveInstalledPluginIndexPolicyHash(params.config)
|
||||
) {
|
||||
diagnostics.push({
|
||||
level: "warn",
|
||||
code: "persisted-registry-stale-policy",
|
||||
message:
|
||||
"Persisted plugin registry policy does not match current config; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
});
|
||||
} else if (hasMissingPersistedPluginSource(persistedIndex)) {
|
||||
pushStaleSourceDiagnostic(
|
||||
"Persisted plugin registry points at missing plugin files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
);
|
||||
} else if (hasMismatchedPersistedBundledPluginRoot(persistedIndex, env, realpathCache)) {
|
||||
pushStaleSourceDiagnostic(
|
||||
"Persisted plugin registry points at a different bundled plugin tree; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
);
|
||||
} else if (
|
||||
hasMismatchedPersistedConfigPathPlugins(persistedIndex, params, env, realpathCache)
|
||||
) {
|
||||
pushStaleSourceDiagnostic(
|
||||
"Persisted plugin registry does not match configured load-path plugins; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
);
|
||||
} else if (hasStalePersistedPluginDiagnostics(persistedIndex)) {
|
||||
pushStaleSourceDiagnostic(
|
||||
"Persisted plugin registry contains diagnostics referencing missing paths; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
);
|
||||
} else if (hasMissingConfigPathActivationMetadata(persistedIndex)) {
|
||||
pushStaleSourceDiagnostic(
|
||||
"Persisted plugin registry is missing config-path startup metadata; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
);
|
||||
} else if (hasStalePersistedPluginMetadata(persistedIndex, realpathCache)) {
|
||||
pushStaleSourceDiagnostic(
|
||||
"Persisted plugin registry metadata no longer matches plugin manifest or package files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
);
|
||||
} else if (
|
||||
hasRecoveredInstallRecordsMissingFromPersistedIndex(
|
||||
persistedIndex,
|
||||
loadSnapshotInstallRecords(params, env),
|
||||
env,
|
||||
)
|
||||
) {
|
||||
pushStaleSourceDiagnostic(
|
||||
"Persisted plugin registry is missing recoverable managed npm plugins; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
);
|
||||
} else {
|
||||
const persistedResult: PluginRegistrySnapshotResult = {
|
||||
snapshot: persistedIndex,
|
||||
source: "persisted",
|
||||
diagnostics,
|
||||
};
|
||||
return rememberPluginRegistrySnapshotMemo(memoKey, persistedResult);
|
||||
}
|
||||
} else {
|
||||
diagnostics.push({
|
||||
level: "info",
|
||||
code: "persisted-registry-missing",
|
||||
message: "Persisted plugin registry is missing or invalid; using derived plugin index.",
|
||||
});
|
||||
}
|
||||
if (!persistedReadsEnabled) {
|
||||
const derived = loadInstalledPluginIndexWithDiscovery({
|
||||
...params,
|
||||
installRecords: params.installRecords ?? {},
|
||||
});
|
||||
return {
|
||||
snapshot: derived.index,
|
||||
source: "derived",
|
||||
diagnostics: [],
|
||||
discovery: derived.discovery,
|
||||
manifestRegistry: derived.manifestRegistry,
|
||||
};
|
||||
}
|
||||
|
||||
const diagnostics: PluginRegistrySnapshotDiagnostic[] = [];
|
||||
const persistedIndex = readPersistedInstalledPluginIndexSync(params);
|
||||
let stalePluginFiles: boolean | undefined;
|
||||
const hasStalePluginFiles = () =>
|
||||
(stalePluginFiles ??= persistedIndex ? hasStalePersistedPluginFiles(persistedIndex) : false);
|
||||
if (!persistedIndex) {
|
||||
diagnostics.push({
|
||||
level: "info",
|
||||
code: "persisted-registry-missing",
|
||||
message: "Persisted plugin registry is missing or invalid; using derived plugin index.",
|
||||
});
|
||||
} else if (
|
||||
params.config &&
|
||||
persistedIndex.policyHash !== resolveInstalledPluginIndexPolicyHash(params.config)
|
||||
) {
|
||||
diagnostics.push({
|
||||
level: "warn",
|
||||
code: "persisted-registry-stale-policy",
|
||||
message:
|
||||
"Persisted plugin registry policy does not match current config; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
});
|
||||
} else if (!requiresDerivedRegistryValidation(persistedIndex, params, env, hasStalePluginFiles)) {
|
||||
return {
|
||||
snapshot: persistedIndex,
|
||||
source: "persisted",
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
const derived = loadInstalledPluginIndexWithDiscovery({
|
||||
...params,
|
||||
installRecords: persistedReadsEnabled ? params.installRecords : (params.installRecords ?? {}),
|
||||
...(params.filePath && !params.pluginIndexFilePath
|
||||
? { pluginIndexFilePath: params.filePath }
|
||||
: {}),
|
||||
});
|
||||
return rememberPluginRegistrySnapshotMemo(memoKey, {
|
||||
const comparePackageJsonPath =
|
||||
params.candidates !== undefined || params.discovery !== undefined || hasStalePluginFiles();
|
||||
const excludedMissingDisabledPlugins = new Map<string, string>();
|
||||
if (
|
||||
persistedIndex &&
|
||||
params.candidates === undefined &&
|
||||
params.discovery === undefined &&
|
||||
params.installRecords === undefined &&
|
||||
!hasStalePluginFiles() &&
|
||||
!hasMismatchedPersistedBundledRoot(persistedIndex, env)
|
||||
) {
|
||||
const derivedPluginIds = new Set(derived.index.plugins.map((plugin) => plugin.pluginId));
|
||||
for (const plugin of persistedIndex.plugins) {
|
||||
if (!plugin.enabled && !derivedPluginIds.has(plugin.pluginId)) {
|
||||
excludedMissingDisabledPlugins.set(plugin.pluginId, plugin.rootDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
const contentMatches =
|
||||
persistedIndex &&
|
||||
diagnostics.length === 0 &&
|
||||
isDeepStrictEqual(
|
||||
resolvePluginRegistryContent(
|
||||
persistedIndex,
|
||||
comparePackageJsonPath,
|
||||
excludedMissingDisabledPlugins,
|
||||
),
|
||||
resolvePluginRegistryContent(
|
||||
derived.index,
|
||||
comparePackageJsonPath,
|
||||
excludedMissingDisabledPlugins,
|
||||
),
|
||||
);
|
||||
if (persistedIndex && contentMatches) {
|
||||
const packageMetadataMatches = isDeepStrictEqual(
|
||||
resolvePluginRegistryContent(persistedIndex, true),
|
||||
resolvePluginRegistryContent(derived.index, true),
|
||||
);
|
||||
return {
|
||||
snapshot: persistedIndex,
|
||||
source: "persisted",
|
||||
diagnostics,
|
||||
discovery: derived.discovery,
|
||||
...(packageMetadataMatches ? { manifestRegistry: derived.manifestRegistry } : {}),
|
||||
};
|
||||
} else if (persistedIndex && diagnostics.length === 0) {
|
||||
diagnostics.push({
|
||||
level: "warn",
|
||||
code: "persisted-registry-stale-source",
|
||||
message:
|
||||
"Persisted plugin registry no longer matches current plugin discovery or metadata; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
snapshot: derived.index,
|
||||
source: "derived",
|
||||
diagnostics,
|
||||
discovery: derived.discovery,
|
||||
manifestRegistry: derived.manifestRegistry,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSnapshot(params: LoadPluginRegistryParams = {}): PluginRegistrySnapshot {
|
||||
@@ -595,6 +507,7 @@ export function loadPluginRegistrySnapshot(
|
||||
): PluginRegistrySnapshot {
|
||||
return resolveSnapshot(params);
|
||||
}
|
||||
|
||||
export function getPluginRecord(params: GetPluginRecordParams): PluginRegistryRecord | undefined {
|
||||
return getInstalledPluginRecord(resolveSnapshot(params), params.pluginId);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
runOpenClawStateWriteTransaction,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import type { PluginCandidate } from "./discovery.js";
|
||||
import {
|
||||
readPersistedInstalledPluginIndex,
|
||||
@@ -169,15 +166,6 @@ function createIndex(
|
||||
};
|
||||
}
|
||||
|
||||
function createPersistableIndex(pluginId: string): InstalledPluginIndex {
|
||||
const index = createIndex(pluginId);
|
||||
const plugins = index.plugins.map((plugin) => Object.assign({}, plugin, { enabled: false }));
|
||||
return {
|
||||
...index,
|
||||
plugins,
|
||||
};
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new Error(`expected ${label}`);
|
||||
@@ -330,6 +318,29 @@ describe("plugin registry facade", () => {
|
||||
).toEqual(["demo"]);
|
||||
});
|
||||
|
||||
it("keeps missing disabled records inspectable from the persisted registry", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const rootDir = makeTempDir();
|
||||
const config = { plugins: { entries: { demo: { enabled: false } } } };
|
||||
const env = hermeticEnv();
|
||||
const persisted = loadPluginRegistrySnapshot({
|
||||
candidates: [createCandidate(rootDir)],
|
||||
config,
|
||||
env,
|
||||
preferPersisted: false,
|
||||
});
|
||||
await writePersistedInstalledPluginIndex(persisted, { stateDir });
|
||||
fs.rmSync(rootDir, { recursive: true });
|
||||
|
||||
const result = loadPluginRegistrySnapshotWithMetadata({ stateDir, config, env });
|
||||
|
||||
expect(result.source).toBe("persisted");
|
||||
expectPluginRecordFields(getPluginRecord({ index: result.snapshot, pluginId: "demo" }), {
|
||||
pluginId: "demo",
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves contribution owners from a plugin lookup table without rereading manifests", () => {
|
||||
const rootDir = makeTempDir();
|
||||
const candidate = createCandidate(rootDir);
|
||||
@@ -471,7 +482,7 @@ describe("plugin registry facade", () => {
|
||||
expect(normalizedConfig.allow).toEqual(["demo"]);
|
||||
});
|
||||
|
||||
it("reads the persisted registry before deriving from discovered candidates", async () => {
|
||||
it("treats explicit discovered candidates as authoritative", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const rootDir = makeTempDir();
|
||||
const persistedRootDir = makeTempDir();
|
||||
@@ -509,13 +520,70 @@ describe("plugin registry facade", () => {
|
||||
env: hermeticEnv(),
|
||||
});
|
||||
|
||||
expect(result.source).toBe("persisted");
|
||||
expect(result.diagnostics).toStrictEqual([]);
|
||||
expect(result.source).toBe("derived");
|
||||
expectDiagnosticCodes(result.diagnostics, ["persisted-registry-stale-source"]);
|
||||
expect(listPluginRecords({ index: result.snapshot }).map((plugin) => plugin.pluginId)).toEqual([
|
||||
"persisted",
|
||||
"demo",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps content-equivalent timestamp changes on the persisted path", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const rootDir = makeTempDir();
|
||||
const env = hermeticEnv();
|
||||
const persisted = loadPluginRegistrySnapshot({
|
||||
candidates: [createCandidate(rootDir)],
|
||||
env,
|
||||
preferPersisted: false,
|
||||
});
|
||||
await writePersistedInstalledPluginIndex(
|
||||
{
|
||||
...persisted,
|
||||
plugins: [
|
||||
{
|
||||
...expectDefined(persisted.plugins[0], "persisted plugin test invariant"),
|
||||
syntheticAuthRefs: ["demo"],
|
||||
},
|
||||
...persisted.plugins.slice(1),
|
||||
],
|
||||
},
|
||||
{ stateDir },
|
||||
);
|
||||
const manifestPath = path.join(rootDir, "openclaw.plugin.json");
|
||||
const future = new Date(Date.now() + 1_000);
|
||||
fs.utimesSync(manifestPath, future, future);
|
||||
|
||||
const result = loadPluginRegistrySnapshotWithMetadata({ stateDir, env });
|
||||
|
||||
expect(result.source).toBe("persisted");
|
||||
expect(result.snapshot.plugins[0]?.syntheticAuthRefs).toEqual(["demo"]);
|
||||
});
|
||||
|
||||
it("reads install records from a custom SQLite registry path", async () => {
|
||||
const tempDir = makeTempDir();
|
||||
const rootDir = makeTempDir();
|
||||
const filePath = path.join(tempDir, "custom-registry.sqlite");
|
||||
const env = hermeticEnv();
|
||||
const persisted = loadPluginRegistrySnapshot({
|
||||
candidates: [createCandidate(rootDir)],
|
||||
env,
|
||||
preferPersisted: false,
|
||||
});
|
||||
persisted.installRecords = {
|
||||
demo: { source: "npm", spec: "demo@1.0.0", installPath: rootDir },
|
||||
};
|
||||
await writePersistedInstalledPluginIndex(persisted, { filePath });
|
||||
|
||||
const result = loadPluginRegistrySnapshotWithMetadata({ filePath, env });
|
||||
|
||||
expect(result.source).toBe("persisted");
|
||||
expectInstallRecord(result.snapshot.installRecords, "demo", {
|
||||
source: "npm",
|
||||
spec: "demo@1.0.0",
|
||||
installPath: rootDir,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the derived registry when persisted source paths are missing", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const rootDir = makeTempDir();
|
||||
@@ -819,7 +887,7 @@ describe("plugin registry facade", () => {
|
||||
expectSnapshotPluginIds(result.snapshot, ["demo"]);
|
||||
});
|
||||
|
||||
it("reuses config-scoped derived registries within the process", () => {
|
||||
it("derives config-scoped registries for cold callers", () => {
|
||||
const stateDir = makeTempDir();
|
||||
const workspaceDir = makeTempDir();
|
||||
const bundledRoot = makeTempDir();
|
||||
@@ -853,7 +921,7 @@ describe("plugin registry facade", () => {
|
||||
expect(first.source).toBe("derived");
|
||||
expect(second.source).toBe("derived");
|
||||
expect(manifestReadsAfterFirst).toBeGreaterThan(0);
|
||||
expect(manifestReadsAfterSecond).toBe(manifestReadsAfterFirst);
|
||||
expect(manifestReadsAfterSecond).toBeGreaterThan(manifestReadsAfterFirst);
|
||||
});
|
||||
|
||||
it("reloads profile extensions after the metadata lifecycle is cleared", () => {
|
||||
@@ -881,7 +949,7 @@ describe("plugin registry facade", () => {
|
||||
expectSnapshotPluginIds(second.snapshot, ["first", "second"]);
|
||||
});
|
||||
|
||||
it("keys the process registry memo by resolved host contract version", () => {
|
||||
it("derives the resolved host contract version", () => {
|
||||
const stateDir = makeTempDir();
|
||||
const bundledRoot = makeTempDir();
|
||||
const rootDir = path.join(bundledRoot, "demo");
|
||||
@@ -907,56 +975,6 @@ describe("plugin registry facade", () => {
|
||||
expect(second.snapshot.hostContractVersion).toBe("2026.4.26");
|
||||
});
|
||||
|
||||
it("clears the process registry memo after persisted registry writes", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const env = hermeticEnv();
|
||||
await writePersistedInstalledPluginIndex(createPersistableIndex("first"), { stateDir });
|
||||
|
||||
const first = loadPluginRegistrySnapshotWithMetadata({ stateDir, env });
|
||||
await writePersistedInstalledPluginIndex(createPersistableIndex("second"), { stateDir });
|
||||
const second = loadPluginRegistrySnapshotWithMetadata({ stateDir, env });
|
||||
|
||||
expect(first.source).toBe("persisted");
|
||||
expect(second.source).toBe("persisted");
|
||||
expectSnapshotPluginIds(first.snapshot, ["first"]);
|
||||
expectSnapshotPluginIds(second.snapshot, ["second"]);
|
||||
});
|
||||
|
||||
it("reloads externally changed persisted state after the metadata lifecycle is cleared", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const env = hermeticEnv();
|
||||
await writePersistedInstalledPluginIndex(createPersistableIndex("first"), { stateDir });
|
||||
const first = loadPluginRegistrySnapshotWithMetadata({ stateDir, env });
|
||||
const external = createPersistableIndex("second-external");
|
||||
runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE installed_plugin_index
|
||||
SET plugins_json = ?,
|
||||
install_records_json = ?,
|
||||
diagnostics_json = ?,
|
||||
updated_at_ms = ?
|
||||
WHERE index_key = 'installed-plugin-index'
|
||||
`,
|
||||
).run(
|
||||
JSON.stringify(external.plugins),
|
||||
JSON.stringify(external.installRecords),
|
||||
JSON.stringify(external.diagnostics),
|
||||
Date.now(),
|
||||
);
|
||||
},
|
||||
{ env: { ...env, OPENCLAW_STATE_DIR: stateDir } },
|
||||
);
|
||||
clearPluginMetadataLifecycleCaches();
|
||||
const second = loadPluginRegistrySnapshotWithMetadata({ stateDir, env });
|
||||
|
||||
expect(first.source).toBe("persisted");
|
||||
expect(second.source).toBe("persisted");
|
||||
expectSnapshotPluginIds(first.snapshot, ["first"]);
|
||||
expectSnapshotPluginIds(second.snapshot, ["second-external"]);
|
||||
});
|
||||
|
||||
it("derives a fresh registry without persisted install records when caller disables persisted reads", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const rootDir = makeTempDir();
|
||||
|
||||
@@ -16,8 +16,8 @@ import { resetPluginRuntimeStateForTest } from "./runtime.js";
|
||||
const loadPluginRegistrySnapshotWithMetadata = vi.hoisted(() => vi.fn());
|
||||
const loadPluginManifestRegistryForInstalledIndex = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./plugin-registry.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./plugin-registry.js")>();
|
||||
vi.mock("./plugin-registry-snapshot.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./plugin-registry-snapshot.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadPluginRegistrySnapshotWithMetadata: (params: unknown) =>
|
||||
|
||||
@@ -104,9 +104,10 @@ function createRuntimeWebFetchProvider() {
|
||||
|
||||
describe("resolvePluginWebFetchProviders", () => {
|
||||
beforeAll(async () => {
|
||||
vi.doMock("./plugin-registry.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./plugin-registry.js")>("./plugin-registry.js");
|
||||
vi.doMock("./plugin-registry-snapshot.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./plugin-registry-snapshot.js")>(
|
||||
"./plugin-registry-snapshot.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loadPluginRegistrySnapshotWithMetadata: () => ({
|
||||
|
||||
@@ -35,6 +35,22 @@ function writeSecureFile(file: string, contents: string): void {
|
||||
fs.chmodSync(file, 0o600);
|
||||
}
|
||||
|
||||
function writePluginManifest(rootDir: string, manifest: Record<string, unknown>): void {
|
||||
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
...manifest,
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
function createCandidate(
|
||||
rootDir: string,
|
||||
idHint: string,
|
||||
@@ -48,6 +64,16 @@ function createCandidate(
|
||||
};
|
||||
}
|
||||
|
||||
function loadTestRegistry(
|
||||
rootDir: string,
|
||||
idHint: string,
|
||||
origin: PluginOrigin = "global",
|
||||
): PluginManifestRegistry {
|
||||
return loadPluginManifestRegistry({
|
||||
candidates: [createCandidate(rootDir, idHint, origin)],
|
||||
});
|
||||
}
|
||||
|
||||
function pluginIntegrationProviderConfig(pluginId: string, integrationId: string) {
|
||||
return {
|
||||
source: "exec" as const,
|
||||
@@ -67,45 +93,33 @@ afterEach(() => {
|
||||
describe("secret provider integration presets", () => {
|
||||
it("materializes plugin manifest exec providers without provider-specific core code", () => {
|
||||
const rootDir = makeTempDir();
|
||||
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
|
||||
makeSecureDir(path.join(rootDir, "bin"));
|
||||
writeSecureFile(path.join(rootDir, "bin", "resolve.mjs"), "process.stdin.resume();\n");
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "acme-secrets",
|
||||
name: "Acme Secrets",
|
||||
secretProviderIntegrations: {
|
||||
acme: {
|
||||
providerAlias: "acme",
|
||||
displayName: "Acme Vault",
|
||||
description: "Acme exec resolver",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./bin/resolve.mjs", "--profile", "work"],
|
||||
timeoutMs: 3000,
|
||||
noOutputTimeoutMs: 3000,
|
||||
maxOutputBytes: 4096,
|
||||
passEnv: ["HOME"],
|
||||
env: {
|
||||
ACME_PROFILE: "work",
|
||||
},
|
||||
jsonOnly: false,
|
||||
writePluginManifest(rootDir, {
|
||||
id: "acme-secrets",
|
||||
name: "Acme Secrets",
|
||||
secretProviderIntegrations: {
|
||||
acme: {
|
||||
providerAlias: "acme",
|
||||
displayName: "Acme Vault",
|
||||
description: "Acme exec resolver",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./bin/resolve.mjs", "--profile", "work"],
|
||||
timeoutMs: 3000,
|
||||
noOutputTimeoutMs: 3000,
|
||||
maxOutputBytes: 4096,
|
||||
passEnv: ["HOME"],
|
||||
env: {
|
||||
ACME_PROFILE: "work",
|
||||
},
|
||||
jsonOnly: false,
|
||||
},
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const registry = loadPluginManifestRegistry({
|
||||
candidates: [createCandidate(rootDir, "acme-secrets")],
|
||||
},
|
||||
});
|
||||
|
||||
const registry = loadTestRegistry(rootDir, "acme-secrets");
|
||||
|
||||
expect(registry.diagnostics).toEqual([]);
|
||||
expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([
|
||||
{
|
||||
@@ -144,36 +158,24 @@ describe("secret provider integration presets", () => {
|
||||
|
||||
it("normalizes manifest exec provider options to SecretRef provider schema limits", () => {
|
||||
const rootDir = makeTempDir();
|
||||
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
|
||||
writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n");
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "bounded-secrets",
|
||||
secretProviderIntegrations: {
|
||||
bounded: {
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./resolve.mjs", "ok", "x".repeat(1025)],
|
||||
timeoutMs: 120001,
|
||||
noOutputTimeoutMs: 1.5,
|
||||
maxOutputBytes: 20 * 1024 * 1024 + 1,
|
||||
passEnv: ["GOOD_ENV", "bad-env"],
|
||||
},
|
||||
writePluginManifest(rootDir, {
|
||||
id: "bounded-secrets",
|
||||
secretProviderIntegrations: {
|
||||
bounded: {
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./resolve.mjs", "ok", "x".repeat(1025)],
|
||||
timeoutMs: 120001,
|
||||
noOutputTimeoutMs: 1.5,
|
||||
maxOutputBytes: 20 * 1024 * 1024 + 1,
|
||||
passEnv: ["GOOD_ENV", "bad-env"],
|
||||
},
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const registry = loadPluginManifestRegistry({
|
||||
candidates: [createCandidate(rootDir, "bounded-secrets")],
|
||||
},
|
||||
});
|
||||
|
||||
const registry = loadTestRegistry(rootDir, "bounded-secrets");
|
||||
|
||||
expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([
|
||||
{
|
||||
id: "bounded",
|
||||
@@ -203,31 +205,19 @@ describe("secret provider integration presets", () => {
|
||||
|
||||
it("skips presets whose provider alias cannot be used as a SecretRef provider", () => {
|
||||
const rootDir = makeTempDir();
|
||||
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "bad-secrets",
|
||||
secretProviderIntegrations: {
|
||||
bad: {
|
||||
providerAlias: "../bad",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
},
|
||||
writePluginManifest(rootDir, {
|
||||
id: "bad-secrets",
|
||||
secretProviderIntegrations: {
|
||||
bad: {
|
||||
providerAlias: "../bad",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
},
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const registry = loadPluginManifestRegistry({
|
||||
candidates: [createCandidate(rootDir, "bad-secrets")],
|
||||
},
|
||||
});
|
||||
|
||||
const registry = loadTestRegistry(rootDir, "bad-secrets");
|
||||
|
||||
expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -236,54 +226,34 @@ describe("secret provider integration presets", () => {
|
||||
const longPluginRootDir = makeTempDir();
|
||||
const longPluginId = `plugin-${"x".repeat(129)}`;
|
||||
const longIntegrationId = `integration-${"x".repeat(129)}`;
|
||||
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
|
||||
fs.writeFileSync(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n", "utf8");
|
||||
fs.writeFileSync(path.join(longPluginRootDir, "index.ts"), "export default {};\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(longPluginRootDir, "resolve.mjs"),
|
||||
"process.stdin.resume();\n",
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "long-integration-secrets",
|
||||
secretProviderIntegrations: {
|
||||
[longIntegrationId]: {
|
||||
providerAlias: "short-alias",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./resolve.mjs"],
|
||||
},
|
||||
writePluginManifest(rootDir, {
|
||||
id: "long-integration-secrets",
|
||||
secretProviderIntegrations: {
|
||||
[longIntegrationId]: {
|
||||
providerAlias: "short-alias",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./resolve.mjs"],
|
||||
},
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
});
|
||||
writePluginManifest(longPluginRootDir, {
|
||||
id: longPluginId,
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "short-plugin-alias",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./resolve.mjs"],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(longPluginRootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: longPluginId,
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "short-plugin-alias",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./resolve.mjs"],
|
||||
},
|
||||
},
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const registry = loadPluginManifestRegistry({
|
||||
candidates: [
|
||||
@@ -299,61 +269,39 @@ describe("secret provider integration presets", () => {
|
||||
"skips non-node manifest preset commands for %s plugin roots",
|
||||
(origin) => {
|
||||
const rootDir = makeTempDir();
|
||||
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
|
||||
fs.mkdirSync(path.join(rootDir, "bin"));
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: `${origin}-secrets`,
|
||||
...(origin === "bundled" ? { enabledByDefault: true } : {}),
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "vault",
|
||||
source: "exec",
|
||||
command: "./bin/vault-resolver",
|
||||
},
|
||||
writePluginManifest(rootDir, {
|
||||
id: `${origin}-secrets`,
|
||||
...(origin === "bundled" ? { enabledByDefault: true } : {}),
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "vault",
|
||||
source: "exec",
|
||||
command: "./bin/vault-resolver",
|
||||
},
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const registry = loadPluginManifestRegistry({
|
||||
candidates: [createCandidate(rootDir, `${origin}-secrets`, origin)],
|
||||
},
|
||||
});
|
||||
|
||||
const registry = loadTestRegistry(rootDir, `${origin}-secrets`, origin);
|
||||
|
||||
expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("skips presets from disabled installed plugins", () => {
|
||||
const rootDir = makeTempDir();
|
||||
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
|
||||
writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n");
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "disabled-secrets",
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "vault",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./resolve.mjs"],
|
||||
},
|
||||
writePluginManifest(rootDir, {
|
||||
id: "disabled-secrets",
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "vault",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./resolve.mjs"],
|
||||
},
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const registry = loadPluginManifestRegistry({
|
||||
candidates: [createCandidate(rootDir, "disabled-secrets", "global")],
|
||||
@@ -386,28 +334,18 @@ describe("secret provider integration presets", () => {
|
||||
|
||||
it("applies plugin id aliases when filtering disabled presets", () => {
|
||||
const rootDir = makeTempDir();
|
||||
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
|
||||
writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n");
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "openai",
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "vault",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./resolve.mjs"],
|
||||
},
|
||||
writePluginManifest(rootDir, {
|
||||
id: "openai",
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "vault",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./resolve.mjs"],
|
||||
},
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
},
|
||||
});
|
||||
const config = {
|
||||
plugins: {
|
||||
entries: {
|
||||
@@ -429,32 +367,20 @@ describe("secret provider integration presets", () => {
|
||||
|
||||
it("exposes bundled presets enabled by platform default", () => {
|
||||
const rootDir = makeTempDir();
|
||||
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
|
||||
writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n");
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "platform-secrets",
|
||||
enabledByDefaultOnPlatforms: [process.platform],
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "vault",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./resolve.mjs"],
|
||||
},
|
||||
writePluginManifest(rootDir, {
|
||||
id: "platform-secrets",
|
||||
enabledByDefaultOnPlatforms: [process.platform],
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "vault",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./resolve.mjs"],
|
||||
},
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const registry = loadPluginManifestRegistry({
|
||||
candidates: [createCandidate(rootDir, "platform-secrets", "bundled")],
|
||||
},
|
||||
});
|
||||
const registry = loadTestRegistry(rootDir, "platform-secrets", "bundled");
|
||||
|
||||
expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([
|
||||
{
|
||||
@@ -473,33 +399,21 @@ describe("secret provider integration presets", () => {
|
||||
const rootDir = makeTempDir();
|
||||
const linkParent = makeTempDir();
|
||||
const linkRoot = path.join(linkParent, "plugin-link");
|
||||
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
|
||||
writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n");
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "linked-secrets",
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "vault",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./resolve.mjs"],
|
||||
},
|
||||
writePluginManifest(rootDir, {
|
||||
id: "linked-secrets",
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "vault",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./resolve.mjs"],
|
||||
},
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
},
|
||||
});
|
||||
fs.symlinkSync(rootDir, linkRoot);
|
||||
|
||||
const registry = loadPluginManifestRegistry({
|
||||
candidates: [createCandidate(linkRoot, "linked-secrets", "global")],
|
||||
});
|
||||
const registry = loadTestRegistry(linkRoot, "linked-secrets", "global");
|
||||
|
||||
expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([
|
||||
{
|
||||
@@ -517,32 +431,20 @@ describe("secret provider integration presets", () => {
|
||||
"skips secret provider presets from %s plugin roots",
|
||||
(origin) => {
|
||||
const rootDir = makeTempDir();
|
||||
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: `${origin}-secrets`,
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "vault",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./resolve.mjs"],
|
||||
},
|
||||
writePluginManifest(rootDir, {
|
||||
id: `${origin}-secrets`,
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "vault",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./resolve.mjs"],
|
||||
},
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const registry = loadPluginManifestRegistry({
|
||||
candidates: [createCandidate(rootDir, `${origin}-secrets`, origin)],
|
||||
},
|
||||
});
|
||||
|
||||
const registry = loadTestRegistry(rootDir, `${origin}-secrets`, origin);
|
||||
|
||||
expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]);
|
||||
},
|
||||
);
|
||||
@@ -550,7 +452,6 @@ describe("secret provider integration presets", () => {
|
||||
it("resolves a node-based plugin preset with plugin trusted dirs", async () => {
|
||||
const rootDir = makeTempDir();
|
||||
const resolverPath = path.join(rootDir, "bin", "resolve.mjs");
|
||||
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
|
||||
makeSecureDir(path.dirname(resolverPath));
|
||||
writeSecureFile(
|
||||
resolverPath,
|
||||
@@ -565,32 +466,21 @@ describe("secret provider integration presets", () => {
|
||||
"});",
|
||||
].join("\n"),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "vault-secrets",
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "vault",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./bin/resolve.mjs"],
|
||||
allowInsecurePath: true,
|
||||
},
|
||||
writePluginManifest(rootDir, {
|
||||
id: "vault-secrets",
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "vault",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./bin/resolve.mjs"],
|
||||
allowInsecurePath: true,
|
||||
},
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
await withSecureTestNodeExecPath(async () => {
|
||||
const registry = loadPluginManifestRegistry({
|
||||
candidates: [createCandidate(rootDir, "vault-secrets", "global")],
|
||||
});
|
||||
const registry = loadTestRegistry(rootDir, "vault-secrets", "global");
|
||||
const [preset] = listSecretProviderIntegrationPresets({ manifestRegistry: registry });
|
||||
if (!preset) {
|
||||
throw new Error("Expected vault preset");
|
||||
@@ -624,28 +514,18 @@ describe("secret provider integration presets", () => {
|
||||
it("fails closed when a plugin-managed provider is disabled", async () => {
|
||||
const rootDir = makeTempDir();
|
||||
const resolverPath = path.join(rootDir, "resolve.mjs");
|
||||
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
|
||||
fs.writeFileSync(resolverPath, "process.stdin.resume();\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "revoked-secrets",
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "vault",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./resolve.mjs"],
|
||||
},
|
||||
writePluginManifest(rootDir, {
|
||||
id: "revoked-secrets",
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "vault",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./resolve.mjs"],
|
||||
},
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
},
|
||||
});
|
||||
const config = {
|
||||
plugins: {
|
||||
entries: {
|
||||
@@ -715,31 +595,19 @@ describe("secret provider integration presets", () => {
|
||||
|
||||
it("skips node presets without a plugin-root relative entrypoint arg", () => {
|
||||
const rootDir = makeTempDir();
|
||||
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "bad-trust-secrets",
|
||||
secretProviderIntegrations: {
|
||||
bad: {
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["--import", "./bin/hook.mjs", "./bin/resolve.mjs"],
|
||||
},
|
||||
writePluginManifest(rootDir, {
|
||||
id: "bad-trust-secrets",
|
||||
secretProviderIntegrations: {
|
||||
bad: {
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["--import", "./bin/hook.mjs", "./bin/resolve.mjs"],
|
||||
},
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const registry = loadPluginManifestRegistry({
|
||||
candidates: [createCandidate(rootDir, "bad-trust-secrets")],
|
||||
},
|
||||
});
|
||||
|
||||
const registry = loadTestRegistry(rootDir, "bad-trust-secrets");
|
||||
|
||||
expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -748,38 +616,26 @@ describe("secret provider integration presets", () => {
|
||||
() => {
|
||||
const rootDir = makeTempDir();
|
||||
const outsideDir = makeTempDir();
|
||||
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
|
||||
fs.mkdirSync(path.join(rootDir, "bin"));
|
||||
fs.writeFileSync(path.join(outsideDir, "resolve.mjs"), "process.stdin.resume();\n");
|
||||
fs.symlinkSync(
|
||||
path.join(outsideDir, "resolve.mjs"),
|
||||
path.join(rootDir, "bin", "resolve.mjs"),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "symlink-secrets",
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "vault",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./bin/resolve.mjs"],
|
||||
},
|
||||
writePluginManifest(rootDir, {
|
||||
id: "symlink-secrets",
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "vault",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./bin/resolve.mjs"],
|
||||
},
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const registry = loadPluginManifestRegistry({
|
||||
candidates: [createCandidate(rootDir, "symlink-secrets")],
|
||||
},
|
||||
});
|
||||
|
||||
const registry = loadTestRegistry(rootDir, "symlink-secrets");
|
||||
|
||||
expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]);
|
||||
},
|
||||
);
|
||||
@@ -790,34 +646,22 @@ describe("secret provider integration presets", () => {
|
||||
const linkedRoot = path.join(parentDir, "linked-plugin");
|
||||
makeSecureDir(realRoot);
|
||||
fs.symlinkSync(realRoot, linkedRoot, "dir");
|
||||
fs.writeFileSync(path.join(realRoot, "index.ts"), "export default {};\n", "utf8");
|
||||
makeSecureDir(path.join(realRoot, "bin"));
|
||||
writeSecureFile(path.join(realRoot, "bin", "resolve.mjs"), "process.stdin.resume();\n");
|
||||
fs.writeFileSync(
|
||||
path.join(realRoot, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "linked-root-secrets",
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "vault",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./bin/resolve.mjs"],
|
||||
},
|
||||
writePluginManifest(realRoot, {
|
||||
id: "linked-root-secrets",
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "vault",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./bin/resolve.mjs"],
|
||||
},
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const registry = loadPluginManifestRegistry({
|
||||
candidates: [createCandidate(linkedRoot, "linked-root-secrets")],
|
||||
},
|
||||
});
|
||||
|
||||
const registry = loadTestRegistry(linkedRoot, "linked-root-secrets");
|
||||
|
||||
expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([
|
||||
{
|
||||
id: "vault",
|
||||
@@ -845,36 +689,24 @@ describe("secret provider integration presets", () => {
|
||||
() => {
|
||||
const rootDir = makeTempDir();
|
||||
const binDir = path.join(rootDir, "bin");
|
||||
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8");
|
||||
fs.mkdirSync(binDir);
|
||||
fs.writeFileSync(path.join(binDir, "resolve.mjs"), "process.stdin.resume();\n");
|
||||
fs.chmodSync(binDir, 0o777);
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "writable-parent-secrets",
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "vault",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./bin/resolve.mjs"],
|
||||
},
|
||||
writePluginManifest(rootDir, {
|
||||
id: "writable-parent-secrets",
|
||||
secretProviderIntegrations: {
|
||||
vault: {
|
||||
providerAlias: "vault",
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./bin/resolve.mjs"],
|
||||
},
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const registry = loadPluginManifestRegistry({
|
||||
candidates: [createCandidate(rootDir, "writable-parent-secrets")],
|
||||
},
|
||||
});
|
||||
|
||||
const registry = loadTestRegistry(rootDir, "writable-parent-secrets");
|
||||
|
||||
expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]);
|
||||
} finally {
|
||||
fs.chmodSync(binDir, 0o700);
|
||||
|
||||
+170
-598
File diff suppressed because it is too large
Load Diff
@@ -40,16 +40,17 @@ import {
|
||||
tasks,
|
||||
tryPersistTaskUpsert,
|
||||
} from "./task-registry-state.js";
|
||||
import type {
|
||||
JsonValue,
|
||||
TaskDeliveryState,
|
||||
TaskDeliveryStatus,
|
||||
TaskNotifyPolicy,
|
||||
TaskRecord,
|
||||
TaskRuntime,
|
||||
TaskScopeKind,
|
||||
TaskStatus,
|
||||
TaskTerminalOutcome,
|
||||
import {
|
||||
parseTaskNotifyPolicy,
|
||||
type JsonValue,
|
||||
type TaskDeliveryState,
|
||||
type TaskDeliveryStatus,
|
||||
type TaskNotifyPolicy,
|
||||
type TaskRecord,
|
||||
type TaskRuntime,
|
||||
type TaskScopeKind,
|
||||
type TaskStatus,
|
||||
type TaskTerminalOutcome,
|
||||
} from "./task-registry.types.js";
|
||||
import { resolveTaskCleanupAfter } from "./task-retention.js";
|
||||
|
||||
@@ -531,9 +532,10 @@ export function updateTaskNotifyPolicyById(params: {
|
||||
taskId: string;
|
||||
notifyPolicy: TaskNotifyPolicy;
|
||||
}): TaskRecord | null {
|
||||
const notifyPolicy = parseTaskNotifyPolicy(params.notifyPolicy);
|
||||
ensureTaskRegistryReady();
|
||||
return updateTask(params.taskId, {
|
||||
notifyPolicy: params.notifyPolicy,
|
||||
notifyPolicy,
|
||||
lastEventAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
loadTaskRegistryStateFromSqlite,
|
||||
saveTaskRegistryStateToSqlite,
|
||||
} from "./task-registry.store.sqlite.js";
|
||||
import type { TaskDeliveryState, TaskRecord } from "./task-registry.types.js";
|
||||
import type { TaskDeliveryState, TaskNotifyPolicy, TaskRecord } from "./task-registry.types.js";
|
||||
import {
|
||||
parseOptionalTaskTerminalOutcome,
|
||||
parseTaskDeliveryStatus,
|
||||
@@ -355,6 +355,112 @@ describe("task-registry store runtime", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["verbose", "", "state-change", "DONE_ONLY"])(
|
||||
"rejects an invalid notification policy before it can poison a SQLite restart (%s)",
|
||||
async (invalidPolicy) => {
|
||||
await withOpenClawTestState(
|
||||
{ layout: "state-only", prefix: "openclaw-task-invalid-notify-" },
|
||||
async () => {
|
||||
resetTaskRegistryForTests();
|
||||
const created = createTaskRecord({
|
||||
runtime: "acp",
|
||||
ownerKey: "agent:main:main",
|
||||
scopeKind: "session",
|
||||
childSessionKey: "agent:main:acp:notify-policy",
|
||||
runId: "run-invalid-notify-policy",
|
||||
task: "Keep the task registry readable",
|
||||
status: "running",
|
||||
deliveryStatus: "pending",
|
||||
notifyPolicy: "done_only",
|
||||
});
|
||||
const database = openOpenClawStateDatabase();
|
||||
const db = getNodeSqliteKysely<TaskRegistryTestDatabase>(database.db);
|
||||
|
||||
let mutationError: string | null = null;
|
||||
try {
|
||||
updateTaskNotifyPolicyById({
|
||||
taskId: created.taskId,
|
||||
notifyPolicy: invalidPolicy as TaskNotifyPolicy,
|
||||
});
|
||||
} catch (error) {
|
||||
mutationError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
const persisted = executeSqliteQueryTakeFirstSync(
|
||||
database.db,
|
||||
db
|
||||
.selectFrom("task_runs")
|
||||
.select("notify_policy")
|
||||
.where("task_id", "=", created.taskId),
|
||||
);
|
||||
|
||||
let restoredPolicy: TaskNotifyPolicy | null = null;
|
||||
let restoreError: string | null = null;
|
||||
try {
|
||||
reloadTaskRegistryFromStore();
|
||||
restoredPolicy = getTaskById(created.taskId)?.notifyPolicy ?? null;
|
||||
} catch (error) {
|
||||
restoreError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
try {
|
||||
expect({
|
||||
mutationError,
|
||||
persistedPolicy: persisted?.notify_policy,
|
||||
restoredPolicy,
|
||||
restoreError,
|
||||
}).toEqual({
|
||||
mutationError: `Invalid persisted task notify policy: ${JSON.stringify(invalidPolicy)}`,
|
||||
persistedPolicy: "done_only",
|
||||
restoredPolicy: "done_only",
|
||||
restoreError: null,
|
||||
});
|
||||
} finally {
|
||||
if (persisted?.notify_policy !== "done_only") {
|
||||
executeSqliteQuerySync(
|
||||
database.db,
|
||||
db
|
||||
.updateTable("task_runs")
|
||||
.set({ notify_policy: "done_only" })
|
||||
.where("task_id", "=", created.taskId),
|
||||
);
|
||||
}
|
||||
resetTaskRegistryForTests({ persist: false });
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["done_only", "state_changes", "silent"] as const)(
|
||||
"persists valid notification policy %s across a fresh SQLite restart",
|
||||
async (notifyPolicy) => {
|
||||
await withOpenClawTestState(
|
||||
{ layout: "state-only", prefix: "openclaw-task-valid-notify-" },
|
||||
async () => {
|
||||
resetTaskRegistryForTests();
|
||||
const created = createTaskRecord({
|
||||
runtime: "acp",
|
||||
ownerKey: "agent:main:main",
|
||||
scopeKind: "session",
|
||||
childSessionKey: "agent:main:acp:notify-policy",
|
||||
runId: "run-valid-notify-policy",
|
||||
task: "Preserve valid notification policies",
|
||||
status: "running",
|
||||
deliveryStatus: "pending",
|
||||
notifyPolicy: "done_only",
|
||||
});
|
||||
|
||||
expect(
|
||||
updateTaskNotifyPolicyById({ taskId: created.taskId, notifyPolicy })?.notifyPolicy,
|
||||
).toBe(notifyPolicy);
|
||||
reloadTaskRegistryFromStore();
|
||||
expect(getTaskById(created.taskId)?.notifyPolicy).toBe(notifyPolicy);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects corrupt persisted task rows during sqlite restore", async () => {
|
||||
await withOpenClawTestState(
|
||||
{ layout: "state-only", prefix: "openclaw-task-store-corrupt-" },
|
||||
|
||||
@@ -462,6 +462,18 @@ describe("bench-cli-startup", () => {
|
||||
args: ["gateway", "health", "--json"],
|
||||
presets: ["real"],
|
||||
},
|
||||
{
|
||||
id: "gatewayHealthJsonConnected",
|
||||
name: "gateway health --json (connected)",
|
||||
args: ["gateway", "health", "--json"],
|
||||
presets: [],
|
||||
},
|
||||
{
|
||||
id: "gatewayHealthJsonFirstDevice",
|
||||
name: "gateway health --json (first device)",
|
||||
args: ["gateway", "health", "--json"],
|
||||
presets: [],
|
||||
},
|
||||
{ id: "health", name: "health", args: ["health"], presets: ["startup", "real"] },
|
||||
{
|
||||
id: "healthJson",
|
||||
@@ -485,16 +497,22 @@ describe("bench-cli-startup", () => {
|
||||
expect(testing.parseGatewayPortEnv("::1")).toBe(32123);
|
||||
expect(testing.parseGatewayPortEnv("[::1]")).toBe(32123);
|
||||
|
||||
expect(
|
||||
withEnv({ OPENCLAW_GATEWAY_PORT: "45678" }, () =>
|
||||
testing.buildConfigFixture({
|
||||
id: "gatewayHealthJson",
|
||||
name: "gateway health --json",
|
||||
args: ["gateway", "health", "--json"],
|
||||
presets: ["real"],
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ gateway: { port: 45678 } });
|
||||
for (const id of [
|
||||
"gatewayHealthJson",
|
||||
"gatewayHealthJsonConnected",
|
||||
"gatewayHealthJsonFirstDevice",
|
||||
]) {
|
||||
expect(
|
||||
withEnv({ OPENCLAW_GATEWAY_PORT: "45678" }, () =>
|
||||
testing.buildConfigFixture({
|
||||
id,
|
||||
name: "gateway health --json",
|
||||
args: ["gateway", "health", "--json"],
|
||||
presets: [],
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ gateway: { port: 45678 } });
|
||||
}
|
||||
|
||||
for (const invalid of ["45678abc", "127.0.0.1:45678abc"]) {
|
||||
expect(() =>
|
||||
|
||||
@@ -34,6 +34,109 @@ describe("CLI startup benchmark script spawners", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses warmed state for gateway health while isolating first-device samples", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bench-state-scope-test-"));
|
||||
try {
|
||||
const fixturePath = path.join(tmpDir, "record-home.mjs");
|
||||
const homeLogPath = path.join(tmpDir, "homes.log");
|
||||
fs.writeFileSync(
|
||||
fixturePath,
|
||||
[
|
||||
'import { appendFileSync } from "node:fs";',
|
||||
"appendFileSync(process.env.OPENCLAW_BENCH_HOME_LOG, `${process.env.HOME}\\n`);",
|
||||
"console.log('{\"ok\":true}');",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const runCase = (caseId: string) => {
|
||||
fs.rmSync(homeLogPath, { force: true });
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
"scripts/bench-cli-startup.ts",
|
||||
"--entry",
|
||||
fixturePath,
|
||||
"--case",
|
||||
caseId,
|
||||
"--runs",
|
||||
"2",
|
||||
"--warmup",
|
||||
"1",
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_BENCH_HOME_LOG: homeLogPath,
|
||||
},
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
return fs.readFileSync(homeLogPath, "utf8").trim().split("\n");
|
||||
};
|
||||
|
||||
const warmedHomes = runCase("gatewayHealthJsonConnected");
|
||||
expect(warmedHomes).toHaveLength(3);
|
||||
expect(new Set(warmedHomes).size).toBe(1);
|
||||
expect(warmedHomes.every((home) => !fs.existsSync(home))).toBe(true);
|
||||
|
||||
for (const caseId of ["gatewayHealthJson", "gatewayHealthJsonFirstDevice"]) {
|
||||
const sampleHomes = runCase(caseId);
|
||||
expect(sampleHomes).toHaveLength(3);
|
||||
expect(new Set(sampleHomes).size).toBe(3);
|
||||
expect(sampleHomes.every((home) => !fs.existsSync(home))).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("requires connected gateway health probes to exit successfully", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bench-connected-test-"));
|
||||
try {
|
||||
const fixturePath = path.join(tmpDir, "transport-error.mjs");
|
||||
fs.writeFileSync(
|
||||
fixturePath,
|
||||
[
|
||||
'console.log(\'{"ok":false,"gateway_transport_error":"closed"}\');',
|
||||
"process.exitCode = 1;",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const runCase = (caseId: string) =>
|
||||
spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
"scripts/bench-cli-startup.ts",
|
||||
"--entry",
|
||||
fixturePath,
|
||||
"--case",
|
||||
caseId,
|
||||
"--runs",
|
||||
"1",
|
||||
"--warmup",
|
||||
"0",
|
||||
],
|
||||
{ cwd: process.cwd(), encoding: "utf8" },
|
||||
);
|
||||
|
||||
expect(runCase("gatewayHealthJson").status).toBe(0);
|
||||
for (const caseId of ["gatewayHealthJsonConnected", "gatewayHealthJsonFirstDevice"]) {
|
||||
const result = runCase(caseId);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(`${caseId} sample 1: exited with code 1`);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not require unrelated fixture cases for a narrowed preset", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bench-budget-test-"));
|
||||
try {
|
||||
|
||||
@@ -254,6 +254,13 @@ describe("OpenClaw performance workflow", () => {
|
||||
expect(run.indexOf(probeCap)).toBeLessThan(run.indexOf(boundedProbe));
|
||||
});
|
||||
|
||||
it("measures warmed and first-device gateway health separately", () => {
|
||||
const run = findStep("Run OpenClaw source performance probes", "source_performance").run ?? "";
|
||||
|
||||
expect(run).toContain("--case gatewayHealthJsonConnected \\");
|
||||
expect(run).toContain("--case gatewayHealthJsonFirstDevice \\");
|
||||
});
|
||||
|
||||
it("isolates required publication in a fresh artifact-consuming job", () => {
|
||||
const workflow = readWorkflow();
|
||||
const publisher = workflow.jobs?.publish;
|
||||
|
||||
@@ -42,6 +42,48 @@ const ANDROID_RELEASE_WORKFLOW = ".github/workflows/android-release.yml";
|
||||
const STABLE_MAIN_CLOSEOUT_WORKFLOW = ".github/workflows/openclaw-stable-main-closeout.yml";
|
||||
const WINDOWS_NODE_RELEASE_WORKFLOW = ".github/workflows/windows-node-release.yml";
|
||||
const FULL_RELEASE_VALIDATION_WORKFLOW = ".github/workflows/full-release-validation.yml";
|
||||
const FULL_RELEASE_CHILD_DISPATCHES = [
|
||||
{
|
||||
jobName: "normal_ci",
|
||||
kind: "ci",
|
||||
nonceSuffix: "-ci",
|
||||
runName: "CI",
|
||||
stepName: "Dispatch and monitor CI",
|
||||
workflow: "ci.yml",
|
||||
},
|
||||
{
|
||||
jobName: "plugin_prerelease",
|
||||
kind: "plugin-prerelease",
|
||||
nonceSuffix: "-plugin-prerelease",
|
||||
runName: "Plugin Prerelease",
|
||||
stepName: "Dispatch and monitor plugin prerelease",
|
||||
workflow: "plugin-prerelease.yml",
|
||||
},
|
||||
{
|
||||
jobName: "release_checks",
|
||||
kind: "release-checks",
|
||||
nonceSuffix: "-release-checks",
|
||||
runName: "OpenClaw Release Checks",
|
||||
stepName: "Dispatch and monitor release checks",
|
||||
workflow: "openclaw-release-checks.yml",
|
||||
},
|
||||
{
|
||||
jobName: "npm_telegram",
|
||||
kind: "npm-telegram",
|
||||
nonceSuffix: "-npm-telegram",
|
||||
runName: "NPM Telegram Beta E2E",
|
||||
stepName: "Dispatch and monitor npm Telegram E2E",
|
||||
workflow: "npm-telegram-beta-e2e.yml",
|
||||
},
|
||||
{
|
||||
jobName: "performance",
|
||||
kind: "performance",
|
||||
nonceSuffix: "",
|
||||
runName: "OpenClaw Performance",
|
||||
stepName: "Dispatch and monitor OpenClaw Performance",
|
||||
workflow: "openclaw-performance.yml",
|
||||
},
|
||||
] as const;
|
||||
const REPO_ROOT = process.env.GITHUB_WORKSPACE ?? process.cwd();
|
||||
const RELEASE_MAINTAINER_SKILL = resolve(
|
||||
REPO_ROOT,
|
||||
@@ -184,6 +226,192 @@ function expectTextToIncludeAll(text: string | undefined, snippets: string[]): v
|
||||
}
|
||||
}
|
||||
|
||||
function runFullReleaseChildDispatch(
|
||||
child: (typeof FULL_RELEASE_CHILD_DISPATCHES)[number],
|
||||
overrides: Record<string, string> = {},
|
||||
) {
|
||||
const step = workflowStep(
|
||||
workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, child.jobName),
|
||||
child.stepName,
|
||||
);
|
||||
const script = step.run;
|
||||
if (!script) {
|
||||
throw new Error(`Expected full release child dispatch script for ${child.jobName}`);
|
||||
}
|
||||
|
||||
const workdir = tempDirs.make("full-release-child-dispatch-");
|
||||
const ghPath = resolve(workdir, "gh");
|
||||
const sleepPath = resolve(workdir, "sleep");
|
||||
const callsPath = resolve(workdir, "gh-calls.jsonl");
|
||||
const statusPath = resolve(workdir, "status-polls");
|
||||
writeFileSync(callsPath, "");
|
||||
writeFileSync(
|
||||
ghPath,
|
||||
`#!${process.execPath}
|
||||
const fs = require("node:fs");
|
||||
const args = process.argv.slice(2);
|
||||
const env = process.env;
|
||||
fs.appendFileSync(env.MOCK_GH_CALLS, JSON.stringify({
|
||||
args,
|
||||
childWorkflowRef: env.CHILD_WORKFLOW_REF,
|
||||
dispatchRunName: env.DISPATCH_RUN_NAME,
|
||||
}) + "\\n");
|
||||
const jobs = JSON.parse(env.MOCK_GH_JOBS);
|
||||
const conclusion = env.MOCK_GH_CONCLUSION;
|
||||
const url = "https://github.com/openclaw/openclaw/actions/runs/101";
|
||||
function nextStatus() {
|
||||
const statuses = JSON.parse(env.MOCK_GH_STATUSES);
|
||||
let index = 0;
|
||||
try { index = Number(fs.readFileSync(env.MOCK_GH_STATUS_POLLS, "utf8")); } catch {}
|
||||
fs.writeFileSync(env.MOCK_GH_STATUS_POLLS, String(index + 1));
|
||||
return statuses[Math.min(index, statuses.length - 1)];
|
||||
}
|
||||
if (args[0] === "workflow" && args[1] === "run") {
|
||||
if (env.MOCK_GH_DISPATCH_ERROR) {
|
||||
console.error(env.MOCK_GH_DISPATCH_ERROR);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("Created workflow_dispatch event.");
|
||||
} else if (args[0] === "api" && args.some((value) => value.includes("/commits/"))) {
|
||||
console.log(env.MOCK_GH_CURRENT_SHA);
|
||||
} else if (args[0] === "api" && args.some((value) => value.includes("/actions/workflows/") && value.endsWith("/runs"))) {
|
||||
console.log(env.MOCK_GH_MATCHES);
|
||||
} else if (args[0] === "api" && args.some((value) => value.includes("/jobs?"))) {
|
||||
if (env.MOCK_GH_JOBS_ERROR) {
|
||||
console.error(env.MOCK_GH_JOBS_ERROR);
|
||||
process.exit(1);
|
||||
}
|
||||
jobs.forEach((job) => console.log(JSON.stringify(job)));
|
||||
} else if (args[0] === "api" && args.some((value) => value.includes("/actions/runs/"))) {
|
||||
if (env.MOCK_GH_STATUS_ERROR && fs.existsSync(env.MOCK_GH_STATUS_POLLS)) {
|
||||
console.error(env.MOCK_GH_STATUS_ERROR);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(JSON.stringify({
|
||||
conclusion,
|
||||
head_sha: env.MOCK_GH_CHILD_SHA,
|
||||
html_url: url,
|
||||
status: nextStatus(),
|
||||
}));
|
||||
} else if (args[0] === "run" && args[1] === "view") {
|
||||
const field = args[args.indexOf("--json") + 1];
|
||||
if (field === "status" && env.MOCK_GH_STATUS_ERROR) {
|
||||
console.error(env.MOCK_GH_STATUS_ERROR);
|
||||
process.exit(1);
|
||||
}
|
||||
if (field === "jobs") {
|
||||
if (env.MOCK_GH_JOBS_ERROR) {
|
||||
console.error(env.MOCK_GH_JOBS_ERROR);
|
||||
process.exit(1);
|
||||
}
|
||||
const query = args[args.indexOf("--jq") + 1];
|
||||
if (query.startsWith("[.jobs")) {
|
||||
console.log(JSON.stringify(jobs.filter((job) => job.status === "completed" && job.conclusion !== "success" && job.conclusion !== "skipped")));
|
||||
} else {
|
||||
jobs.forEach((job) => console.log(JSON.stringify(job)));
|
||||
}
|
||||
} else {
|
||||
console.log({
|
||||
conclusion,
|
||||
headSha: env.MOCK_GH_CHILD_SHA,
|
||||
status: field === "status" ? nextStatus() : undefined,
|
||||
url,
|
||||
}[field]);
|
||||
}
|
||||
} else if (args[0] !== "run" || args[1] !== "cancel") {
|
||||
console.error("Unexpected mock gh invocation: " + JSON.stringify(args));
|
||||
process.exit(2);
|
||||
}
|
||||
`,
|
||||
);
|
||||
chmodSync(ghPath, 0o755);
|
||||
writeFileSync(sleepPath, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(sleepPath, 0o755);
|
||||
|
||||
const parentSha = "a".repeat(40);
|
||||
const defaultJobs = [
|
||||
{
|
||||
conclusion: "success",
|
||||
html_url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201",
|
||||
name: "Verify release checks",
|
||||
status: "completed",
|
||||
url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201",
|
||||
},
|
||||
];
|
||||
const stepValues: Record<string, string> = {
|
||||
ALLOW_UNRELEASED_CHANGELOG: "false",
|
||||
CANDIDATE_ARTIFACT_JSON: "",
|
||||
CHILD_WORKFLOW_KIND: child.kind,
|
||||
CHILD_WORKFLOW_REF: "main",
|
||||
CODEX_PLUGIN_SPEC: "",
|
||||
CROSS_OS_SUITE_FILTER: "",
|
||||
FAIL_FAST: "false",
|
||||
GH_TOKEN: "fixture-token",
|
||||
LIVE_SUITE_FILTER: "",
|
||||
MODE: "both",
|
||||
PACKAGE_ACCEPTANCE_PACKAGE_SPEC: "",
|
||||
PACKAGE_SPEC: "openclaw@beta",
|
||||
PARENT_WORKFLOW_SHA: parentSha,
|
||||
PROVIDER: "openai",
|
||||
PROVIDER_MODE: "mock-openai",
|
||||
RELEASE_PACKAGE_SPEC: "",
|
||||
RELEASE_PROFILE: "stable",
|
||||
RERUN_GROUP: "all",
|
||||
RUN_RELEASE_SOAK: "false",
|
||||
SCENARIO: "",
|
||||
TARGET_CONTEXT_REF: "",
|
||||
TARGET_REF: "main",
|
||||
TARGET_SHA: "b".repeat(40),
|
||||
};
|
||||
const stepEnv = Object.fromEntries(
|
||||
Object.keys(step.env ?? {}).map((name) => {
|
||||
const value = stepValues[name];
|
||||
if (value === undefined) {
|
||||
throw new Error(`Missing child dispatch fixture value for ${child.jobName}.${name}`);
|
||||
}
|
||||
return [name, value];
|
||||
}),
|
||||
);
|
||||
const result = spawnSync("bash", ["-c", script], {
|
||||
cwd: workdir,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...stepEnv,
|
||||
GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN:
|
||||
readWorkflow(FULL_RELEASE_VALIDATION_WORKFLOW).env
|
||||
?.GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ?? "HTTP 5[0-9][0-9]",
|
||||
GITHUB_OUTPUT: resolve(workdir, "github-output"),
|
||||
GITHUB_REPOSITORY: "openclaw/openclaw",
|
||||
GITHUB_RUN_ATTEMPT: "2",
|
||||
GITHUB_RUN_ID: "77",
|
||||
GITHUB_STEP_SUMMARY: resolve(workdir, "github-summary"),
|
||||
MOCK_GH_CALLS: callsPath,
|
||||
MOCK_GH_CHILD_SHA: parentSha,
|
||||
MOCK_GH_CONCLUSION: "success",
|
||||
MOCK_GH_CURRENT_SHA: parentSha,
|
||||
MOCK_GH_JOBS: JSON.stringify(defaultJobs),
|
||||
MOCK_GH_MATCHES: "[101]",
|
||||
MOCK_GH_STATUSES: '["completed"]',
|
||||
MOCK_GH_STATUS_POLLS: statusPath,
|
||||
PATH: `${workdir}:${process.env.PATH}`,
|
||||
...overrides,
|
||||
},
|
||||
timeout: 10_000,
|
||||
});
|
||||
const calls = readFileSync(callsPath, "utf8")
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map(
|
||||
(line) =>
|
||||
JSON.parse(line) as {
|
||||
args: string[];
|
||||
childWorkflowRef: string;
|
||||
dispatchRunName?: string;
|
||||
},
|
||||
);
|
||||
return { calls, result };
|
||||
}
|
||||
|
||||
function runPackageAcceptanceSummary(params: {
|
||||
advisory?: boolean;
|
||||
dockerArtifactResult?: string;
|
||||
@@ -1205,10 +1433,10 @@ describe("package acceptance workflow", () => {
|
||||
it("requires full release child workflows to run at the parent workflow SHA", () => {
|
||||
const workflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8");
|
||||
const releaseChecksWorkflow = readFileSync(RELEASE_CHECKS_WORKFLOW, "utf8");
|
||||
const performanceJob = workflow.slice(
|
||||
workflow.indexOf(" performance:\n"),
|
||||
workflow.indexOf("\n summary:"),
|
||||
);
|
||||
const performanceJob = workflowStep(
|
||||
workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "performance"),
|
||||
"Dispatch and monitor OpenClaw Performance",
|
||||
).run;
|
||||
|
||||
expect(workflow).toContain("TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}");
|
||||
expect(workflow).toContain("CHILD_WORKFLOW_REF: ${{ github.ref_name }}");
|
||||
@@ -1315,22 +1543,24 @@ describe("package acceptance workflow", () => {
|
||||
});
|
||||
|
||||
it("keeps child-job fail-fast polling best-effort", () => {
|
||||
const workflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8");
|
||||
expect(workflow.match(/continuing with authoritative workflow conclusion\./gu)).toHaveLength(3);
|
||||
for (const child of FULL_RELEASE_CHILD_DISPATCHES.slice(0, 3)) {
|
||||
const dispatch = workflowStep(
|
||||
workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, child.jobName),
|
||||
child.stepName,
|
||||
);
|
||||
expect(dispatch.env?.CHILD_WORKFLOW_KIND).toBe(child.kind);
|
||||
expect(dispatch.run).toContain("continuing with authoritative workflow conclusion.");
|
||||
}
|
||||
});
|
||||
|
||||
it("adopts exact full-release child runs without retrying ambiguous dispatch posts", () => {
|
||||
const childDispatches = [
|
||||
["normal_ci", "Dispatch and monitor CI"],
|
||||
["plugin_prerelease", "Dispatch and monitor plugin prerelease"],
|
||||
["release_checks", "Dispatch and monitor release checks"],
|
||||
["npm_telegram", "Dispatch and monitor npm Telegram E2E"],
|
||||
["performance", "Dispatch and monitor OpenClaw Performance"],
|
||||
] as const;
|
||||
const dispatchScripts = childDispatches.map(([jobName, stepName]) => {
|
||||
const job = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, jobName);
|
||||
return workflowStep(job, stepName).run ?? "";
|
||||
const dispatchScripts = FULL_RELEASE_CHILD_DISPATCHES.map((child) => {
|
||||
const job = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, child.jobName);
|
||||
const step = workflowStep(job, child.stepName);
|
||||
expect(step.env?.CHILD_WORKFLOW_KIND).toBe(child.kind);
|
||||
return step.run ?? "";
|
||||
});
|
||||
expect(new Set(dispatchScripts).size).toBe(1);
|
||||
|
||||
for (const script of dispatchScripts) {
|
||||
expect(script.match(/gh workflow run/gu)).toHaveLength(1);
|
||||
@@ -1411,7 +1641,7 @@ describe("package acceptance workflow", () => {
|
||||
|
||||
const workflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8");
|
||||
const retryCalls = workflow.split("\n").filter((line) => line.includes("gh_with_retry "));
|
||||
expect(retryCalls).toHaveLength(37);
|
||||
expect(retryCalls.length).toBeGreaterThan(0);
|
||||
for (const call of retryCalls) {
|
||||
expect(call).toMatch(/gh_with_retry (api|run view)/u);
|
||||
}
|
||||
@@ -1437,6 +1667,254 @@ describe("package acceptance workflow", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each(FULL_RELEASE_CHILD_DISPATCHES)(
|
||||
"rejects moved workflow refs before dispatching $jobName",
|
||||
(child) => {
|
||||
const { calls, result } = runFullReleaseChildDispatch(child, {
|
||||
MOCK_GH_CURRENT_SHA: "c".repeat(40),
|
||||
});
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("refusing dispatch.");
|
||||
expect(calls.filter(({ args }) => args[0] === "workflow")).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(FULL_RELEASE_CHILD_DISPATCHES)(
|
||||
"adopts the one exact $jobName child after an ambiguous dispatch without reposting",
|
||||
(child) => {
|
||||
const { calls, result } = runFullReleaseChildDispatch(child, {
|
||||
MOCK_GH_DISPATCH_ERROR: "HTTP 500: Failed to run workflow dispatch",
|
||||
});
|
||||
const dispatchCalls = calls.filter(({ args }) => args[0] === "workflow");
|
||||
const adoptionCall = calls.find(({ args }) => args.includes("-X"));
|
||||
|
||||
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
|
||||
expect(result.stderr).toContain("adopted exact run 101");
|
||||
expect(dispatchCalls).toHaveLength(1);
|
||||
expect(dispatchCalls[0]?.args.slice(0, 5)).toEqual([
|
||||
"workflow",
|
||||
"run",
|
||||
child.workflow,
|
||||
"--ref",
|
||||
"main",
|
||||
]);
|
||||
expect(adoptionCall).toMatchObject({
|
||||
childWorkflowRef: "main",
|
||||
dispatchRunName: `${child.runName} full-release-validation-77-2${child.nonceSuffix}`,
|
||||
});
|
||||
expect(adoptionCall?.args).toContain(
|
||||
"[.workflow_runs[] | select(.display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF) | .id]",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(FULL_RELEASE_CHILD_DISPATCHES)(
|
||||
"refuses duplicate exact adoption candidates for $jobName",
|
||||
(child) => {
|
||||
const { calls, result } = runFullReleaseChildDispatch(child, {
|
||||
MOCK_GH_MATCHES: "[101, 102]",
|
||||
});
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("Multiple runs matched");
|
||||
expect(calls.filter(({ args }) => args[0] === "workflow")).toHaveLength(1);
|
||||
expect(calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel")).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(FULL_RELEASE_CHILD_DISPATCHES)(
|
||||
"refuses to adopt or retry a non-transient $jobName dispatch failure",
|
||||
(child) => {
|
||||
const { calls, result } = runFullReleaseChildDispatch(child, {
|
||||
MOCK_GH_DISPATCH_ERROR: "HTTP 422: Validation Failed",
|
||||
});
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("refusing adoption polling");
|
||||
expect(calls.filter(({ args }) => args[0] === "workflow")).toHaveLength(1);
|
||||
expect(calls.some(({ args }) => args.includes("-X"))).toBe(false);
|
||||
expect(calls.some(({ args }) => args[0] === "run" && args[1] === "cancel")).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(FULL_RELEASE_CHILD_DISPATCHES)(
|
||||
"cancels exactly the adopted $jobName child when its workflow SHA differs",
|
||||
(child) => {
|
||||
const { calls, result } = runFullReleaseChildDispatch(child, {
|
||||
MOCK_GH_CHILD_SHA: "c".repeat(40),
|
||||
});
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toContain("expected parent workflow SHA");
|
||||
expect(calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel")).toEqual([
|
||||
expect.objectContaining({ args: ["run", "cancel", "101"] }),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(FULL_RELEASE_CHILD_DISPATCHES)(
|
||||
"cancels exactly the adopted $jobName child when monitoring fails unexpectedly",
|
||||
(child) => {
|
||||
const { calls, result } = runFullReleaseChildDispatch(child, {
|
||||
MOCK_GH_STATUS_ERROR: "HTTP 403: Resource not accessible by integration",
|
||||
});
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("HTTP 403");
|
||||
expect(calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel")).toEqual([
|
||||
expect.objectContaining({ args: ["run", "cancel", "101"] }),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(FULL_RELEASE_CHILD_DISPATCHES.slice(0, 4))(
|
||||
"cancels the exact $jobName child after its first blocking failed job",
|
||||
(child) => {
|
||||
const { calls, result } = runFullReleaseChildDispatch(child, {
|
||||
FAIL_FAST: "true",
|
||||
MOCK_GH_JOBS: JSON.stringify([
|
||||
{
|
||||
conclusion: "failure",
|
||||
html_url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201",
|
||||
name: "Run package acceptance",
|
||||
status: "completed",
|
||||
url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201",
|
||||
},
|
||||
]),
|
||||
MOCK_GH_STATUSES: JSON.stringify([
|
||||
"in_progress",
|
||||
"in_progress",
|
||||
"in_progress",
|
||||
"in_progress",
|
||||
"in_progress",
|
||||
"in_progress",
|
||||
"completed",
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(1);
|
||||
expect(result.stdout).toContain("has failed child jobs before the workflow completed");
|
||||
expect(calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel")).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps CI fail-fast job lookups advisory but npm Telegram job lookups fail-closed", () => {
|
||||
const overrides = {
|
||||
FAIL_FAST: "true",
|
||||
MOCK_GH_JOBS_ERROR: "HTTP 403: Resource not accessible by integration",
|
||||
MOCK_GH_STATUSES: JSON.stringify([
|
||||
"in_progress",
|
||||
"in_progress",
|
||||
"in_progress",
|
||||
"in_progress",
|
||||
"in_progress",
|
||||
"in_progress",
|
||||
"completed",
|
||||
]),
|
||||
};
|
||||
const normalCi = runFullReleaseChildDispatch(FULL_RELEASE_CHILD_DISPATCHES[0], overrides);
|
||||
const npmTelegram = runFullReleaseChildDispatch(FULL_RELEASE_CHILD_DISPATCHES[3], overrides);
|
||||
|
||||
expect(normalCi.result.status, normalCi.result.stderr).toBe(0);
|
||||
expect(normalCi.result.stdout).toContain("continuing with authoritative workflow conclusion.");
|
||||
expect(npmTelegram.result.status).toBe(1);
|
||||
expect(
|
||||
npmTelegram.calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel"),
|
||||
`${npmTelegram.result.stdout}\n${npmTelegram.result.stderr}\n${JSON.stringify(npmTelegram.calls)}`,
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ expectedStatus: 0, jobName: "Run QA Lab parity lane (sqlite)" },
|
||||
{ expectedStatus: 0, jobName: "Run QA Lab live Discord lane" },
|
||||
{ expectedStatus: 0, jobName: "Run repo/live E2E validation / Docker live" },
|
||||
{
|
||||
expectedStatus: 0,
|
||||
jobName: "Run package acceptance / Telegram package acceptance / mock-openai",
|
||||
},
|
||||
{ expectedStatus: 1, jobName: "Run repo/live E2E validation / Repo E2E" },
|
||||
{ expectedStatus: 1, jobName: "Run package acceptance / Verify package integrity" },
|
||||
])("preserves beta fail-fast ownership for $jobName", ({ expectedStatus, jobName }) => {
|
||||
const { calls, result } = runFullReleaseChildDispatch(FULL_RELEASE_CHILD_DISPATCHES[2], {
|
||||
FAIL_FAST: "true",
|
||||
MOCK_GH_JOBS: JSON.stringify([
|
||||
{
|
||||
conclusion: "failure",
|
||||
html_url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201",
|
||||
name: jobName,
|
||||
status: "completed",
|
||||
},
|
||||
]),
|
||||
MOCK_GH_STATUSES: JSON.stringify([
|
||||
"in_progress",
|
||||
"in_progress",
|
||||
"in_progress",
|
||||
"in_progress",
|
||||
"in_progress",
|
||||
"in_progress",
|
||||
"completed",
|
||||
]),
|
||||
RELEASE_PROFILE: "beta",
|
||||
});
|
||||
|
||||
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(expectedStatus);
|
||||
expect(calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel")).toHaveLength(
|
||||
expectedStatus,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ expectedStatus: 0, failOnRegression: "false", profile: "beta" },
|
||||
{ expectedStatus: 1, failOnRegression: "true", profile: "stable" },
|
||||
])(
|
||||
"keeps failed product performance $profile release behavior unchanged",
|
||||
({ expectedStatus, failOnRegression, profile }) => {
|
||||
const { calls, result } = runFullReleaseChildDispatch(FULL_RELEASE_CHILD_DISPATCHES[4], {
|
||||
MOCK_GH_CONCLUSION: "failure",
|
||||
RELEASE_PROFILE: profile,
|
||||
});
|
||||
const dispatch = calls.find(({ args }) => args[0] === "workflow");
|
||||
|
||||
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(expectedStatus);
|
||||
expect(dispatch?.args).toContain(`fail_on_regression=${failOnRegression}`);
|
||||
if (profile === "beta") {
|
||||
expect(result.stdout).toContain("advisory for beta");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ expectedStatus: 0, failingJob: "Run optional live-provider check" },
|
||||
{ expectedStatus: 1, failingJob: "Run package acceptance" },
|
||||
])("keeps Tideclaw alpha package-safety lanes blocking", ({ expectedStatus, failingJob }) => {
|
||||
const { result } = runFullReleaseChildDispatch(FULL_RELEASE_CHILD_DISPATCHES[2], {
|
||||
CHILD_WORKFLOW_REF: "tideclaw/alpha/2026-08-01-0000Z",
|
||||
MOCK_GH_CONCLUSION: "failure",
|
||||
MOCK_GH_JOBS: JSON.stringify([
|
||||
{
|
||||
conclusion: "success",
|
||||
html_url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201",
|
||||
name: "Verify release checks",
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
conclusion: "failure",
|
||||
html_url: "https://github.com/openclaw/openclaw/actions/runs/101/job/202",
|
||||
name: failingJob,
|
||||
status: "completed",
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(expectedStatus);
|
||||
if (expectedStatus === 0) {
|
||||
expect(result.stdout).toContain("accepted Tideclaw alpha advisory lanes");
|
||||
} else {
|
||||
expect(result.stdout).toContain("package-safety Tideclaw alpha release-check lane");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps exhaustive update migration as a separate manual package gate", () => {
|
||||
const workflow = readFileSync(UPDATE_MIGRATION_WORKFLOW, "utf8");
|
||||
const packageWorkflow = readFileSync(PACKAGE_ACCEPTANCE_WORKFLOW, "utf8");
|
||||
@@ -3115,6 +3593,7 @@ describe("package artifact reuse", () => {
|
||||
expect(npmTelegramJob.if).toContain("inputs.rerun_group == 'npm-telegram'");
|
||||
expect(npmTelegramJob.if).not.toContain("inputs.rerun_group == 'all'");
|
||||
expect(dispatchStep.env).toEqual({
|
||||
CHILD_WORKFLOW_KIND: "npm-telegram",
|
||||
CHILD_WORKFLOW_REF: "${{ github.ref_name }}",
|
||||
FAIL_FAST: "${{ inputs.fail_fast }}",
|
||||
GH_TOKEN: "${{ github.token }}",
|
||||
@@ -3126,7 +3605,8 @@ describe("package artifact reuse", () => {
|
||||
});
|
||||
expectTextToIncludeAll(dispatchStep.run, [
|
||||
'dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-npm-telegram"',
|
||||
'dispatch_output="$(gh workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}" 2>&1)"',
|
||||
'dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"',
|
||||
'dispatch_and_wait npm-telegram-beta-e2e.yml "$dispatch_run_name" "${args[@]}"',
|
||||
".display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF",
|
||||
"The dispatch was not retried to avoid creating a duplicate child.",
|
||||
'if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then',
|
||||
|
||||
@@ -446,7 +446,9 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => {
|
||||
expect(releaseWorkflowSource).toContain('--arg targetContextRef "$TARGET_CONTEXT_REF"');
|
||||
expect(releaseWorkflowSource).toContain("targetContextRef: $targetContextRef");
|
||||
expect(normalCiScript).toContain('dispatch_and_wait ci.yml "$dispatch_run_name" "${args[@]}"');
|
||||
expect(normalCiScript).not.toContain("full_release_validation=true");
|
||||
const normalCiDispatchCase = normalCiScript.match(/^\s*ci\)\n([\s\S]*?)^\s*;;$/mu)?.[1];
|
||||
expect(normalCiDispatchCase).toContain('dispatch_and_wait ci.yml "$dispatch_run_name"');
|
||||
expect(normalCiDispatchCase).not.toContain("full_release_validation=true");
|
||||
expect(pluginPrereleaseScript).toContain(
|
||||
'args=(-f target_ref="$TARGET_SHA" -f expected_sha="$TARGET_SHA" -f full_release_validation=true -f dispatch_id="$dispatch_id")',
|
||||
);
|
||||
@@ -676,10 +678,19 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => {
|
||||
default: false,
|
||||
type: "boolean",
|
||||
});
|
||||
expect(
|
||||
fullReleaseSource.match(/has failed child jobs before the workflow completed/gu)?.length,
|
||||
).toBeGreaterThanOrEqual(3);
|
||||
expect(fullReleaseSource.match(/if \[\[ "\$FAIL_FAST" != "true" \]\]; then/gu)?.length).toBe(4);
|
||||
for (const [jobName, kind] of [
|
||||
["normal_ci", "ci"],
|
||||
["plugin_prerelease", "plugin-prerelease"],
|
||||
["release_checks", "release-checks"],
|
||||
["npm_telegram", "npm-telegram"],
|
||||
] as const) {
|
||||
const dispatch: WorkflowStep = fullReleaseWorkflow.jobs[jobName].steps[0];
|
||||
expect(dispatch.env?.CHILD_WORKFLOW_KIND).toBe(kind);
|
||||
expect(dispatch.env?.FAIL_FAST).toBe("${{ inputs.fail_fast }}");
|
||||
expect(dispatch.run).toContain('if [[ "$FAIL_FAST" != "true" ]]; then');
|
||||
expect(dispatch.run).toContain("has failed child jobs before the workflow completed");
|
||||
}
|
||||
expect(fullReleaseWorkflow.jobs.performance.steps[0].env).not.toHaveProperty("FAIL_FAST");
|
||||
expect(fullReleaseSource).toContain('-f fail_fast="$FAIL_FAST"');
|
||||
expect(fullReleaseSource).toContain(
|
||||
"npm-telegram-beta-e2e.yml has failed child jobs before the workflow completed; cancelling the remaining run.",
|
||||
|
||||
@@ -366,7 +366,8 @@ describe("release validation no-push transport", () => {
|
||||
|
||||
expect(fullText).toContain("dispatch_and_wait plugin-prerelease.yml");
|
||||
expect(fullText).toContain("dispatch_and_wait openclaw-release-checks.yml");
|
||||
expect(fullText).toContain("gh workflow run openclaw-performance.yml");
|
||||
expect(fullText).toContain("dispatch_and_wait openclaw-performance.yml");
|
||||
expect(fullText).toContain('gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@"');
|
||||
|
||||
const preparePackage = job(release, "prepare_release_package");
|
||||
const live = job(release, "live_repo_e2e_release_checks");
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
||||
import acpCorePackageJson from "../../packages/acp-core/package.json" with { type: "json" };
|
||||
import { pluginSdkSubpaths } from "../../scripts/lib/plugin-sdk-entries.mjs";
|
||||
import privateLocalOnlyPluginSdkSubpaths from "../../scripts/lib/plugin-sdk-private-local-only-subpaths.json" with { type: "json" };
|
||||
import { createStateSchemaInlinePlugin } from "../../scripts/lib/state-schema-inline-plugin.mjs";
|
||||
import {
|
||||
detectVitestHostInfo as detectVitestHostInfoImpl,
|
||||
isCiLikeEnv,
|
||||
@@ -158,6 +159,7 @@ if (!isCI && localScheduling.throttledBySystem && shouldPrintVitestThrottle(proc
|
||||
export const sharedVitestConfig = {
|
||||
root: repoRoot,
|
||||
envDir: false as const,
|
||||
plugins: [createStateSchemaInlinePlugin(repoRoot)],
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
|
||||
+5
-37
@@ -11,6 +11,10 @@ import {
|
||||
pluginSdkEntrypoints,
|
||||
productionPluginSdkEntrypoints,
|
||||
} from "./scripts/lib/plugin-sdk-entries.mjs";
|
||||
import {
|
||||
createStateSchemaInlinePlugin,
|
||||
STATE_SCHEMA_INLINE_PLUGIN_NAME,
|
||||
} from "./scripts/lib/state-schema-inline-plugin.mjs";
|
||||
import {
|
||||
TSDOWN_PACKAGE_CONFIG_GROUP,
|
||||
TSDOWN_UNIFIED_CONFIG_GROUP,
|
||||
@@ -46,43 +50,7 @@ const env = {
|
||||
const OUTPUT_SOURCE_MAPS = process.env.OUTPUT_SOURCE_MAPS === "1";
|
||||
const RUN_NODE_SKIP_DTS_BUILD = process.env.OPENCLAW_RUN_NODE_SKIP_DTS_BUILD === "1";
|
||||
const TSDOWN_DECLARATIONS = !RUN_NODE_SKIP_DTS_BUILD;
|
||||
export const STATE_SCHEMA_INLINE_PLUGIN_NAME = "openclaw:inline-state-schemas";
|
||||
|
||||
const STATE_SCHEMA_MODULES = [
|
||||
{
|
||||
modulePath: "src/state/openclaw-state-schema.ts",
|
||||
schemaPath: "src/state/openclaw-state-schema.sql",
|
||||
exportName: "OPENCLAW_STATE_SCHEMA_SQL",
|
||||
},
|
||||
{
|
||||
modulePath: "src/state/openclaw-agent-schema.ts",
|
||||
schemaPath: "src/state/openclaw-agent-schema.sql",
|
||||
exportName: "OPENCLAW_AGENT_SCHEMA_SQL",
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** Inline canonical schema bytes so packaged database opens need no SQL asset. */
|
||||
export function createStateSchemaInlinePlugin(rootDir: string = process.cwd()) {
|
||||
const schemasByModulePath = new Map(
|
||||
STATE_SCHEMA_MODULES.map((schema) => [path.resolve(rootDir, schema.modulePath), schema]),
|
||||
);
|
||||
|
||||
return {
|
||||
name: STATE_SCHEMA_INLINE_PLUGIN_NAME,
|
||||
load(this: { addWatchFile(id: string): void }, id: string) {
|
||||
const schema = schemasByModulePath.get(path.resolve(id));
|
||||
if (!schema) {
|
||||
return null;
|
||||
}
|
||||
const schemaPath = path.resolve(rootDir, schema.schemaPath);
|
||||
this.addWatchFile(schemaPath);
|
||||
return {
|
||||
code: `export const ${schema.exportName} = ${JSON.stringify(fs.readFileSync(schemaPath, "utf8"))};\n`,
|
||||
moduleType: "js" as const,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
export { createStateSchemaInlinePlugin, STATE_SCHEMA_INLINE_PLUGIN_NAME };
|
||||
|
||||
const SUPPRESSED_EVAL_WARNING_PATHS = [
|
||||
"@protobufjs/inquire/index.js",
|
||||
|
||||
@@ -95,6 +95,44 @@ describe("RealtimeTalkMediaStreamMeter", () => {
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reclaims its interval when the initial level callback stops it", () => {
|
||||
vi.useFakeTimers();
|
||||
const close = vi.fn(async () => undefined);
|
||||
const disconnectSource = vi.fn();
|
||||
const disconnectAnalyser = vi.fn();
|
||||
class MockAudioContext {
|
||||
readonly close = close;
|
||||
createMediaStreamSource() {
|
||||
return { connect: vi.fn(), disconnect: disconnectSource };
|
||||
}
|
||||
createAnalyser() {
|
||||
return {
|
||||
fftSize: 0,
|
||||
smoothingTimeConstant: 0,
|
||||
disconnect: disconnectAnalyser,
|
||||
getFloatTimeDomainData: (samples: Float32Array) => samples.fill(0.25),
|
||||
};
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("AudioContext", MockAudioContext);
|
||||
const onLevel = vi.fn((level: number) => {
|
||||
if (level > 0) {
|
||||
meter.stop();
|
||||
}
|
||||
});
|
||||
const meter = new RealtimeTalkMediaStreamMeter(onLevel);
|
||||
|
||||
meter.start({} as MediaStream);
|
||||
meter.stop();
|
||||
meter.stop();
|
||||
vi.advanceTimersByTime(1_000);
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(disconnectSource).toHaveBeenCalledOnce();
|
||||
expect(disconnectAnalyser).toHaveBeenCalledOnce();
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("closes an owned AudioContext when analyser setup fails", () => {
|
||||
const close = vi.fn(async () => undefined);
|
||||
class MockAudioContext {
|
||||
|
||||
@@ -160,8 +160,10 @@ export class RealtimeTalkMediaStreamMeter {
|
||||
analyser.fftSize = this.samples.length;
|
||||
analyser.smoothingTimeConstant = 0;
|
||||
source.connect(analyser);
|
||||
this.publishCurrentLevel();
|
||||
this.timer = globalThis.setInterval(() => this.publishCurrentLevel(), 100);
|
||||
// The initial level callback can synchronously stop its owning transport.
|
||||
// Own the interval first so that reentrant cleanup cannot leave it behind.
|
||||
this.publishCurrentLevel();
|
||||
} catch {
|
||||
// Metering is feedback only; capture must still work if Web Audio analysis
|
||||
// is unavailable in an otherwise functional WebRTC browser.
|
||||
|
||||
@@ -128,6 +128,137 @@ describe("realtime Talk conversation", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("bounds streamed assistant delta growth while retaining useful context", () => {
|
||||
let state = createRealtimeTalkConversationState();
|
||||
const opening = "Opening context stays visible. ";
|
||||
|
||||
state = updateRealtimeTalkConversation(state, {
|
||||
role: "assistant",
|
||||
text: `${opening}${"a".repeat(7_900)}`,
|
||||
final: false,
|
||||
nowMs: 1,
|
||||
});
|
||||
state = updateRealtimeTalkConversation(state, {
|
||||
role: "assistant",
|
||||
text: "b".repeat(500),
|
||||
final: false,
|
||||
nowMs: 2,
|
||||
});
|
||||
state = updateRealtimeTalkConversation(state, {
|
||||
role: "assistant",
|
||||
text: `${"c".repeat(500)}NEWEST`,
|
||||
final: false,
|
||||
nowMs: 3,
|
||||
});
|
||||
|
||||
expect(state.entries[0]?.text.length).toBeLessThanOrEqual(8_000);
|
||||
expect(state.entries[0]?.text.startsWith(opening)).toBe(true);
|
||||
expect(state.entries[0]?.text).toContain("\n…\n");
|
||||
expect(state.entries[0]?.text.split("\n…\n")).toHaveLength(2);
|
||||
expect(state.entries[0]?.text.endsWith("NEWEST")).toBe(true);
|
||||
});
|
||||
|
||||
it("replaces a bounded assistant stream with the authoritative final transcript", () => {
|
||||
let state = createRealtimeTalkConversationState();
|
||||
const opening = "Original opening context. ";
|
||||
|
||||
state = updateRealtimeTalkConversation(state, {
|
||||
role: "assistant",
|
||||
text: `${opening}${"draft ".repeat(1_600)}`,
|
||||
final: false,
|
||||
nowMs: 1,
|
||||
});
|
||||
expect(state.entries[0]?.text).toContain("\n…\n");
|
||||
|
||||
state = updateRealtimeTalkConversation(state, {
|
||||
role: "assistant",
|
||||
text: `${opening}corrected ${"final ".repeat(1_600)}DONE`,
|
||||
final: true,
|
||||
nowMs: 2,
|
||||
});
|
||||
|
||||
expect(state.entries[0]?.text.length).toBeLessThanOrEqual(8_000);
|
||||
expect(state.entries[0]?.text.startsWith(`${opening}corrected `)).toBe(true);
|
||||
expect(state.entries[0]?.text).not.toContain("draft ");
|
||||
expect(state.entries[0]?.text.endsWith("DONE")).toBe(true);
|
||||
expect(state.entries[0]?.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("does not expose dangling surrogates at a bounded transcript edge", () => {
|
||||
let state = createRealtimeTalkConversationState();
|
||||
const transcript = `${"a".repeat(8_000)}🚀${"b".repeat(7_740)}`;
|
||||
|
||||
state = updateRealtimeTalkConversation(state, {
|
||||
role: "assistant",
|
||||
text: transcript,
|
||||
final: true,
|
||||
nowMs: 1,
|
||||
});
|
||||
|
||||
const text = state.entries[0]?.text ?? "";
|
||||
expect(text.length).toBeLessThanOrEqual(8_000);
|
||||
expect(text).not.toMatch(
|
||||
/(?:[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF])/,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not trust a natural truncation marker outside the bounded prefix", () => {
|
||||
let state = createRealtimeTalkConversationState();
|
||||
|
||||
state = updateRealtimeTalkConversation(state, {
|
||||
role: "assistant",
|
||||
text: `${"a".repeat(7_998)}\n…\n${"b".repeat(500)}NEWEST`,
|
||||
final: true,
|
||||
nowMs: 1,
|
||||
});
|
||||
|
||||
expect(state.entries[0]?.text.length).toBeLessThanOrEqual(8_000);
|
||||
expect(state.entries[0]?.text.startsWith("a".repeat(256))).toBe(true);
|
||||
expect(state.entries[0]?.text.endsWith("NEWEST")).toBe(true);
|
||||
});
|
||||
|
||||
it.each([255, 256])(
|
||||
"does not retain a lone high surrogate before a natural marker at offset %i",
|
||||
(markerOffset) => {
|
||||
let state = createRealtimeTalkConversationState();
|
||||
const retainedText = "a".repeat(markerOffset - 1);
|
||||
|
||||
state = updateRealtimeTalkConversation(state, {
|
||||
role: "assistant",
|
||||
text: `${retainedText}\uD800\n…\n${"b".repeat(8_000)}NEWEST`,
|
||||
final: true,
|
||||
nowMs: 1,
|
||||
});
|
||||
|
||||
const text = state.entries[0]?.text ?? "";
|
||||
expect(text.length).toBeLessThanOrEqual(8_000);
|
||||
expect(text.startsWith(`${retainedText}\n…\n`)).toBe(true);
|
||||
expect(text.endsWith("NEWEST")).toBe(true);
|
||||
expect(text).not.toMatch(
|
||||
/(?:[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF])/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["user", "assistant"] as const)(
|
||||
"bounds oversized final %s entries while retaining the newest text",
|
||||
(role) => {
|
||||
let state = createRealtimeTalkConversationState();
|
||||
|
||||
state = updateRealtimeTalkConversation(state, {
|
||||
role,
|
||||
text: `Useful opening. ${"x".repeat(9_000)}NEWEST`,
|
||||
final: true,
|
||||
nowMs: 1,
|
||||
});
|
||||
|
||||
expect(state.entries[0]?.text.length).toBeLessThanOrEqual(8_000);
|
||||
expect(state.entries[0]?.text.startsWith("Useful opening. ")).toBe(true);
|
||||
expect(state.entries[0]?.text.endsWith("NEWEST")).toBe(true);
|
||||
expect(state.entries[0]?.isStreaming).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps alternating realtime turns as separate bubbles", () => {
|
||||
let state = createRealtimeTalkConversationState();
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// Control UI chat module implements realtime talk conversation behavior.
|
||||
import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
|
||||
type RealtimeTalkConversationRole = "user" | "assistant";
|
||||
|
||||
export type RealtimeTalkConversationEntry = {
|
||||
@@ -25,6 +27,9 @@ type RealtimeTalkTranscriptUpdate = {
|
||||
};
|
||||
|
||||
const MAX_CONVERSATION_ENTRIES = 60;
|
||||
const MAX_CONVERSATION_ENTRY_CHARS = 8_000;
|
||||
const CONVERSATION_ENTRY_PREFIX_CHARS = 256;
|
||||
const CONVERSATION_ENTRY_TRUNCATION_MARKER = "\n…\n";
|
||||
const USER_FINAL_REWRITE_GRACE_MS = 1_500;
|
||||
|
||||
export function createRealtimeTalkConversationState(): RealtimeTalkConversationState {
|
||||
@@ -96,7 +101,12 @@ function upsertRealtimeConversationEntry(
|
||||
const id = `rt-${state.nextEntryId}`;
|
||||
const entries = [
|
||||
...state.entries,
|
||||
{ id, role, text: text.trimStart(), isStreaming: !isFinal },
|
||||
{
|
||||
id,
|
||||
role,
|
||||
text: boundRealtimeConversationText(text.trimStart()),
|
||||
isStreaming: !isFinal,
|
||||
},
|
||||
].slice(-MAX_CONVERSATION_ENTRIES);
|
||||
return rememberRealtimeConversationEntry(
|
||||
{ ...state, entries, nextEntryId: state.nextEntryId + 1 },
|
||||
@@ -115,10 +125,11 @@ function upsertRealtimeConversationEntry(
|
||||
if (!entry) {
|
||||
return upsertRealtimeConversationEntry(state, role, null, text, isFinal, nowMs);
|
||||
}
|
||||
const updatedText =
|
||||
const mergedText =
|
||||
role === "assistant"
|
||||
? mergeAssistantTranscriptText(entry.text, text, isFinal)
|
||||
: mergeRealtimeTranscriptText(entry.text, text, isFinal);
|
||||
const updatedText = boundRealtimeConversationText(mergedText);
|
||||
const entries =
|
||||
entry.text === updatedText && entry.isStreaming === !isFinal
|
||||
? state.entries
|
||||
@@ -263,6 +274,27 @@ function mergeRealtimeTranscriptText(existing: string, incoming: string, isFinal
|
||||
return `${existing}${separator}${suffix}`;
|
||||
}
|
||||
|
||||
function boundRealtimeConversationText(text: string): string {
|
||||
if (text.length <= MAX_CONVERSATION_ENTRY_CHARS) {
|
||||
return text;
|
||||
}
|
||||
// Keep the opening context for late full-final replacement detection and
|
||||
// the newest tail for the visible conversation. Reuse the original prefix
|
||||
// so repeated streaming deltas do not move the truncation boundary.
|
||||
const markerIndex = text.indexOf(CONVERSATION_ENTRY_TRUNCATION_MARKER);
|
||||
const hasBoundedPrefix =
|
||||
markerIndex >= CONVERSATION_ENTRY_PREFIX_CHARS - 1 &&
|
||||
markerIndex <= CONVERSATION_ENTRY_PREFIX_CHARS;
|
||||
const prefixEnd = hasBoundedPrefix ? markerIndex : CONVERSATION_ENTRY_PREFIX_CHARS;
|
||||
// A natural marker can follow malformed provider text ending in a lone high
|
||||
// surrogate. Keep that code unit out of the retained truncation boundary.
|
||||
const prefix = sliceUtf16Safe(text, 0, prefixEnd).replace(/[\uD800-\uDBFF]$/, "");
|
||||
const tailChars =
|
||||
MAX_CONVERSATION_ENTRY_CHARS - prefix.length - CONVERSATION_ENTRY_TRUNCATION_MARKER.length;
|
||||
const tail = sliceUtf16Safe(text, -tailChars);
|
||||
return `${prefix}${CONVERSATION_ENTRY_TRUNCATION_MARKER}${tail}`;
|
||||
}
|
||||
|
||||
function looksLikeTranscriptReplacement(existing: string, incoming: string): boolean {
|
||||
const existingWords = transcriptWords(existing);
|
||||
const incomingWords = transcriptWords(incoming);
|
||||
|
||||
@@ -614,6 +614,25 @@ describe("GatewayRelayRealtimeTalkTransport", () => {
|
||||
expect(onInputLevel).toHaveBeenLastCalledWith(0);
|
||||
});
|
||||
|
||||
it("reclaims the input meter when its first level update stops the transport", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = createClient();
|
||||
const onInputLevel = vi.fn((level: number) => {
|
||||
if (level > 0) {
|
||||
transport.stop();
|
||||
}
|
||||
});
|
||||
const transport = createTransport({ client, callbacks: { onInputLevel } });
|
||||
|
||||
await expect(transport.start()).resolves.toBe("ready");
|
||||
transport.stop();
|
||||
transport.stop();
|
||||
vi.advanceTimersByTime(1_000);
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(requestCallsFor(client, "talk.session.close")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("bounds stalled microphone appends and aborts every owner on stop", async () => {
|
||||
const onStatus = vi.fn();
|
||||
const client = createClient();
|
||||
|
||||
@@ -216,11 +216,6 @@ export class GoogleLiveRealtimeTalkTransport implements RealtimeTalkTransport {
|
||||
const inputMeter = new RealtimeTalkMediaStreamMeter(this.ctx.callbacks.onInputLevel);
|
||||
this.inputMeter = inputMeter;
|
||||
inputMeter.start(this.media, this.inputContext);
|
||||
if (this.closed || !this.lifecycle.isActive || this.inputMeter !== inputMeter) {
|
||||
// start() publishes synchronously before installing its interval. A
|
||||
// reentrant stop must reclaim the interval that start() installs next.
|
||||
inputMeter.stop(false);
|
||||
}
|
||||
this.assertActivationCurrent();
|
||||
}
|
||||
this.startMicrophonePump();
|
||||
|
||||
@@ -223,6 +223,42 @@ describe("WebRtcSdpRealtimeTalkTransport", () => {
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reclaims the input meter when its first level update stops the transport", async () => {
|
||||
vi.useFakeTimers();
|
||||
stubAnswerSdpFetch();
|
||||
const close = vi.fn(async () => undefined);
|
||||
class MockAudioContext {
|
||||
readonly close = close;
|
||||
createMediaStreamSource() {
|
||||
return { connect: vi.fn(), disconnect: vi.fn() };
|
||||
}
|
||||
createAnalyser() {
|
||||
return {
|
||||
fftSize: 0,
|
||||
smoothingTimeConstant: 0,
|
||||
disconnect: vi.fn(),
|
||||
getFloatTimeDomainData: (samples: Float32Array) => samples.fill(0.25),
|
||||
};
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("AudioContext", MockAudioContext);
|
||||
const onInputLevel = vi.fn((level: number) => {
|
||||
if (level > 0) {
|
||||
transport.stop();
|
||||
}
|
||||
});
|
||||
const transport = createOpenAiTransport({}, { onInputLevel });
|
||||
|
||||
await expect(transport.start()).resolves.toBe("cancelled");
|
||||
transport.stop();
|
||||
transport.stop();
|
||||
vi.advanceTimersByTime(1_000);
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(stopInputTrack).toHaveBeenCalledOnce();
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not continue WebRTC setup when stopped while microphone access is pending", async () => {
|
||||
const fetchMock = vi.fn(async () => new Response("answer-sdp"));
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
@@ -602,19 +602,38 @@ describe("cron view editor", () => {
|
||||
expect(onClosePanel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("wires form changes from prompt and name inputs", () => {
|
||||
it("wires shared text and select controls without changing their field ownership", () => {
|
||||
const onFormChange = vi.fn();
|
||||
const container = renderView({ createOpen: true, onFormChange });
|
||||
const container = renderView({
|
||||
createOpen: true,
|
||||
channels: ["telegram"],
|
||||
channelMeta: [{ id: "telegram", label: "", detailLabel: "Telegram" }],
|
||||
channelLabels: { telegram: "Telegram fallback" },
|
||||
form: { ...DEFAULT_CRON_FORM, scheduleKind: "cron", failureAlertMode: "custom" },
|
||||
onFormChange,
|
||||
});
|
||||
|
||||
const prompt = getElement(container, "#cron-payload-text", HTMLTextAreaElement);
|
||||
prompt.value = "do the thing";
|
||||
prompt.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(onFormChange).toHaveBeenCalledWith({ payloadText: "do the thing" });
|
||||
|
||||
const name = getElement(container, "#cron-name", HTMLInputElement);
|
||||
name.value = "Thing";
|
||||
name.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(onFormChange).toHaveBeenCalledWith({ name: "Thing" });
|
||||
for (const field of ["name", "sessionKey", "deliveryAccountId", "payloadModel"] as const) {
|
||||
const id = `cron-${field.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
|
||||
const input = getElement(container, `#${id}`, HTMLInputElement);
|
||||
if (field === "sessionKey" || field === "deliveryAccountId") {
|
||||
expect(input.placeholder).toBe(field === "sessionKey" ? "agent:main:main" : "default");
|
||||
}
|
||||
input.value = field;
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
expect(onFormChange).toHaveBeenLastCalledWith({ [field]: field });
|
||||
}
|
||||
|
||||
const channel = getElement(container, "#cron-failure-alert-channel", HTMLSelectElement);
|
||||
channel.value = "telegram";
|
||||
expect(channel.selectedOptions[0]?.textContent).toBe("Telegram fallback");
|
||||
channel.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
expect(onFormChange).toHaveBeenLastCalledWith({ failureAlertChannel: "telegram" });
|
||||
});
|
||||
|
||||
it("switches schedule inputs by segmented kind and wires kind changes", () => {
|
||||
|
||||
+373
-674
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user