Skip to content

Commit 9bf6ba7

Browse files
committed
test(desktop): strengthen plist tests — value assertions, XML escaping, ProgramArguments order
- Entitlements: verify <true/> values (not just key presence), no dangerous entitlements (disable-executable-page-protection, get-task-allow), no duplicate keys, hardenedRuntime enabled in electron-builder - ProgramArguments: exact ordering for controller [node, entry] and openclaw [node, openclaw.mjs, gateway, run], dev --auth none insertion - Openclaw completeness: WorkingDirectory, StandardErrorPath, KeepAlive SuccessfulExit, ThrottleInterval, RunAtLoad=false - XML escaping: &, <, >, ", ' in path fields correctly escaped
1 parent 10e1269 commit 9bf6ba7

File tree

3 files changed

+352
-0
lines changed

3 files changed

+352
-0
lines changed

tests/desktop/entitlements-plist.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,29 @@ function plistHasKey(plistContent: string, key: string): boolean {
3232
return plistContent.includes(`<key>${key}</key>`);
3333
}
3434

35+
/**
36+
* Extract all key-value pairs from a flat plist <dict>.
37+
* Returns a map of key → value string (e.g. "true" for <true/>).
38+
*/
39+
function parsePlistDict(plistContent: string): Map<string, string> {
40+
const result = new Map<string, string>();
41+
const keyRegex = /<key>([^<]+)<\/key>\s*<([^/> ]+)\s*\/?>([^<]*)/g;
42+
let match = keyRegex.exec(plistContent);
43+
while (match !== null) {
44+
const key = match[1];
45+
const tag = match[2];
46+
if (tag === "true") {
47+
result.set(key, "true");
48+
} else if (tag === "false") {
49+
result.set(key, "false");
50+
} else if (tag === "string") {
51+
result.set(key, match[3]);
52+
}
53+
match = keyRegex.exec(plistContent);
54+
}
55+
return result;
56+
}
57+
3558
describe("macOS Entitlements — V8 JIT requirements", () => {
3659
const parentPlist = readPlist("entitlements.mac.plist");
3760
const inheritPlist = readPlist("entitlements.mac.inherit.plist");
@@ -132,4 +155,88 @@ describe("macOS Entitlements — V8 JIT requirements", () => {
132155
),
133156
).toBe(true);
134157
});
158+
159+
// -------------------------------------------------------------------------
160+
// 8. Value-level: all required keys are set to <true/>
161+
// -------------------------------------------------------------------------
162+
it("parent plist sets all required entitlements to true (not just key presence)", () => {
163+
const dict = parsePlistDict(parentPlist);
164+
expect(dict.get("com.apple.security.cs.allow-jit")).toBe("true");
165+
expect(
166+
dict.get("com.apple.security.cs.allow-unsigned-executable-memory"),
167+
).toBe("true");
168+
expect(dict.get("com.apple.security.cs.disable-library-validation")).toBe(
169+
"true",
170+
);
171+
});
172+
173+
it("inherit plist sets all required entitlements to true", () => {
174+
const dict = parsePlistDict(inheritPlist);
175+
expect(dict.get("com.apple.security.inherit")).toBe("true");
176+
expect(dict.get("com.apple.security.cs.allow-jit")).toBe("true");
177+
expect(
178+
dict.get("com.apple.security.cs.allow-unsigned-executable-memory"),
179+
).toBe("true");
180+
});
181+
182+
// -------------------------------------------------------------------------
183+
// 9. No dangerous entitlements that would weaken security unnecessarily
184+
// -------------------------------------------------------------------------
185+
it("neither plist grants com.apple.security.cs.disable-executable-page-protection", () => {
186+
expect(
187+
plistHasKey(
188+
parentPlist,
189+
"com.apple.security.cs.disable-executable-page-protection",
190+
),
191+
).toBe(false);
192+
expect(
193+
plistHasKey(
194+
inheritPlist,
195+
"com.apple.security.cs.disable-executable-page-protection",
196+
),
197+
).toBe(false);
198+
});
199+
200+
it("neither plist grants com.apple.security.get-task-allow in production", () => {
201+
// get-task-allow is for debugging only; must not ship in release builds
202+
expect(plistHasKey(parentPlist, "com.apple.security.get-task-allow")).toBe(
203+
false,
204+
);
205+
expect(plistHasKey(inheritPlist, "com.apple.security.get-task-allow")).toBe(
206+
false,
207+
);
208+
});
209+
210+
// -------------------------------------------------------------------------
211+
// 10. No duplicate keys (malformed plist)
212+
// -------------------------------------------------------------------------
213+
it("parent plist has no duplicate keys", () => {
214+
const keys = [...parentPlist.matchAll(/<key>([^<]+)<\/key>/g)].map(
215+
(m) => m[1],
216+
);
217+
const unique = new Set(keys);
218+
expect(keys.length).toBe(unique.size);
219+
});
220+
221+
it("inherit plist has no duplicate keys", () => {
222+
const keys = [...inheritPlist.matchAll(/<key>([^<]+)<\/key>/g)].map(
223+
(m) => m[1],
224+
);
225+
const unique = new Set(keys);
226+
expect(keys.length).toBe(unique.size);
227+
});
228+
229+
// -------------------------------------------------------------------------
230+
// 11. electron-builder hardenedRuntime must be enabled
231+
// -------------------------------------------------------------------------
232+
it("electron-builder config enables hardenedRuntime", () => {
233+
const packageJson = readFileSync(
234+
resolve(DESKTOP_ROOT, "package.json"),
235+
"utf8",
236+
);
237+
const config = JSON.parse(packageJson) as Record<string, unknown>;
238+
const build = config.build as Record<string, unknown>;
239+
const mac = build.mac as Record<string, unknown>;
240+
expect(mac.hardenedRuntime).toBe(true);
241+
});
135242
});

