Skip to content

Commit 1bd10cf

Browse files
Fix bundled channel dist-runtime setup roots
* Fix bundled channel dist-runtime setup roots Resolve bundled channel generated entries from dist-runtime before falling back to source paths, and select the dist-runtime plugin root as the boundary root for packaged setup modules. This keeps the fs-safe module open boundary check intact while preventing packaged bundled setup entries from being checked against the source extensions root. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Repair session store validation test fixtures Update current-main tests that wrote persisted session entries without valid session IDs after session store loading started filtering invalid entries. Keep the fixture-only repair separate from the bundled channel loader fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Repair pairing and cron validation fixtures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 394f61b commit 1bd10cf

9 files changed

Lines changed: 136 additions & 15 deletions

src/auto-reply/reply/session.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2036,6 +2036,8 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
20362036
const storePath = await createStorePath("openclaw-group-activation-backfill-");
20372037
await writeSessionStoreFast(storePath, {
20382038
[sessionKey]: {
2039+
sessionId: "activation-only",
2040+
updatedAt: 0,
20392041
groupActivation: "always",
20402042
},
20412043
});

src/channels/plugins/bundled.shape-guard.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ afterEach(() => {
187187
vi.doUnmock("../../plugins/manifest-registry.js");
188188
vi.doUnmock("../../plugins/channel-catalog-registry.js");
189189
vi.doUnmock("../../infra/boundary-file-read.js");
190+
vi.doUnmock("./bundled-root.js");
190191
vi.doUnmock("jiti");
191192
});
192193

@@ -575,6 +576,63 @@ describe("bundled channel entry shape guards", () => {
575576
}
576577
});
577578

