refactor: dedupe core config and runtime helpers

This commit is contained in:
Peter Steinberger
2026-02-22 17:11:34 +00:00
parent 24ea941e28
commit 34ea33f057
29 changed files with 720 additions and 874 deletions

View File

@@ -25,6 +25,24 @@ async function setupPairedOperatorDevice(baseDir: string, scopes: string[]) {
await approveDevicePairing(request.request.requestId, baseDir);
}
async function setupOperatorToken(scopes: string[]) {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
await setupPairedOperatorDevice(baseDir, scopes);
const paired = await getPairedDevice("device-1", baseDir);
const token = requireToken(paired?.tokens?.operator?.token);
return { baseDir, token };
}
function verifyOperatorToken(params: { baseDir: string; token: string; scopes: string[] }) {
return verifyDeviceToken({
deviceId: "device-1",
token: params.token,
role: "operator",
scopes: params.scopes,
baseDir: params.baseDir,
});
}
function requireToken(token: string | undefined): string {
expect(typeof token).toBe("string");
if (typeof token !== "string") {
@@ -163,71 +181,52 @@ describe("device pairing tokens", () => {
});
test("verifies token and rejects mismatches", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
await setupPairedOperatorDevice(baseDir, ["operator.read"]);
const paired = await getPairedDevice("device-1", baseDir);
const token = requireToken(paired?.tokens?.operator?.token);
const { baseDir, token } = await setupOperatorToken(["operator.read"]);
const ok = await verifyDeviceToken({
deviceId: "device-1",
token,
role: "operator",
scopes: ["operator.read"],
const ok = await verifyOperatorToken({
baseDir,
token,
scopes: ["operator.read"],
});
expect(ok.ok).toBe(true);
const mismatch = await verifyDeviceToken({
deviceId: "device-1",
token: "x".repeat(token.length),
role: "operator",
scopes: ["operator.read"],
const mismatch = await verifyOperatorToken({
baseDir,
token: "x".repeat(token.length),
scopes: ["operator.read"],
});
expect(mismatch.ok).toBe(false);
expect(mismatch.reason).toBe("token-mismatch");
});
test("accepts operator.read/operator.write requests with an operator.admin token scope", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
await setupPairedOperatorDevice(baseDir, ["operator.admin"]);
const paired = await getPairedDevice("device-1", baseDir);
const token = requireToken(paired?.tokens?.operator?.token);
const { baseDir, token } = await setupOperatorToken(["operator.admin"]);
const readOk = await verifyDeviceToken({
deviceId: "device-1",
token,
role: "operator",
scopes: ["operator.read"],
const readOk = await verifyOperatorToken({
baseDir,
token,
scopes: ["operator.read"],
});
expect(readOk.ok).toBe(true);
const writeOk = await verifyDeviceToken({
deviceId: "device-1",
token,
role: "operator",
scopes: ["operator.write"],
const writeOk = await verifyOperatorToken({
baseDir,
token,
scopes: ["operator.write"],
});
expect(writeOk.ok).toBe(true);
});
test("treats multibyte same-length token input as mismatch without throwing", async () => {
const baseDir = await mkdtemp(join(tmpdir(), "openclaw-device-pairing-"));
await setupPairedOperatorDevice(baseDir, ["operator.read"]);
const paired = await getPairedDevice("device-1", baseDir);
const token = requireToken(paired?.tokens?.operator?.token);
const { baseDir, token } = await setupOperatorToken(["operator.read"]);
const multibyteToken = "é".repeat(token.length);
expect(Buffer.from(multibyteToken).length).not.toBe(Buffer.from(token).length);
await expect(
verifyDeviceToken({
deviceId: "device-1",
token: multibyteToken,
role: "operator",
scopes: ["operator.read"],
verifyOperatorToken({
baseDir,
token: multibyteToken,
scopes: ["operator.read"],
}),
).resolves.toEqual({ ok: false, reason: "token-mismatch" });
});

View File

@@ -91,6 +91,44 @@ function mockProcStatRead(params: { onProcRead: () => string }) {
});
}
async function writeLockFile(
env: NodeJS.ProcessEnv,
params: { startTime: number; createdAt?: string } = { startTime: 111 },
) {
const { lockPath, configPath } = resolveLockPath(env);
const payload = createLockPayload({
configPath,
startTime: params.startTime,
createdAt: params.createdAt,
});
await fs.writeFile(lockPath, JSON.stringify(payload), "utf8");
return { lockPath, configPath };
}
function createEaccesProcStatSpy() {
return mockProcStatRead({
onProcRead: () => {
throw new Error("EACCES");
},
});
}
async function acquireStaleLinuxLock(env: NodeJS.ProcessEnv) {
await writeLockFile(env, {
startTime: 111,
createdAt: new Date(0).toISOString(),
});
const staleProcSpy = createEaccesProcStatSpy();
const lock = await acquireForTest(env, {
staleMs: 1,
platform: "linux",
});
expect(lock).not.toBeNull();
await lock?.release();
staleProcSpy.mockRestore();
}
describe("gateway lock", () => {
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-lock-"));
@@ -154,15 +192,8 @@ describe("gateway lock", () => {
it("keeps lock on linux when proc access fails unless stale", async () => {
vi.useRealTimers();
const env = await makeEnv();
const { lockPath, configPath } = resolveLockPath(env);
const payload = createLockPayload({ configPath, startTime: 111 });
await fs.writeFile(lockPath, JSON.stringify(payload), "utf8");
const spy = mockProcStatRead({
onProcRead: () => {
throw new Error("EACCES");
},
});
await writeLockFile(env);
const spy = createEaccesProcStatSpy();
const pending = acquireForTest(env, {
timeoutMs: 15,
@@ -172,42 +203,14 @@ describe("gateway lock", () => {
await expect(pending).rejects.toBeInstanceOf(GatewayLockError);
spy.mockRestore();
const stalePayload = createLockPayload({
configPath,
startTime: 111,
createdAt: new Date(0).toISOString(),
});
await fs.writeFile(lockPath, JSON.stringify(stalePayload), "utf8");
const staleSpy = mockProcStatRead({
onProcRead: () => {
throw new Error("EACCES");
},
});
const lock = await acquireForTest(env, {
staleMs: 1,
platform: "linux",
});
expect(lock).not.toBeNull();
await lock?.release();
staleSpy.mockRestore();
await acquireStaleLinuxLock(env);
});
it("keeps lock when fs.stat fails until payload is stale", async () => {
vi.useRealTimers();
const env = await makeEnv();
const { lockPath, configPath } = resolveLockPath(env);
const payload = createLockPayload({ configPath, startTime: 111 });
await fs.writeFile(lockPath, JSON.stringify(payload), "utf8");
const procSpy = mockProcStatRead({
onProcRead: () => {
throw new Error("EACCES");
},
});
await writeLockFile(env);
const procSpy = createEaccesProcStatSpy();
const statSpy = vi
.spyOn(fs, "stat")
.mockRejectedValue(Object.assign(new Error("EPERM"), { code: "EPERM" }));
@@ -220,28 +223,7 @@ describe("gateway lock", () => {
await expect(pending).rejects.toBeInstanceOf(GatewayLockError);
procSpy.mockRestore();
const stalePayload = createLockPayload({
configPath,
startTime: 111,
createdAt: new Date(0).toISOString(),
});
await fs.writeFile(lockPath, JSON.stringify(stalePayload), "utf8");
const staleProcSpy = mockProcStatRead({
onProcRead: () => {
throw new Error("EACCES");
},
});
const lock = await acquireForTest(env, {
staleMs: 1,
platform: "linux",
});
expect(lock).not.toBeNull();
await lock?.release();
staleProcSpy.mockRestore();
await acquireStaleLinuxLock(env);
statSpy.mockRestore();
});

View File

@@ -87,16 +87,35 @@ describe("Ghost reminder bug (issue #13317)", () => {
result: Awaited<ReturnType<typeof runHeartbeatOnce>>;
sendTelegram: ReturnType<typeof vi.fn>;
calledCtx: { Provider?: string; Body?: string } | null;
}> => {
return runHeartbeatCase({
tmpPrefix,
replyText: "Relay this reminder now",
reason: "cron:reminder-job",
enqueue,
});
};
const runHeartbeatCase = async (params: {
tmpPrefix: string;
replyText: string;
reason: string;
enqueue: (sessionKey: string) => void;
}): Promise<{
result: Awaited<ReturnType<typeof runHeartbeatOnce>>;
sendTelegram: ReturnType<typeof vi.fn>;
calledCtx: { Provider?: string; Body?: string } | null;
replyCallCount: number;
}> => {
return withTempHeartbeatSandbox(
async ({ tmpDir, storePath }) => {
const { sendTelegram, getReplySpy } = createHeartbeatDeps("Relay this reminder now");
const { sendTelegram, getReplySpy } = createHeartbeatDeps(params.replyText);
const { cfg, sessionKey } = await createConfig({ tmpDir, storePath });
enqueue(sessionKey);
params.enqueue(sessionKey);
const result = await runHeartbeatOnce({
cfg,
agentId: "main",
reason: "cron:reminder-job",
reason: params.reason,
deps: {
sendTelegram,
},
@@ -105,38 +124,32 @@ describe("Ghost reminder bug (issue #13317)", () => {
Provider?: string;
Body?: string;
} | null;
return { result, sendTelegram, calledCtx };
return {
result,
sendTelegram,
calledCtx,
replyCallCount: getReplySpy.mock.calls.length,
};
},
{ prefix: tmpPrefix },
{ prefix: params.tmpPrefix },
);
};
it("does not use CRON_EVENT_PROMPT when only a HEARTBEAT_OK event is present", async () => {
await withTempHeartbeatSandbox(
async ({ tmpDir, storePath }) => {
const { sendTelegram, getReplySpy } = createHeartbeatDeps("Heartbeat check-in");
const { cfg, sessionKey } = await createConfig({ tmpDir, storePath });
const { result, sendTelegram, calledCtx, replyCallCount } = await runHeartbeatCase({
tmpPrefix: "openclaw-ghost-",
replyText: "Heartbeat check-in",
reason: "cron:test-job",
enqueue: (sessionKey) => {
enqueueSystemEvent("HEARTBEAT_OK", { sessionKey });
const result = await runHeartbeatOnce({
cfg,
agentId: "main",
reason: "cron:test-job",
deps: {
sendTelegram,
},
});
expect(result.status).toBe("ran");
expect(getReplySpy).toHaveBeenCalledTimes(1);
const calledCtx = getReplySpy.mock.calls[0]?.[0];
expect(calledCtx?.Provider).toBe("heartbeat");
expect(calledCtx?.Body).not.toContain("scheduled reminder has been triggered");
expect(calledCtx?.Body).not.toContain("relay this reminder");
expect(sendTelegram).toHaveBeenCalled();
},
{ prefix: "openclaw-ghost-" },
);
});
expect(result.status).toBe("ran");
expect(replyCallCount).toBe(1);
expect(calledCtx?.Provider).toBe("heartbeat");
expect(calledCtx?.Body).not.toContain("scheduled reminder has been triggered");
expect(calledCtx?.Body).not.toContain("relay this reminder");
expect(sendTelegram).toHaveBeenCalled();
});
it("uses CRON_EVENT_PROMPT when an actionable cron event exists", async () => {
@@ -165,34 +178,23 @@ describe("Ghost reminder bug (issue #13317)", () => {
});
it("uses CRON_EVENT_PROMPT for tagged cron events on interval wake", async () => {
await withTempHeartbeatSandbox(
async ({ tmpDir, storePath }) => {
const { sendTelegram, getReplySpy } = createHeartbeatDeps("Relay this cron update now");
const { cfg, sessionKey } = await createConfig({ tmpDir, storePath });
const { result, sendTelegram, calledCtx, replyCallCount } = await runHeartbeatCase({
tmpPrefix: "openclaw-cron-interval-",
replyText: "Relay this cron update now",
reason: "interval",
enqueue: (sessionKey) => {
enqueueSystemEvent("Cron: QMD maintenance completed", {
sessionKey,
contextKey: "cron:qmd-maintenance",
});
const result = await runHeartbeatOnce({
cfg,
agentId: "main",
reason: "interval",
deps: {
sendTelegram,
},
});
expect(result.status).toBe("ran");
expect(getReplySpy).toHaveBeenCalledTimes(1);
const calledCtx = getReplySpy.mock.calls[0]?.[0];
expect(calledCtx?.Provider).toBe("cron-event");
expect(calledCtx?.Body).toContain("scheduled reminder has been triggered");
expect(calledCtx?.Body).toContain("Cron: QMD maintenance completed");
expect(calledCtx?.Body).not.toContain("Read HEARTBEAT.md");
expect(sendTelegram).toHaveBeenCalled();
},
{ prefix: "openclaw-cron-interval-" },
);
});
expect(result.status).toBe("ran");
expect(replyCallCount).toBe(1);
expect(calledCtx?.Provider).toBe("cron-event");
expect(calledCtx?.Body).toContain("scheduled reminder has been triggered");
expect(calledCtx?.Body).toContain("Cron: QMD maintenance completed");
expect(calledCtx?.Body).not.toContain("Read HEARTBEAT.md");
expect(sendTelegram).toHaveBeenCalled();
});
});

View File

@@ -79,6 +79,27 @@ async function deliverWhatsAppPayload(params: {
});
}
async function runChunkedWhatsAppDelivery(params?: {
mirror?: Parameters<typeof deliverOutboundPayloads>[0]["mirror"];
}) {
const sendWhatsApp = vi
.fn()
.mockResolvedValueOnce({ messageId: "w1", toJid: "jid" })
.mockResolvedValueOnce({ messageId: "w2", toJid: "jid" });
const cfg: OpenClawConfig = {
channels: { whatsapp: { textChunkLimit: 2 } },
};
const results = await deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "abcd" }],
deps: { sendWhatsApp },
...(params?.mirror ? { mirror: params.mirror } : {}),
});
return { sendWhatsApp, results };
}
describe("deliverOutboundPayloads", () => {
beforeEach(() => {
setActivePluginRegistry(defaultRegistry);
@@ -238,21 +259,7 @@ describe("deliverOutboundPayloads", () => {
});
it("chunks WhatsApp text and returns all results", async () => {
const sendWhatsApp = vi
.fn()
.mockResolvedValueOnce({ messageId: "w1", toJid: "jid" })
.mockResolvedValueOnce({ messageId: "w2", toJid: "jid" });
const cfg: OpenClawConfig = {
channels: { whatsapp: { textChunkLimit: 2 } },
};
const results = await deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "abcd" }],
deps: { sendWhatsApp },
});
const { sendWhatsApp, results } = await runChunkedWhatsAppDelivery();
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
expect(results.map((r) => r.messageId)).toEqual(["w1", "w2"]);
@@ -447,24 +454,12 @@ describe("deliverOutboundPayloads", () => {
});
it("emits internal message:sent hook with success=true for chunked payload delivery", async () => {
const sendWhatsApp = vi
.fn()
.mockResolvedValueOnce({ messageId: "w1", toJid: "jid" })
.mockResolvedValueOnce({ messageId: "w2", toJid: "jid" });
const cfg: OpenClawConfig = {
channels: { whatsapp: { textChunkLimit: 2 } },
};
await deliverOutboundPayloads({
cfg,
channel: "whatsapp",
to: "+1555",
payloads: [{ text: "abcd" }],
deps: { sendWhatsApp },
const { sendWhatsApp } = await runChunkedWhatsAppDelivery({
mirror: {
sessionKey: "agent:main:main",
},
});
expect(sendWhatsApp).toHaveBeenCalledTimes(2);
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledTimes(1);
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledWith(

View File

@@ -1,4 +1,5 @@
import path from "node:path";
import { isPathInside } from "./path-guards.js";
export function resolveSafeBaseDir(rootDir: string): string {
const resolved = path.resolve(rootDir);
@@ -6,15 +7,5 @@ export function resolveSafeBaseDir(rootDir: string): string {
}
export function isWithinDir(rootDir: string, targetPath: string): boolean {
const resolvedRoot = path.resolve(rootDir);
const resolvedTarget = path.resolve(targetPath);
// Windows paths are effectively case-insensitive; normalize to avoid false negatives.
if (process.platform === "win32") {
const relative = path.win32.relative(resolvedRoot.toLowerCase(), resolvedTarget.toLowerCase());
return relative === "" || (!relative.startsWith("..") && !path.win32.isAbsolute(relative));
}
const relative = path.relative(resolvedRoot, resolvedTarget);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
return isPathInside(rootDir, targetPath);
}

View File

@@ -123,32 +123,34 @@ describe("runGatewayUpdate", () => {
return uiIndexPath;
}
function buildStableTagResponses(stableTag: string): Record<string, CommandResponse> {
function buildStableTagResponses(
stableTag: string,
options?: { additionalTags?: string[] },
): Record<string, CommandResponse> {
const tagOutput = [stableTag, ...(options?.additionalTags ?? [])].join("\n");
return {
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
[`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: "" },
[`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" },
[`git -C ${tempDir} tag --list v* --sort=-v:refname`]: { stdout: `${stableTag}\n` },
[`git -C ${tempDir} tag --list v* --sort=-v:refname`]: { stdout: `${tagOutput}\n` },
[`git -C ${tempDir} checkout --detach ${stableTag}`]: { stdout: "" },
};
}
async function removeControlUiAssets() {
await fs.rm(path.join(tempDir, "dist", "control-ui"), { recursive: true, force: true });
function buildGitWorktreeProbeResponses(options?: { status?: string; branch?: string }) {
return {
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
[`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: options?.branch ?? "main" },
[`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: {
stdout: options?.status ?? "",
},
} satisfies Record<string, CommandResponse>;
}
async function runWithRunner(
runner: (argv: string[]) => Promise<CommandResult>,
options?: { channel?: "stable" | "beta"; tag?: string; cwd?: string },
) {
return runGatewayUpdate({
cwd: options?.cwd ?? tempDir,
runCommand: async (argv, _runOptions) => runner(argv),
timeoutMs: 5000,
...(options?.channel ? { channel: options.channel } : {}),
...(options?.tag ? { tag: options.tag } : {}),
});
async function removeControlUiAssets() {
await fs.rm(path.join(tempDir, "dist", "control-ui"), { recursive: true, force: true });
}
async function runWithCommand(
@@ -164,6 +166,13 @@ describe("runGatewayUpdate", () => {
});
}
async function runWithRunner(
runner: (argv: string[]) => Promise<CommandResult>,
options?: { channel?: "stable" | "beta"; tag?: string; cwd?: string },
) {
return runWithCommand(runner, options);
}
async function seedGlobalPackageRoot(pkgRoot: string, version = "1.0.0") {
await fs.mkdir(pkgRoot, { recursive: true });
await fs.writeFile(
@@ -176,10 +185,7 @@ describe("runGatewayUpdate", () => {
it("skips git update when worktree is dirty", async () => {
await setupGitCheckout();
const { runner, calls } = createRunner({
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
[`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" },
[`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: " M README.md" },
...buildGitWorktreeProbeResponses({ status: " M README.md" }),
});
const result = await runWithRunner(runner);
@@ -192,10 +198,7 @@ describe("runGatewayUpdate", () => {
it("aborts rebase on failure", async () => {
await setupGitCheckout();
const { runner, calls } = createRunner({
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
[`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" },
[`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: "" },
...buildGitWorktreeProbeResponses(),
[`git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`]: {
stdout: "origin/main",
},
@@ -252,14 +255,7 @@ describe("runGatewayUpdate", () => {
const stableTag = "v1.0.1-1";
const betaTag = "v1.0.0-beta.2";
const { runner, calls } = createRunner({
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
[`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: "" },
[`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" },
[`git -C ${tempDir} tag --list v* --sort=-v:refname`]: {
stdout: `${stableTag}\n${betaTag}\n`,
},
[`git -C ${tempDir} checkout --detach ${stableTag}`]: { stdout: "" },
...buildStableTagResponses(stableTag, { additionalTags: [betaTag] }),
"pnpm install": { stdout: "" },
"pnpm build": { stdout: "" },
"pnpm ui:build": { stdout: "" },
@@ -472,12 +468,7 @@ describe("runGatewayUpdate", () => {
const stableTag = "v1.0.1-1";
const { runner } = createRunner({
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
[`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: "" },
[`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" },
[`git -C ${tempDir} tag --list v* --sort=-v:refname`]: { stdout: `${stableTag}\n` },
[`git -C ${tempDir} checkout --detach ${stableTag}`]: { stdout: "" },
...buildStableTagResponses(stableTag),
"pnpm install": { stdout: "" },
"pnpm build": { stdout: "" },
"pnpm ui:build": { stdout: "" },