Skip to content

Commit 6931c23

Browse files
committed
fix(daemon): align launchd recovery plist paths
1 parent 65e26b6 commit 6931c23

4 files changed

Lines changed: 144 additions & 2 deletions

File tree

apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Foundation
33
enum GatewayLaunchAgentManager {
44
private static let logger = Logger(subsystem: "ai.openclaw", category: "gateway.launchd")
55
private static let disableLaunchAgentMarker = ".openclaw/disable-launchagent"
6+
private nonisolated(unsafe) static var externalHomeVolumeMemo: (path: String, external: Bool)?
67

78
private static var disableLaunchAgentMarkerURL: URL {
89
#if DEBUG
@@ -15,10 +16,76 @@ enum GatewayLaunchAgentManager {
1516
}
1617

1718
private static var plistURL: URL {
18-
FileManager().homeDirectoryForCurrentUser
19+
self.launchAgentPlistURL()
20+
}
21+
22+
static func launchAgentPlistURL(
23+
homeURL: URL = FileManager().homeDirectoryForCurrentUser,
24+
environment: [String: String] = ProcessInfo.processInfo.environment,
25+
homeIsExternalVolume: Bool? = nil) -> URL
26+
{
27+
self.launchAgentHomeURL(
28+
homeURL: homeURL,
29+
environment: environment,
30+
homeIsExternalVolume: homeIsExternalVolume)
1931
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
2032
}
2133

34+
private static func launchAgentHomeURL(
35+
homeURL: URL,
36+
environment: [String: String],
37+
homeIsExternalVolume: Bool?) -> URL
38+
{
39+
let external = homeIsExternalVolume ?? self.isExternalVolumeHome(homeURL)
40+
guard external, let user = self.loginUsername(environment: environment) else {
41+
return homeURL
42+
}
43+
return URL(fileURLWithPath: "/Users", isDirectory: true)
44+
.appendingPathComponent(user, isDirectory: true)
45+
}
46+
47+
private static func loginUsername(environment: [String: String]) -> String? {
48+
if let user = environment["USER"]?.trimmingCharacters(in: .whitespacesAndNewlines),
49+
!user.isEmpty
50+
{
51+
return user
52+
}
53+
if let logname = environment["LOGNAME"]?.trimmingCharacters(in: .whitespacesAndNewlines),
54+
!logname.isEmpty
55+
{
56+
return logname
57+
}
58+
let fallback = NSUserName().trimmingCharacters(in: .whitespacesAndNewlines)
59+
return fallback.isEmpty ? nil : fallback
60+
}
61+
62+
private static func isExternalVolumeHome(_ homeURL: URL) -> Bool {
63+
let path = homeURL.path
64+
if let memo = self.externalHomeVolumeMemo, memo.path == path {
65+
return memo.external
66+
}
67+
68+
let external = self.fileSystemNumber(forPath: "/")
69+
.flatMap { rootDevice in
70+
self.fileSystemNumber(forPath: path).map { homeDevice in
71+
rootDevice != homeDevice
72+
}
73+
} ?? false
74+
self.externalHomeVolumeMemo = (path, external)
75+
return external
76+
}
77+
78+
private static func fileSystemNumber(forPath path: String) -> NSNumber? {
79+
guard
80+
let value = try? FileManager.default.attributesOfFileSystem(forPath: path)[
81+
.systemNumber,
82+
] as? NSNumber
83+
else {
84+
return nil
85+
}
86+
return value
87+
}
88+
2289
static func isLaunchAgentWriteDisabled() -> Bool {
2390
if FileManager().fileExists(atPath: self.disableLaunchAgentMarkerURL.path) { return true }
2491
return false

apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,25 @@ import Testing
33
@testable import OpenClaw
44

55
struct GatewayLaunchAgentManagerTests {
6+
@Test func `launch agent plist URL keeps normal home`() throws {
7+
let url = GatewayLaunchAgentManager.launchAgentPlistURL(
8+
homeURL: URL(fileURLWithPath: "/Users/testuser", isDirectory: true),
9+
environment: ["USER": "ignored"],
10+
homeIsExternalVolume: false)
11+
12+
#expect(url.path == "/Users/testuser/Library/LaunchAgents/ai.openclaw.gateway.plist")
13+
}
14+
15+
@Test func `launch agent plist URL uses boot volume user home when current home is external`() throws {
16+
let url = GatewayLaunchAgentManager.launchAgentPlistURL(
17+
homeURL: URL(fileURLWithPath: "/Volumes/MainDataDrive", isDirectory: true),
18+
environment: ["USER": "TestUser"],
19+
homeIsExternalVolume: true)
20+
21+
#expect(url.path == "/Users/TestUser/Library/LaunchAgents/ai.openclaw.gateway.plist")
22+
#expect(!url.path.contains("/Volumes/MainDataDrive"))
23+
}
24+
625
@Test func `attach only runtime override does not uninstall gateway launch agent`() throws {
726
let dir = FileManager().temporaryDirectory
827
.appendingPathComponent("openclaw-attach-only-\(UUID().uuidString)", isDirectory: true)

src/infra/update-managed-service-handoff.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,23 @@ const { spawnMock } = vi.hoisted(() => ({
1818
unref: vi.fn(),
1919
})),
2020
}));
21+
const fsState = vi.hoisted(() => ({ externalHome: undefined as string | undefined }));
22+
23+
vi.mock("node:fs", async () => {
24+
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
25+
const statSync = ((p: string) => {
26+
if (fsState.externalHome) {
27+
if (p === "/") {
28+
return { dev: 1 };
29+
}
30+
if (p === fsState.externalHome) {
31+
return { dev: 99 };
32+
}
33+
}
34+
return actual.statSync(p);
35+
}) as typeof actual.statSync;
36+
return { ...actual, statSync, default: { ...actual, statSync } };
37+
});
2138

2239
vi.mock("node:child_process", async () => {
2340
const { mockNodeChildProcessModule } =
@@ -31,6 +48,7 @@ const tempDirs = new Set<string>();
3148

3249
afterEach(async () => {
3350
spawnMock.mockClear();
51+
fsState.externalHome = undefined;
3452
await Promise.all([...tempDirs].map((dir) => fs.rm(dir, { recursive: true, force: true })));
3553
tempDirs.clear();
3654
});
@@ -477,6 +495,40 @@ describe("managed service update handoff", () => {
477495
}
478496
});
479497

498+
it("uses the boot-volume LaunchAgent plist for launchd handoff recovery when HOME is external", async () => {
499+
const { startManagedServiceUpdateHandoff } =
500+
await import("./update-managed-service-handoff.js");
501+
fsState.externalHome = "/Volumes/Data/Users/external";
502+
503+
const result = await startManagedServiceUpdateHandoff({
504+
root: "/tmp/openclaw",
505+
timeoutMs: 1_800_000,
506+
restartDelayMs: 500,
507+
parentPid: 12345,
508+
execPath: "/usr/local/bin/node",
509+
argv1: "/opt/openclaw/openclaw.mjs",
510+
supervisor: "launchd",
511+
env: {
512+
HOME: fsState.externalHome,
513+
USER: "testuser",
514+
OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.gateway",
515+
},
516+
meta: { sessionKey: "agent:test:webchat:dm:user-123" },
517+
});
518+
519+
expect(result.status).toBe("started");
520+
const [, args] = spawnMock.mock.calls.at(-1) as unknown as [string, string[]];
521+
tempDirs.add(path.dirname(args[0] ?? ""));
522+
const helperParams = JSON.parse(await fs.readFile(args[1] ?? "", "utf-8")) as {
523+
serviceRecovery?: { kind?: string; plistPath?: string };
524+
};
525+
expect(helperParams.serviceRecovery).toMatchObject({
526+
kind: "launchd",
527+
plistPath: "/Users/testuser/Library/LaunchAgents/ai.openclaw.gateway.plist",
528+
});
529+
expect(helperParams.serviceRecovery?.plistPath).not.toContain("/Volumes/Data");
530+
});
531+
480532
it("does not overwrite a restart sentinel owned by another startup task", async () => {
481533
const unrelatedSentinel = {
482534
version: 1,

src/infra/update-managed-service-handoff.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
resolveGatewaySystemdServiceName,
1010
resolveGatewayWindowsTaskName,
1111
} from "../daemon/constants.js";
12+
import { resolveLaunchAgentHomeDir } from "../daemon/paths.js";
1213
import { resolveRestartSentinelPath } from "./restart-sentinel.js";
1314
import { SUPERVISOR_HINT_ENV_VARS, type RespawnSupervisor } from "./supervisor-markers.js";
1415
import {
@@ -384,7 +385,10 @@ function resolveGatewayServiceRecovery(
384385
const label =
385386
env.OPENCLAW_LAUNCHD_LABEL?.trim() || resolveGatewayLaunchAgentLabel(env.OPENCLAW_PROFILE);
386387
const uid = typeof process.getuid === "function" ? process.getuid() : 501;
387-
const home = env.HOME?.trim() || os.homedir();
388+
const home = resolveLaunchAgentHomeDir({
389+
...env,
390+
HOME: env.HOME?.trim() || os.homedir(),
391+
});
388392
return {
389393
kind: "launchd",
390394
uid,

0 commit comments

Comments
 (0)