tests/desktop/launchd-startup-scenarios.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -956,4 +956,79 @@ describe("Launchd Startup Scenarios", () => {
956956
expect(result.isAttach).toBe(false);
957957
expect(result.effectivePorts.controllerPort).toBe(50800);
958958
});
959+
960+
// -----------------------------------------------------------------------
961+
// Scenario 25: Upgrade from pre-version runtime-ports forces clean restart
962+
// -----------------------------------------------------------------------
963+
it("Scenario 25: missing appVersion in recovered ports triggers clean cold start", async () => {
964+
const fsMock = await import("node:fs/promises");
965+
const legacyPorts = JSON.stringify({
966+
writtenAt: new Date().toISOString(),
967+
electronPid: 12345,
968+
controllerPort: 50800,
969+
openclawPort: 18789,
970+
webPort: 50810,
971+
nexuHome: "/tmp/nexu-home",
972+
isDev: true,
973+
userDataPath: "/tmp/user-data",
974+
buildSource: "stable",
975+
});
976+
977+
(fsMock.readFile as ReturnType<typeof vi.fn>)
978+
.mockRejectedValueOnce(new Error("ENOENT"))
979+
.mockRejectedValueOnce(new Error("ENOENT"))
980+
.mockResolvedValueOnce(JSON.stringify(legacyPorts))
981+
.mockResolvedValueOnce(JSON.stringify(legacyPorts));
982+
983+
mockLaunchdManager.getServiceStatus.mockResolvedValue(
984+
mockRunningService({ NEXU_HOME: "/tmp/nexu-home", PORT: "50800" }),
985+
);
986+
987+
const { bootstrapWithLaunchd } = await import(
988+
"../../apps/desktop/main/services/launchd-bootstrap"
989+
);
990+
991+
const result = await bootstrapWithLaunchd(
992+
makeBootstrapEnv({
993+
appVersion: "1.0.0",
994+
userDataPath: "/tmp/user-data",
995+
buildSource: "stable",
996+
}) as never,
997+
);
998+
999+
expect(result.isAttach).toBe(false);
1000+
expect(result.effectivePorts.controllerPort).toBe(50800);
1001+
expect(mockLaunchdManager.installService).toHaveBeenCalledTimes(2);
1002+
});
1003+
1004+
// -----------------------------------------------------------------------
1005+
// Scenario 26: stale force-quit session cleans runtime-ports and services
1006+
// -----------------------------------------------------------------------
1007+
it("Scenario 26: stale dead-electron session older than threshold is cleaned before restart", async () => {
1008+
const fsMock = await import("node:fs/promises");
1009+
const stalePorts = makeRuntimePorts({
1010+
electronPid: 999999,
1011+
writtenAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
1012+
});
1013+
(fsMock.readFile as ReturnType<typeof vi.fn>)
1014+
.mockRejectedValueOnce(new Error("ENOENT"))
1015+
.mockRejectedValueOnce(new Error("ENOENT"))
1016+
.mockResolvedValueOnce(stalePorts);
1017+
1018+
mockLaunchdManager.getServiceStatus.mockResolvedValue(
1019+
mockRunningService({ NEXU_HOME: "/tmp/nexu-home", PORT: "50800" }),
1020+
);
1021+
1022+
const { bootstrapWithLaunchd } = await import(
1023+
"../../apps/desktop/main/services/launchd-bootstrap"
1024+
);
1025+
1026+
const result = await bootstrapWithLaunchd(makeBootstrapEnv() as never);
1027+
1028+
expect(result.isAttach).toBe(false);
1029+
expect(mockLaunchdManager.bootoutService).toHaveBeenCalled();
1030+
expect(fsMock.unlink).toHaveBeenCalledWith(
1031+
expect.stringContaining("runtime-ports.json"),
1032+
);
1033+
});
9591034
});

