

























@@ -0,0 +1,221 @@
1+import fs from "node:fs";
2+import os from "node:os";
3+import path from "node:path";
4+import { afterEach, describe, expect, it } from "vitest";
5+import {
6+consumeGatewayRestartHandoffForExitedProcessSync,
7+GATEWAY_SUPERVISOR_RESTART_HANDOFF_FILENAME,
8+GATEWAY_SUPERVISOR_RESTART_HANDOFF_KIND,
9+readGatewayRestartHandoffSync,
10+writeGatewayRestartHandoffSync,
11+} from "./restart-handoff.js";
12+13+const tempDirs: string[] = [];
14+15+function createHandoffEnv(): NodeJS.ProcessEnv {
16+const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-restart-handoff-"));
17+tempDirs.push(dir);
18+return {
19+ ...process.env,
20+OPENCLAW_STATE_DIR: dir,
21+};
22+}
23+24+function handoffPath(env: NodeJS.ProcessEnv): string {
25+return path.join(env.OPENCLAW_STATE_DIR ?? "", GATEWAY_SUPERVISOR_RESTART_HANDOFF_FILENAME);
26+}
27+28+describe("gateway restart handoff", () => {
29+afterEach(() => {
30+for (const dir of tempDirs.splice(0)) {
31+fs.rmSync(dir, { force: true, recursive: true });
32+}
33+});
34+35+it("writes a supervisor handoff for an exited gateway process", () => {
36+const env = createHandoffEnv();
37+38+const handoff = writeGatewayRestartHandoffSync({
39+ env,
40+pid: 12_345,
41+processInstanceId: "gateway-instance-1",
42+reason: "plugin source changed",
43+restartKind: "full-process",
44+supervisorMode: "launchd",
45+createdAt: 1_000,
46+});
47+48+expect(handoff).toMatchObject({
49+kind: GATEWAY_SUPERVISOR_RESTART_HANDOFF_KIND,
50+version: 1,
51+pid: 12_345,
52+processInstanceId: "gateway-instance-1",
53+reason: "plugin source changed",
54+source: "plugin-change",
55+restartKind: "full-process",
56+supervisorMode: "launchd",
57+createdAt: 1_000,
58+expiresAt: 61_000,
59+});
60+expect(fs.statSync(handoffPath(env)).mode & 0o777).toBe(0o600);
61+expect(readGatewayRestartHandoffSync(env, 1_500)).toMatchObject({
62+pid: 12_345,
63+reason: "plugin source changed",
64+});
65+});
66+67+it("consumes a fresh handoff by exited pid instead of current process pid", () => {
68+const env = createHandoffEnv();
69+70+expect(
71+writeGatewayRestartHandoffSync({
72+ env,
73+pid: process.pid + 1,
74+reason: "update.run",
75+restartKind: "update-process",
76+supervisorMode: "systemd",
77+createdAt: 2_000,
78+}),
79+).not.toBeNull();
80+81+expect(
82+consumeGatewayRestartHandoffForExitedProcessSync({
83+ env,
84+exitedPid: process.pid + 1,
85+now: 2_001,
86+}),
87+).toMatchObject({
88+pid: process.pid + 1,
89+source: "gateway-update",
90+restartKind: "update-process",
91+supervisorMode: "systemd",
92+});
93+expect(fs.existsSync(handoffPath(env))).toBe(false);
94+});
95+96+it("rejects handoffs for a different exited pid and clears them", () => {
97+const env = createHandoffEnv();
98+99+expect(
100+writeGatewayRestartHandoffSync({
101+ env,
102+pid: 111,
103+restartKind: "full-process",
104+supervisorMode: "external",
105+createdAt: 1_000,
106+}),
107+).not.toBeNull();
108+109+expect(
110+consumeGatewayRestartHandoffForExitedProcessSync({
111+ env,
112+exitedPid: 222,
113+now: 1_001,
114+}),
115+).toBeNull();
116+expect(fs.existsSync(handoffPath(env))).toBe(false);
117+});
118+119+it("rejects a handoff when the supplied process instance does not match", () => {
120+const env = createHandoffEnv();
121+122+expect(
123+writeGatewayRestartHandoffSync({
124+ env,
125+pid: 111,
126+processInstanceId: "gateway-instance-1",
127+restartKind: "full-process",
128+supervisorMode: "external",
129+createdAt: 1_000,
130+}),
131+).not.toBeNull();
132+133+expect(
134+consumeGatewayRestartHandoffForExitedProcessSync({
135+ env,
136+exitedPid: 111,
137+processInstanceId: "gateway-instance-2",
138+now: 1_001,
139+}),
140+).toBeNull();
141+expect(fs.existsSync(handoffPath(env))).toBe(false);
142+});
143+144+it("rejects malformed handoff payloads", () => {
145+const env = createHandoffEnv();
146+147+fs.writeFileSync(
148+handoffPath(env),
149+`${JSON.stringify({
150+ kind: GATEWAY_SUPERVISOR_RESTART_HANDOFF_KIND,
151+ version: 1,
152+ intentId: "bad",
153+ pid: 111,
154+ createdAt: 1_000,
155+ expiresAt: 61_000,
156+ reason: 123,
157+ source: "bad-source",
158+ restartKind: "full-process",
159+ supervisorMode: "external",
160+ })}\n`,
161+{ encoding: "utf8", mode: 0o600 },
162+);
163+164+expect(readGatewayRestartHandoffSync(env, 1_001)).toBeNull();
165+});
166+167+it("rejects expired and oversized handoff files", () => {
168+const env = createHandoffEnv();
169+170+expect(
171+writeGatewayRestartHandoffSync({
172+ env,
173+pid: 111,
174+restartKind: "full-process",
175+supervisorMode: "external",
176+createdAt: 1_000,
177+ttlMs: 1_000,
178+}),
179+).not.toBeNull();
180+expect(readGatewayRestartHandoffSync(env, 2_001)).toBeNull();
181+182+fs.writeFileSync(handoffPath(env), "x".repeat(8192), { encoding: "utf8", mode: 0o600 });
183+expect(
184+consumeGatewayRestartHandoffForExitedProcessSync({
185+ env,
186+exitedPid: 111,
187+now: 2_001,
188+}),
189+).toBeNull();
190+expect(fs.existsSync(handoffPath(env))).toBe(false);
191+});
192+193+it("does not follow an existing handoff-path symlink when writing", () => {
194+const env = createHandoffEnv();
195+const targetPath = path.join(env.OPENCLAW_STATE_DIR ?? "", "attacker-target.txt");
196+fs.writeFileSync(targetPath, "keep", "utf8");
197+try {
198+fs.symlinkSync(targetPath, handoffPath(env));
199+} catch {
200+return;
201+}
202+203+expect(
204+writeGatewayRestartHandoffSync({
205+ env,
206+pid: 12_345,
207+restartKind: "full-process",
208+supervisorMode: "external",
209+}),
210+).not.toBeNull();
211+212+expect(fs.readFileSync(targetPath, "utf8")).toBe("keep");
213+expect(fs.lstatSync(handoffPath(env)).isSymbolicLink()).toBe(false);
214+expect(
215+consumeGatewayRestartHandoffForExitedProcessSync({
216+ env,
217+exitedPid: 12_345,
218+}),
219+).toMatchObject({ pid: 12_345 });
220+});
221+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。