authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-25 00:35:38-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-25 00:35:38-07:00
log1baf99195a552572e24f907cd7f72ce01c6c81c8
tree33580d56661aaa42e44d479322090fd905f9ff91
parent1a4a7596c2c557fcc26a5dceefc04ac6268640b4
signature Signed by PGP key B5690EEEBB952194

fix(server): keep provider CLIs available in the macOS service (#8173)


2 files changed, 146 insertions(+), 30 deletions(-)

apps/server/src/cloud/bootService.test.ts+96-24
......@@ -56,17 +56,26 @@ const macPlan = {
5656 logPath: "/Users/theo/.t3/userdata/logs/boot-service.log",
5757 unitPath: "/Users/theo/Library/LaunchAgents/com.t3tools.t3code.service.plist",
5858};
59const macInstallerPath =
60 "/opt/homebrew/bin:/Users/theo/.npm-global/bin:/Users/theo/.nvm/versions/node/v22.16.0/bin:/usr/bin:/bin";
61const macRenderOptions = { homeDir: "/Users/theo", environmentPath: macInstallerPath };
5962
6063it("keeps launchd pinned to the stable launcher rather than a versioned server", () => {
61 const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" });
64 const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions);
6265
6366 expect(plist).toContain("<string>/opt/homebrew/bin/node</string>");
6467 expect(plist).toContain("<string>/Users/theo/.t3/runtime/service-launcher.mjs</string>");
6568 expect(plist).not.toContain("versions/1.2.3");
6669});
6770
71it("preserves the installer's provider search path in the launch agent", () => {
72 const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions);
73
74 expect(plist).toContain(` <key>PATH</key>\n <string>${macInstallerPath}</string>`);
75});
76
6877it("restarts the launch agent on the systemd cadence", () => {
69 const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" });
78 const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions);
7079
7180 expect(plist).toContain("<key>RunAtLoad</key>\n <true/>");
7281 expect(plist).toContain("<key>KeepAlive</key>\n <true/>");
......@@ -75,7 +84,7 @@ it("restarts the launch agent on the systemd cadence", () => {
7584});
7685
7786it("appends both stdio streams to the boot service log", () => {
78 const plist = BootService.renderBootServicePlist(macPlan, { homeDir: "/Users/theo" });
87 const plist = BootService.renderBootServicePlist(macPlan, macRenderOptions);
7988
8089 expect(plist).toContain(
8190 "<key>StandardOutPath</key>\n <string>/Users/theo/.t3/userdata/logs/boot-service.log</string>",
......@@ -88,15 +97,17 @@ it("appends both stdio streams to the boot service log", () => {
8897it("escapes XML in host paths", () => {
8998 const plist = BootService.renderBootServicePlist(
9099 { ...macPlan, baseDir: "/Users/theo/T3 & <Co>" },
91 { homeDir: "/Users/theo" },
100 { homeDir: "/Users/theo", environmentPath: "/Users/theo/Tools & <Scripts>:/usr/bin" },
92101 );
93102
94103 expect(plist).toContain("<string>/Users/theo/T3 &amp; &lt;Co&gt;</string>");
104 expect(plist).toContain("<string>/Users/theo/Tools &amp; &lt;Scripts&gt;:/usr/bin</string>");
95105});
96106
97107const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
98108 platform: NodeJS.Platform = "linux",
99109 usePinnedLauncher = false,
110 installerPath = macInstallerPath,
100111) {
101112 const fs = yield* FileSystem.FileSystem;
102113 const path = yield* Path.Path;
......@@ -135,27 +146,33 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
135146 };
136147 }),
137148 });
138 const service = yield* BootService.make({
139 baseDir,
140 logsDir: path.join(baseDir, "userdata", "logs"),
141 cliVersion: "1.2.3",
142 host: {
143 execPath: "/usr/bin/node",
144 ...(usePinnedLauncher ? {} : { launcherSourcePath: sourceLauncher }),
145 },
146 }).pipe(
147 Effect.provideService(ProcessRunner.ProcessRunner, runner),
148 Effect.provide(
149 Layer.mergeAll(
150 Layer.succeed(HostProcessPlatform, platform),
151 Layer.succeed(HostProcessUserId, 501),
152 Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"),
153 Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]),
154 ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })),
149 const makeService = (environmentPath = installerPath) =>
150 BootService.make({
151 baseDir,
152 logsDir: path.join(baseDir, "userdata", "logs"),
153 cliVersion: "1.2.3",
154 host: {
155 execPath: "/usr/bin/node",
156 ...(usePinnedLauncher ? {} : { launcherSourcePath: sourceLauncher }),
157 },
158 }).pipe(
159 Effect.provideService(ProcessRunner.ProcessRunner, runner),
160 Effect.provide(
161 Layer.mergeAll(
162 Layer.succeed(HostProcessPlatform, platform),
163 Layer.succeed(HostProcessUserId, 501),
164 Layer.succeed(HostProcessExecutablePath, "/usr/bin/node"),
165 Layer.succeed(HostProcessArguments, ["/usr/bin/node", path.join(home, "bin.mjs")]),
166 ConfigProvider.layer(
167 ConfigProvider.fromEnv({
168 env: { HOME: home, ...(environmentPath === "" ? {} : { PATH: environmentPath }) },
169 }),
170 ),
171 ),
155172 ),
156 ),
157 );
158 return { service, fs, statePath, commands, timeouts, control };
173 );
174 const service = yield* makeService();
175 return { service, makeService, fs, statePath, commands, timeouts, control };
159176});
160177
161178it.layer(NodeServices.layer)("boot service install", (it) => {
......@@ -266,6 +283,9 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
266283 expect(plan.unitPath.endsWith("Library/LaunchAgents/com.t3tools.t3code.service.plist")).toBe(
267284 true,
268285 );
286 expect(yield* fs.readFileString(plan.unitPath)).toContain(
287 ` <key>PATH</key>\n <string>${macInstallerPath}:/usr/local/bin:/usr/sbin:/sbin</string>`,
288 );
269289 expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({
270290 protocol: SERVICE_LAUNCHER_PROTOCOL,
271291 activeVersion: "1.2.3",
......@@ -303,6 +323,58 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
303323 }),
304324 );
305325
326 it.effect("reconstructs a launch agent search path when the installer has no PATH", () =>
327 Effect.gen(function* () {
328 const { service, fs } = yield* makeHarness("darwin", false, "");
329 const plan = yield* service.install;
330
331 expect(yield* fs.readFileString(plan.unitPath)).toContain(
332 " <key>PATH</key>\n <string>/usr/bin:/opt/homebrew/bin:/usr/local/bin:/bin:/usr/sbin:/sbin</string>",
333 );
334 expect((yield* service.status).current).toBe(true);
335 }),
336 );
337
338 it.effect("adds missing provider directories to a minimal installer PATH", () =>
339 Effect.gen(function* () {
340 const { service, fs } = yield* makeHarness("darwin", false, "/usr/bin:/bin");
341 const plan = yield* service.install;
342
343 expect(yield* fs.readFileString(plan.unitPath)).toContain(
344 " <key>PATH</key>\n <string>/usr/bin:/bin:/opt/homebrew/bin:/usr/local/bin:/usr/sbin:/sbin</string>",
345 );
346 expect((yield* service.status).current).toBe(true);
347 }),
348 );
349
350 it.effect("keeps an installed launch agent current when the process PATH changes", () =>
351 Effect.gen(function* () {
352 const { service, makeService } = yield* makeHarness("darwin");
353 yield* service.install;
354
355 const restartedService = yield* makeService("/usr/local/bin:/usr/bin:/bin");
356 expect((yield* restartedService.status).current).toBe(true);
357 }),
358 );
359
360 it.effect("drops PATH directories that cannot be represented in a launch agent plist", () =>
361 Effect.gen(function* () {
362 const { service, fs } = yield* makeHarness(
363 "darwin",
364 false,
365 "/opt/homebrew/bin:/Users/theo/\u0001invalid:/usr/bin",
366 );
367 const plan = yield* service.install;
368 const plist = yield* fs.readFileString(plan.unitPath);
369
370 expect(plist).toContain(
371 " <key>PATH</key>\n <string>/opt/homebrew/bin:/usr/bin:/usr/local/bin:/bin:/usr/sbin:/sbin</string>",
372 );
373 expect(plist).not.toContain("\u0001");
374 expect((yield* service.status).current).toBe(true);
375 }),
376 );
377
306378 it.effect("ignores a bootout for an agent that is not loaded", () =>
307379 Effect.gen(function* () {
308380 const { service, control } = yield* makeHarness("darwin");
apps/server/src/cloud/bootService.ts+50-6
......@@ -99,7 +99,7 @@ export function escapeXmlText(value: string): string {
9999/** Pure renderer: launch agents cannot rely on the user's shell or PATH. */
100100export function renderBootServicePlist(
101101 plan: BootServicePlan,
102 options: { readonly homeDir: string },
102 options: { readonly homeDir: string; readonly environmentPath: string },
103103): string {
104104 // KeepAlive + ThrottleInterval mirror Restart=always + RestartSec=5. launchd
105105 // has no StartLimitBurst analog; a hard crash loop respawns every 5s forever.
......@@ -127,6 +127,8 @@ export function renderBootServicePlist(
127127 ` </array>`,
128128 ` <key>EnvironmentVariables</key>`,
129129 ` <dict>`,
130 ` <key>PATH</key>`,
131 ` <string>${escapeXmlText(options.environmentPath)}</string>`,
130132 ` <key>T3CODE_HOME</key>`,
131133 ` <string>${escapeXmlText(plan.baseDir)}</string>`,
132134 ` <key>${BOOT_SERVICE_UNIT_ENV}</key>`,
......@@ -268,6 +270,7 @@ export function launchdManager(input: {
268270 readonly path: Path.Path;
269271 readonly homeDir: string;
270272 readonly uid: number;
273 readonly environmentPath: string;
271274}): BootServiceManager {
272275 const unitPath = input.path.join(
273276 input.homeDir,
......@@ -287,7 +290,11 @@ export function launchdManager(input: {
287290 return {
288291 kind: "launchd",
289292 unitPath,
290 render: (plan) => renderBootServicePlist(plan, { homeDir: input.homeDir }),
293 render: (plan) =>
294 renderBootServicePlist(plan, {
295 homeDir: input.homeDir,
296 environmentPath: input.environmentPath,
297 }),
291298 // Without --wait, bootout returns in milliseconds while the job drains
292299 // for up to ExitTimeOut, and a bootstrap during the drain fails EIO.
293300 // --wait (present on modern macOS, absent from the man page) blocks until
......@@ -346,6 +353,7 @@ export function selectBootServiceManager(input: {
346353 readonly homeDir: string;
347354 readonly uid: number | undefined;
348355 readonly path: Path.Path;
356 readonly environmentPath: string;
349357}): BootServiceManager | undefined {
350358 if (input.homeDir === "") {
351359 return undefined;
......@@ -354,7 +362,12 @@ export function selectBootServiceManager(input: {
354362 return systemdManager({ path: input.path, homeDir: input.homeDir });
355363 }
356364 if (input.platform === "darwin" && input.uid !== undefined) {
357 return launchdManager({ path: input.path, homeDir: input.homeDir, uid: input.uid });
365 return launchdManager({
366 path: input.path,
367 homeDir: input.homeDir,
368 uid: input.uid,
369 environmentPath: input.environmentPath,
370 });
358371 }
359372 return undefined;
360373}
......@@ -441,12 +454,39 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
441454 const platform = yield* HostProcessPlatform;
442455 const uid = yield* HostProcessUserId;
443456 const homeDir = yield* Config.string("HOME").pipe(Config.withDefault(""));
457 const installerPath = yield* Config.string("PATH").pipe(Config.withDefault(""));
444458 const fs = yield* FileSystem.FileSystem;
445459 const path = yield* Path.Path;
446460 const runner = yield* ProcessRunner.ProcessRunner;
447461 const host = input.host ?? { execPath: hostExecPath };
448
449 const detectedManager = selectBootServiceManager({ platform, homeDir, uid, path });
462 const xmlSafeInstallerDirectories = installerPath.split(":").filter(
463 (directory) =>
464 directory.length > 0 &&
465 Array.from(directory).every((character) => {
466 const code = character.charCodeAt(0);
467 return code >= 0x20 || code === 0x09 || code === 0x0a || code === 0x0d;
468 }),
469 );
470 const environmentPath = Array.from(
471 new Set([
472 ...xmlSafeInstallerDirectories,
473 path.dirname(host.execPath),
474 "/opt/homebrew/bin",
475 "/usr/local/bin",
476 "/usr/bin",
477 "/bin",
478 "/usr/sbin",
479 "/sbin",
480 ]),
481 ).join(":");
482
483 const detectedManager = selectBootServiceManager({
484 platform,
485 homeDir,
486 uid,
487 path,
488 environmentPath,
489 });
450490 const unitPath = detectedManager?.unitPath ?? "";
451491 const logPath = path.join(input.logsDir, "boot-service.log");
452492 const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE);
......@@ -664,11 +704,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
664704 fs.readFileString(statePath).pipe(Effect.option),
665705 ]);
666706 const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined;
707 const normalizeUnit = (contents: string) =>
708 detectedManager.kind === "launchd"
709 ? contents.replace(/(<key>PATH<\/key>\n\s*<string>)[^<]*(<\/string>)/, "$1$2")
710 : contents;
667711 return {
668712 supported: true,
669713 installed: true,
670714 current:
671 unit === detectedManager.render(plan) &&
715 normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) &&
672716 launcherExists &&
673717 runtimeEntryExists &&
674718 Option.isSome(runtimeSentinel) &&