tests/desktop/plist-generator.test.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,176 @@ describe("generatePlist", () => {
124124
);
125125
});
126126

127+
// -----------------------------------------------------------------------
128+
// ProgramArguments ordering — controller
129+
// -----------------------------------------------------------------------
130+
it("controller ProgramArguments: [nodePath, controllerEntryPath] in exact order", async () => {
131+
const { generatePlist } = await import(
132+
"../../apps/desktop/main/services/plist-generator"
133+
);
134+
const plist = generatePlist("controller", mockEnv);
135+
136+
// Extract ProgramArguments array content
137+
const argsMatch = plist.match(
138+
/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/,
139+
);
140+
expect(argsMatch).not.toBeNull();
141+
const argsBlock = argsMatch?.[1] ?? "";
142+
const strings = [...argsBlock.matchAll(/<string>([^<]*)<\/string>/g)].map(
143+
(m) => m[1],
144+
);
145+
expect(strings).toEqual([
146+
"/usr/local/bin/node",
147+
"/app/controller/dist/index.js",
148+
]);
149+
});
150+
151+
// -----------------------------------------------------------------------
152+
// ProgramArguments ordering — openclaw
153+
// -----------------------------------------------------------------------
154+
it("openclaw ProgramArguments: [nodePath, openclawPath, gateway, run] in exact order", async () => {
155+
const { generatePlist } = await import(
156+
"../../apps/desktop/main/services/plist-generator"
157+
);
158+
const plist = generatePlist("openclaw", mockEnv);
159+
160+
const argsMatch = plist.match(
161+
/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/,
162+
);
163+
expect(argsMatch).not.toBeNull();
164+
const argsBlock = argsMatch?.[1] ?? "";
165+
const strings = [...argsBlock.matchAll(/<string>([^<]*)<\/string>/g)].map(
166+
(m) => m[1],
167+
);
168+
expect(strings).toEqual([
169+
"/usr/local/bin/node",
170+
"/app/openclaw/openclaw.mjs",
171+
"gateway",
172+
"run",
173+
]);
174+
});
175+
176+
it("openclaw dev mode inserts --auth none after gateway run", async () => {
177+
const { generatePlist } = await import(
178+
"../../apps/desktop/main/services/plist-generator"
179+
);
180+
const plist = generatePlist("openclaw", { ...mockEnv, isDev: true });
181+
182+
const argsMatch = plist.match(
183+
/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/,
184+
);
185+
expect(argsMatch).not.toBeNull();
186+
const argsBlock = argsMatch?.[1] ?? "";
187+
const strings = [...argsBlock.matchAll(/<string>([^<]*)<\/string>/g)].map(
188+
(m) => m[1],
189+
);
190+
expect(strings).toEqual([
191+
"/usr/local/bin/node",
192+
"/app/openclaw/openclaw.mjs",
193+
"gateway",
194+
"run",
195+
"--auth",
196+
"none",
197+
]);
198+
});
199+
200+
// -----------------------------------------------------------------------
201+
// Openclaw plist completeness — WorkingDirectory, error log, KeepAlive
202+
// -----------------------------------------------------------------------
203+
it("openclaw plist has correct WorkingDirectory", async () => {
204+
const { generatePlist } = await import(
205+
"../../apps/desktop/main/services/plist-generator"
206+
);
207+
const plist = generatePlist("openclaw", mockEnv);
208+
209+
expect(plist).toContain(
210+
"<key>WorkingDirectory</key>\n <string>/app</string>",
211+
);
212+
});
213+
214+
it("openclaw plist has StandardErrorPath", async () => {
215+
const { generatePlist } = await import(
216+
"../../apps/desktop/main/services/plist-generator"
217+
);
218+
const plist = generatePlist("openclaw", mockEnv);
219+
220+
expect(plist).toContain(
221+
"<string>/Users/testuser/.nexu/logs/openclaw.error.log</string>",
222+
);
223+
});
224+
225+
it("openclaw plist KeepAlive restarts on non-zero exit", async () => {
226+
const { generatePlist } = await import(
227+
"../../apps/desktop/main/services/plist-generator"
228+
);
229+
const plist = generatePlist("openclaw", mockEnv);
230+
231+
// SuccessfulExit=false means launchd restarts when exit code != 0
232+
expect(plist).toContain("<key>SuccessfulExit</key>");
233+
expect(plist).toMatch(/<key>SuccessfulExit<\/key>\s*<false\/>/);
234+
});
235+
236+
it("openclaw plist has ThrottleInterval to prevent rapid respawn", async () => {
237+
const { generatePlist } = await import(
238+
"../../apps/desktop/main/services/plist-generator"
239+
);
240+
const plist = generatePlist("openclaw", mockEnv);
241+
242+
expect(plist).toContain("<key>ThrottleInterval</key>");
243+
expect(plist).toMatch(
244+
/<key>ThrottleInterval<\/key>\s*<integer>\d+<\/integer>/,
245+
);
246+
});
247+
248+
it("controller plist has correct WorkingDirectory", async () => {
249+
const { generatePlist } = await import(
250+
"../../apps/desktop/main/services/plist-generator"
251+
);
252+
const plist = generatePlist("controller", mockEnv);
253+
254+
expect(plist).toContain(
255+
"<key>WorkingDirectory</key>\n <string>/app/controller</string>",
256+
);
257+
});
258+
259+
it("both plists have RunAtLoad=false (explicit start only)", async () => {
260+
const { generatePlist } = await import(
261+
"../../apps/desktop/main/services/plist-generator"
262+
);
263+
const controller = generatePlist("controller", mockEnv);
264+
const openclaw = generatePlist("openclaw", mockEnv);
265+
266+
expect(controller).toMatch(/<key>RunAtLoad<\/key>\s*<false\/>/);
267+
expect(openclaw).toMatch(/<key>RunAtLoad<\/key>\s*<false\/>/);
268+
});
269+
270+
// -----------------------------------------------------------------------
271+
// XML escaping robustness
272+
// -----------------------------------------------------------------------
273+
it("escapes ampersand, angle brackets, quotes, and apostrophes in all path fields", async () => {
274+
const { generatePlist } = await import(
275+
"../../apps/desktop/main/services/plist-generator"
276+
);
277+
278+
const nastEnv = {
279+
...mockEnv,
280+
nodePath: "/usr/bin/node's",
281+
controllerCwd: '/app/"controller"',
282+
openclawPath: "/path/with&special<chars>.mjs",
283+
openclawConfigPath: "/Users/test'user/.nexu/config",
284+
};
285+
286+
const controller = generatePlist("controller", nastEnv);
287+
const openclaw = generatePlist("openclaw", nastEnv);
288+
289+
// Verify escaped forms present, raw forms absent
290+
expect(controller).toContain("node&apos;s");
291+
expect(controller).not.toContain("node's</string>");
292+
expect(controller).toContain("&quot;controller&quot;");
293+
expect(openclaw).toContain("&amp;special&lt;chars&gt;");
294+
expect(openclaw).toContain("test&apos;user");
295+
});
296+
127297
it("sets ELECTRON_RUN_AS_NODE=1 for both services", async () => {
128298
const { generatePlist } = await import(
129299
"../../apps/desktop/main/services/plist-generator"

0 commit comments

Comments
 (0)