579+
it("uses dist-runtime as the boundary root for packaged setup entries", async () => {
580+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-runtime-root-"));
581+
const pluginDir = path.join(root, "dist-runtime", "extensions", "alpha");
582+
fs.mkdirSync(pluginDir, { recursive: true });
583+
fs.writeFileSync(
584+
path.join(pluginDir, "setup-entry.js"),
585+
[
586+
"export default {",
587+
" kind: 'bundled-channel-setup-entry',",
588+
" loadSetupPlugin() {",
589+
" return {",
590+
" id: 'alpha',",
591+
" meta: { id: 'alpha', label: 'Setup dist-runtime' },",
592+
" capabilities: {},",
593+
" config: {},",
594+
" };",
595+
" },",
596+
"};",
597+
"",
598+
].join("\n"),
599+
"utf8",
600+
);
601+
602+
vi.doMock("./bundled-root.js", () => ({
603+
resolveBundledChannelRootScope: () => ({
604+
packageRoot: root,
605+
cacheKey: `${root}:dist-runtime`,
606+
}),
607+
}));
608+
vi.doMock("../../plugins/bundled-channel-runtime.js", () => ({
609+
listBundledChannelPluginMetadata: () => [alphaChannelMetadata({ includeSetup: true })],
610+
resolveBundledChannelGeneratedPath: (
611+
rootDir: string,
612+
entry: BundledEntrySource | undefined,
613+
pluginDirName?: string,
614+
) =>
615+
path.join(
616+
rootDir,
617+
"dist-runtime",
618+
"extensions",
619+
pluginDirName ?? "alpha",
620+
(entry?.built ?? entry?.source ?? "./index.js").replace(/^\.\//u, ""),
621+
),
622+
}));
623+
624+
try {
625+
const bundled = await importFreshModule<typeof import("./bundled.js")>(
626+
import.meta.url,
627+
"./bundled.js?scope=bundled-dist-runtime-boundary",
628+
);
629+
630+
expect(bundled.getBundledChannelSetupPlugin("alpha")?.meta.label).toBe("Setup dist-runtime");
631+
} finally {
632+
fs.rmSync(root, { recursive: true, force: true });
633+
}
634+
});
635+
578636
it("loads setup-entry feature plugins without loading the main channel entry", async () => {
579637
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-setup-only-"));
580638
const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;

src/channels/plugins/bundled.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import path from "node:path";
22
import type { OpenClawConfig } from "../../config/types.openclaw.js";
33
import { formatErrorMessage } from "../../infra/errors.js";
4+
import { isPathInside } from "../../infra/path-guards.js";
45
import { createSubsystemLogger } from "../../logging/subsystem.js";
56
import type {
67
BundledChannelLegacySessionSurface,
@@ -160,20 +161,26 @@ function resolveBundledChannelBoundaryRoot(params: {
160161
metadata: BundledChannelPluginMetadata;
161162
modulePath: string;
162163
}): string {
164+
const isModuleUnderRoot = (root: string) => isPathInside(path.resolve(root), params.modulePath);
163165
const overrideRoot = params.pluginsDir
164166
? path.resolve(params.pluginsDir, params.metadata.dirName)
165167
: null;
166-
if (
167-
overrideRoot &&
168-
(params.modulePath === overrideRoot ||
169-
params.modulePath.startsWith(`${overrideRoot}${path.sep}`))
170-
) {
168+
if (overrideRoot && isModuleUnderRoot(overrideRoot)) {
171169
return overrideRoot;
172170
}
173171
const distRoot = path.resolve(params.packageRoot, "dist", "extensions", params.metadata.dirName);
174-
if (params.modulePath === distRoot || params.modulePath.startsWith(`${distRoot}${path.sep}`)) {
172+
if (isModuleUnderRoot(distRoot)) {
175173
return distRoot;
176174
}
175+
const distRuntimeRoot = path.resolve(
176+
params.packageRoot,
177+
"dist-runtime",
178+
"extensions",
179+
params.metadata.dirName,
180+
);
181+
if (isModuleUnderRoot(distRuntimeRoot)) {
182+
return distRuntimeRoot;
183+
}
177184
return path.resolve(params.packageRoot, "extensions", params.metadata.dirName);
178185
}
179186

src/commands/doctor-heartbeat-session-target.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ describe("describeHeartbeatSessionTargetIssues", () => {
6868
const cfg = cfgWithSession("agent:ops:main");
6969
writeStore(cfg, {
7070
"agent:ops:work": {
71-
sessionId: "agent:ops:work",
71+
sessionId: "agent-ops-work",
7272
updatedAt: Date.now(),
7373
},
7474
});

src/cron/service.skips-main-jobs-empty-systemevent-text.test.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
createNoopLogger,
66
withCronServiceForTest,
77
} from "./service.test-harness.js";
8+
import { createCronServiceState } from "./service/state.js";
9+
import { executeJobCore } from "./service/timer.js";
810
import type { CronJob } from "./types.js";
911

1012
const noopLogger = createNoopLogger();
@@ -60,6 +62,39 @@ describe("CronService", () => {
6062
});
6163

6264
it("skips main jobs with empty systemEvent text", async () => {
65+
const enqueueSystemEvent = vi.fn();
66+
const requestHeartbeat = vi.fn();
67+
const state = createCronServiceState({
68+
cronEnabled: true,
69+
storePath: "cron-empty-systemevent-test.json",
70+
log: noopLogger,
71+
nowMs: () => Date.now(),
72+
enqueueSystemEvent,
73+
requestHeartbeat,
74+
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
75+
});
76+
const job: CronJob = {
77+
id: "empty-systemevent-test",
78+
name: "empty systemEvent test",
79+
enabled: true,
80+
schedule: { kind: "at", at: "2025-12-13T00:00:01.000Z" },
81+
sessionTarget: "main",
82+
wakeMode: "now",
83+
payload: { kind: "systemEvent", text: " " },
84+
createdAtMs: Date.now(),
85+
updatedAtMs: Date.now(),
86+
state: {},
87+
};
88+
89+
const result = await executeJobCore(state, job);
90+
91+
expect(result.status).toBe("skipped");
92+
expect(result.error).toMatch(/non-empty/i);
93+
expect(enqueueSystemEvent).not.toHaveBeenCalled();
94+
expect(requestHeartbeat).not.toHaveBeenCalled();
95+
});
96+
97+
it("drops persisted main jobs with empty systemEvent text before they run", async () => {
6398
await withCronService(true, async ({ cron, enqueueSystemEvent, requestHeartbeat }) => {
6499
const atMs = Date.parse("2025-12-13T00:00:01.000Z");
65100
await cron.add({
@@ -77,9 +112,8 @@ describe("CronService", () => {
77112
expect(enqueueSystemEvent).not.toHaveBeenCalled();
78113
expect(requestHeartbeat).not.toHaveBeenCalled();
79114

80-
const job = await waitForFirstJob(cron, (current) => current?.state.lastStatus === "skipped");
81-
expect(job?.state.lastStatus).toBe("skipped");
82-
expect(job?.state.lastError).toMatch(/non-empty/i);
115+
const job = await waitForFirstJob(cron, (current) => current === undefined);
116+
expect(job).toBeUndefined();
83117
});
84118
});
85119

src/cron/service/store.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ describe("cron service store seam coverage", () => {
291291
expect(findJobOrThrow(state, "reload-cron-expr-job").state.nextRunAtMs).toBe(dueNextRunAtMs);
292292
});
293293

294-
it("clears stale nextRunAtMs without throwing when a force-reloaded schedule is malformed", async () => {
294+
it("skips a force-reloaded job when the persisted schedule is malformed", async () => {
295295
const { storePath } = await makeStorePath();
296296
const staleNextRunAtMs = STORE_TEST_NOW + 3_600_000;
297297

@@ -316,9 +316,12 @@ describe("cron service store seam coverage", () => {
316316
undefined,
317317
);
318318

319-
const reloadedJob = findJobOrThrow(state, "reload-cron-expr-job");
320-
expect(reloadedJob.schedule).toBe("0 17 * * *");
321-
expect(reloadedJob.state.nextRunAtMs).toBeUndefined();
319+
expect(state.store?.jobs.find((job) => job.id === "reload-cron-expr-job")).toBeUndefined();
320+
expectWarnedJob({
321+
storePath,
322+
jobId: "reload-cron-expr-job",
323+
message: "skipped invalid persisted job",
324+
});
322325
});
323326

324327
it("preserves nextRunAtMs after force reload when scheduling inputs are unchanged", async () => {

src/gateway/server.sessions.create.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ test("sessions.create replaces a dead main entry with a fresh session id", async
167167
expect(created.payload?.sessionId).toMatch(
168168
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
169169
);
170-
expect(created.payload?.entry?.label).toBe("Ops Main");
170+
expect(created.payload?.entry?.label).toBeUndefined();
171171
expect(created.payload?.entry?.sessionFile).not.toBe("stale.jsonl");
172172

173173
const rawStore = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<

src/plugins/bundled-plugin-metadata.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,22 @@ describe("bundled plugin metadata", () => {
631631
expectGeneratedPathResolution(tempRoot, path.join("dist", "extensions", "plugin", "index.js"));
632632
});
633633

634+
it("uses dist-runtime generated paths before source fallback when packaged dist is absent", () => {
635+
const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-runtime-metadata-");
636+
const pluginRoot = path.join(tempRoot, "extensions", "plugin");
637+
const runtimePluginRoot = path.join(tempRoot, "dist-runtime", "extensions", "plugin");
638+
639+
fs.mkdirSync(pluginRoot, { recursive: true });
640+
fs.mkdirSync(runtimePluginRoot, { recursive: true });
641+
fs.writeFileSync(path.join(pluginRoot, "index.ts"), "export {};\n", "utf8");
642+
fs.writeFileSync(path.join(runtimePluginRoot, "index.js"), "export {};\n", "utf8");
643+
644+
expectGeneratedPathResolution(
645+
tempRoot,
646+
path.join("dist-runtime", "extensions", "plugin", "index.js"),
647+
);
648+
});
649+
634650
it("resolves plugin-local generated entry paths when the plugin dir is provided", () => {
635651
const tempRoot = createGeneratedPluginTempRoot("openclaw-bundled-plugin-metadata-local-");
636652
const pluginRoot = path.join(tempRoot, "extensions", "alpha");

src/plugins/bundled-plugin-metadata.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ function listBundledPluginEntryBaseDirs(params: {
231231
const baseDirs = [
232232
...(params.scanDir ? [path.resolve(params.scanDir, params.pluginDirName ?? "")] : []),
233233
path.resolve(params.rootDir, "dist", "extensions", params.pluginDirName ?? ""),
234+
path.resolve(params.rootDir, "dist-runtime", "extensions", params.pluginDirName ?? ""),
234235
path.resolve(params.rootDir, "extensions", params.pluginDirName ?? ""),
235236
];
236237
return baseDirs.filter((entry, index, all) => all.indexOf(entry) === index);

0 commit comments

Comments
 (0)