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:
Vincent Koc
2026-08-02 03:43:22 +08:00
78 changed files with 5278 additions and 4323 deletions
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -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 },
]);
});
});
+12
View File
@@ -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");
+17 -3
View File
@@ -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();
+52
View File
@@ -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 = [
+73
View File
@@ -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: {
+4 -5
View File
@@ -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,
+21 -1
View File
@@ -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 {
+4 -2
View File
@@ -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 } : {}),
}));
}
+54 -96
View File
@@ -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),
],
]);
}
+10 -45
View File
@@ -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.",
],
]);
}
+38 -107
View File
@@ -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.",
],
]);
}
+46 -128
View File
@@ -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(
+19 -45
View File
@@ -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.",
],
]);
}
+36 -116
View File
@@ -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.",
],
]);
}
+64 -198
View File
@@ -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);
+7 -14
View File
@@ -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",
+38 -8
View File
@@ -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",
};
},
};
}
+5 -3
View File
@@ -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 () => {
+3 -5
View File
@@ -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();
+151
View File
@@ -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: [],
},
]);
});
});
+9 -5
View File
@@ -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;
}
+2 -1
View File
@@ -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 {
+61 -1
View File
@@ -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([]);
+232
View File
@@ -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
View File
@@ -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}.`,
),
);
}
+115 -25
View File
@@ -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 () => {
+5 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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}`));
+53 -3
View File
@@ -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 () => {});
+22 -11
View File
@@ -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();
});
});
+224 -88
View File
@@ -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");
+332 -419
View File
@@ -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);
}
+88 -70
View File
@@ -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: () => ({
+220 -388
View File
@@ -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);
File diff suppressed because it is too large Load Diff
+13 -11
View File
@@ -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(),
});
}
+107 -1
View File
@@ -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-" },
+28 -10
View File
@@ -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;
+498 -18
View File
@@ -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");
+2
View File
@@ -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
View File
@@ -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 {
+3 -1
View File
@@ -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);
+25 -6
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff