Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
58de11d
test(server): reproduce #17225 launch agent program path changing per…
juliusmarminge Oct 27, 2026
46604ac
fix(server): macOS launch agent keeps one program path across updates
juliusmarminge Oct 27, 2026
f15a4a6
fix(server): macOS launcher swap survives interruption and clones on …
juliusmarminge Oct 27, 2026
4fabb88
fix(server): overlapping macOS installs keep a complete launcher
juliusmarminge Oct 27, 2026
f4af379
docs: macOS Full Disk Access for the service carries over updates
juliusmarminge Oct 27, 2026
7a90b56
docs: note the one-time macOS re-grant when the service moves to its …
juliusmarminge Oct 27, 2026
4c04c00
Merge remote-tracking branch 'origin/main' into fix/issue-17225
juliusmarminge Nov 11, 2026
d68d95a
test(server): hoist orDie in the boot service runner fake and drop a …
juliusmarminge Nov 11, 2026
94871ad
Merge remote-tracking branch 'origin/main' into fix/issue-17225
juliusmarminge Nov 11, 2026
5a7ddad
test(server): fold the duplicate launch agent path assertion into one…
juliusmarminge Nov 11, 2026
83c66af
fix(server): swap only the macOS launcher executable so its path neve…
juliusmarminge Nov 11, 2026
a3f8003
Merge remote-tracking branch 'origin/main' into fix/issue-17225
tsukinoko-kun Nov 11, 2026
f334e1d
fix(server): hold the unit lease through launcher publish and removal
tsukinoko-kun Nov 11, 2026
077930d
fix(server): finish uninstall removal before releasing the unit lease
tsukinoko-kun Nov 11, 2026
011900a
Merge remote-tracking branch 'origin/main' into fix/issue-17225
juliusmarminge Nov 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 56 additions & 6 deletions apps/server/src/cloud/bootService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
const statePath = path.join(baseDir, "runtime", "service-state.json");
const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3", platform);
yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true });
yield* fs.writeFileString(runtime.entryPath, "#!/bin/sh\n");
yield* fs.writeFileString(runtime.entryPath, "#!/bin/sh\n", { mode: 0o755 });
yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n");

const commands: string[] = [];
Expand Down Expand Up @@ -218,7 +218,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
Effect.gen(function* () {
const paths = pinnedRuntimePaths(path, serviceBaseDir, cliVersion, platform);
yield* fs.makeDirectory(path.dirname(paths.entryPath), { recursive: true });
yield* fs.writeFileString(paths.entryPath, "#!/bin/sh\n");
yield* fs.writeFileString(paths.entryPath, "#!/bin/sh\n", { mode: 0o755 });
yield* fs.writeFileString(paths.sentinelPath, `${cliVersion}\n`);
return yield* BootService.make({
baseDir: serviceBaseDir,
Expand Down Expand Up @@ -569,6 +569,8 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
yield* makeHarness(platform);
const path = yield* Path.Path;
const envPath = path.join(baseDir, SERVICE_ENV_FILE);
const program = (version: string) =>
platform === "darwin" ? "runtime/launcher/t3" : `versions/${version}/t3`;
const envBytes = new TextEncoder().encode(
'# user configuration\r\nT3CODE_TOKEN="literal $HOME # token"',
);
Expand All @@ -587,7 +589,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
protocol: SERVICE_LAUNCHER_PROTOCOL,
activeVersion: "1.2.4",
});
expect(yield* fs.readFileString(plan.unitPath)).toContain("versions/1.2.4/t3");
expect(yield* fs.readFileString(plan.unitPath)).toContain(program("1.2.4"));
expect(
commands.filter(
(command) =>
Expand All @@ -609,7 +611,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
protocol: SERVICE_LAUNCHER_PROTOCOL,
activeVersion: "1.2.5",
});
expect(yield* fs.readFileString(plan.unitPath)).toContain("versions/1.2.5/t3");
expect(yield* fs.readFileString(plan.unitPath)).toContain(program("1.2.5"));
expect((yield* latest.status).current).toBe(true);
expect(
yield* fs.exists(path.join(path.dirname(statePath), SERVICE_RESTART_PENDING_FILE)),
Expand Down Expand Up @@ -1033,8 +1035,9 @@ process.disconnect();

it.effect("installs, reports current state, and uninstalls on macOS", () =>
Effect.gen(function* () {
const { service, fs, statePath, commands, timeouts, runtime } = yield* makeHarness("darwin");
const { service, fs, baseDir, statePath, commands, timeouts } = yield* makeHarness("darwin");
const path = yield* Path.Path;
const launcherDir = path.join(baseDir, "runtime", "launcher");
const plan = yield* service.install();

expect(
Expand All @@ -1050,13 +1053,18 @@ process.disconnect();
activeVersion: "1.2.3",
});
expect(yield* fs.readFileString(plan.unitPath)).toContain(
` <string>${runtime.entryPath}</string>\n <string>__service-launcher</string>`,
` <string>${path.join(launcherDir, "t3")}</string>\n <string>__service-launcher</string>`,
);
expect(yield* service.status).toMatchObject({
current: true,
installedVersion: "1.2.3",
});
yield* fs.writeFileString(path.join(launcherDir, ".install-complete"), "1.2.2\n");
expect((yield* service.status).current).toBe(false);
yield* service.install();
expect((yield* service.status).current).toBe(true);
expect(yield* service.uninstall).toBe(true);
expect(yield* fs.exists(launcherDir)).toBe(false);
expect((yield* service.status).installed).toBe(false);
expect(commands.some((command) => command.startsWith("systemctl "))).toBe(false);
// launchd can take 90s to bootout; the runner's 60s default could let bootstrap race it.
Expand All @@ -1066,6 +1074,48 @@ process.disconnect();
}),
);

// regression: #17225 macOS privacy grants are keyed to the launch agent's program path
it.effect("keeps the launch agent program at one path across version updates", () =>
Effect.gen(function* () {
const { service, makeService, fs } = yield* makeHarness("darwin");
const plan = yield* service.install();
const program = BootService.bootServiceProgramOf(yield* fs.readFileString(plan.unitPath));

yield* (yield* makeService(undefined, "1.2.4")).install();
const updatedProgram = BootService.bootServiceProgramOf(
yield* fs.readFileString(plan.unitPath),
);

expect(updatedProgram).toBe(program);
expect(updatedProgram).not.toContain("/runtime/versions/");
expect(yield* fs.exists(updatedProgram ?? "")).toBe(true);
}),
);

it.effect("swaps in a new launch agent runtime without writing into the running one", () =>
Effect.gen(function* () {
const { service, makeService, fs, baseDir } = yield* makeHarness("darwin");
const path = yield* Path.Path;
const launcherDir = path.join(baseDir, "runtime", "launcher");
const versionEntry = (version: string) =>
path.join(baseDir, "runtime", "versions", version, "t3");
yield* service.install();
const running = yield* fs.open(path.join(launcherDir, "t3"), { flag: "r" });

const newer = yield* makeService(undefined, "1.2.4");
yield* fs.writeFileString(versionEntry("1.2.4"), "#!/bin/sh\n# 1.2.4\n");
yield* newer.install({ start: false });

const buffer = new Uint8Array(64);
const read = yield* running.read(buffer);
expect(new TextDecoder().decode(buffer.subarray(0, Number(read)))).toBe("#!/bin/sh\n");
expect(yield* fs.readFileString(path.join(launcherDir, "t3"))).toBe("#!/bin/sh\n# 1.2.4\n");
expect((yield* fs.stat(path.join(launcherDir, "t3"))).mode & 0o111).toBe(
(yield* fs.stat(versionEntry("1.2.4"))).mode & 0o111,
);
}),
);

// regression: #8173 keep provider CLIs available in the macOS service
it.effect("reconstructs a launch agent search path when the installer has no PATH", () =>
Effect.gen(function* () {
Expand Down
51 changes: 41 additions & 10 deletions apps/server/src/cloud/bootService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease";

import * as ProcessRunner from "../processRunner.ts";
import { acquireServiceSelectionLock } from "./serviceSelectionLock.ts";
import { publishStableLauncher, stableLauncherPaths } from "./stableLauncher.ts";
import {
ensurePinnedRuntimeInstalled,
launcherOwnsVersionsDir,
Expand Down Expand Up @@ -639,6 +640,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
const logPath = path.join(input.logsDir, BOOT_SERVICE_LOG_FILE);
const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE);
const restartPendingPath = path.join(input.baseDir, "runtime", SERVICE_RESTART_PENDING_FILE);
const launcherPaths =
detectedManager?.kind === "launchd" ? stableLauncherPaths(input.baseDir) : runtimePaths;
const otherBaseDirOf = (unit: string) => {
const installedBaseDir = bootServiceBaseDirOf(unit);
return installedBaseDir !== undefined &&
Expand Down Expand Up @@ -666,7 +669,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
}),
).pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
const plan: BootServicePlan = {
program: [runtimePaths.entryPath, "__service-launcher"],
program: [launcherPaths.entryPath, "__service-launcher"],
baseDir: input.baseDir,
logPath,
unitPath,
Expand Down Expand Up @@ -954,6 +957,13 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
return yield* new BootServiceUpdatePendingError();
}
}
if (manager.kind === "launchd") {
yield* Effect.tryPromise({
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
try: () =>
publishStableLauncher(input.baseDir, runtimePaths.entryPath, input.cliVersion),
catch: (cause) => new BootServiceInstallError({ cause }),
}).pipe(Effect.uninterruptible);
}
yield* writeDurably(unitPath, manager.render(plan));
}),
),
Expand Down Expand Up @@ -1073,9 +1083,21 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
)
return false;
yield* runSteps(manager.deactivate);
yield* fs
.remove(unitPath)
.pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
yield* withSelectionLock(
unitPath,
fs.remove(unitPath).pipe(
Effect.andThen(
manager.kind === "launchd"
? fs.remove(stableLauncherPaths(input.baseDir).directory, {
recursive: true,
force: true,
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
: Effect.void,
),
Effect.mapError((cause) => new BootServiceInstallError({ cause })),
Effect.uninterruptible,
),
);
yield* runSteps(manager.finalize);
return true;
}).pipe(Effect.withSpan("cloud.boot_service.uninstall"));
Expand All @@ -1087,10 +1109,20 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
if (!(yield* fs.exists(unitPath))) {
return { supported: true, installed: false, current: false, unitPath, logPath };
}
const [unit, runtimeEntryExists, runtimeSentinel, stateText] = yield* Effect.all([
const isCurrent = (paths: { readonly entryPath: string; readonly sentinelPath: string }) =>
Effect.all([
fs.exists(paths.entryPath),
fs.readFileString(paths.sentinelPath).pipe(Effect.option),
]).pipe(
Effect.map(
([entryExists, sentinel]) =>
entryExists && Option.isSome(sentinel) && sentinel.value.trim() === input.cliVersion,
),
);
const [unit, runtimeIsCurrent, launcherIsCurrent, stateText] = yield* Effect.all([
fs.readFileString(unitPath),
fs.exists(runtimePaths.entryPath),
fs.readFileString(runtimePaths.sentinelPath).pipe(Effect.option),
isCurrent(runtimePaths),
isCurrent(launcherPaths),
fs.readFileString(statePath).pipe(Effect.option),
]);
const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined;
Expand All @@ -1116,9 +1148,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
current:
problems.length === 0 &&
normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) &&
runtimeEntryExists &&
Option.isSome(runtimeSentinel) &&
runtimeSentinel.value.trim() === input.cliVersion &&
runtimeIsCurrent &&
launcherIsCurrent &&
state?.activeVersion === input.cliVersion &&
state?.update?.status !== "pending",
unitPath,
Expand Down
69 changes: 69 additions & 0 deletions apps/server/src/cloud/stableLauncher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// @effect-diagnostics nodeBuiltinImport:off -- Shared with the standalone launcher, which has no Effect runtime.
import * as NodeChildProcess from "node:child_process";
import * as NodeFSP from "node:fs/promises";
import * as NodePath from "node:path";

/**
* macOS TCC keys a launch agent's privacy grants to its program path, so launchd starts a copy
* of the selected runtime's executable kept here. `__service-launcher` loads only Node built-ins,
* so the executable alone is the launcher.
*/
export const stableLauncherPaths = (baseDir: string) => {
const directory = NodePath.join(baseDir, "runtime", "launcher");
return {
directory,
entryPath: NodePath.join(directory, "t3"),
sentinelPath: NodePath.join(directory, ".install-complete"),
};
};

// /bin/cp -c clones on APFS; Node's copyFile copies every byte on macOS.
const cloneFile = (source: string, destination: string) =>
new Promise<boolean>((resolve) => {
NodeChildProcess.execFile(
"/bin/cp",
["-c", "-p", source, destination],
{ timeout: 60_000 },
(error) => resolve(error === null),
);
});

/**
* Callers hold the unit's selection lease. Each file is renamed over its predecessor, so the
* program path never goes missing and a running launcher keeps its old inode.
*/
export async function publishStableLauncher(
baseDir: string,
sourceEntryPath: string,
version: string,
): Promise<void> {
const paths = stableLauncherPaths(baseDir);
const stagedEntry = NodePath.join(paths.directory, ".t3.staged");
const stagedSentinel = NodePath.join(paths.directory, ".install-complete.staged");
await NodeFSP.mkdir(paths.directory, { recursive: true });
try {
await NodeFSP.rm(stagedEntry, { force: true });
if (!(await cloneFile(sourceEntryPath, stagedEntry))) {
await NodeFSP.copyFile(sourceEntryPath, stagedEntry);
}
await NodeFSP.writeFile(stagedSentinel, `${version}\n`);
await syncPath(stagedEntry);
await syncPath(stagedSentinel);
await NodeFSP.rename(stagedEntry, paths.entryPath);
await NodeFSP.rename(stagedSentinel, paths.sentinelPath);
await syncPath(paths.directory);
} finally {
await NodeFSP.rm(stagedEntry, { force: true });
await NodeFSP.rm(stagedSentinel, { force: true });
}
}

// The unit is written durably right after this, so the program it names must be too.
async function syncPath(filePath: string): Promise<void> {
const handle = await NodeFSP.open(filePath, "r");
try {
await handle.sync();
} finally {
await handle.close();
}
}
20 changes: 20 additions & 0 deletions apps/server/src/serviceLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as Path from "effect/Path";
import { vi } from "vite-plus/test";

import { renderBootServicePlist, renderBootServiceUnit } from "./cloud/bootService.ts";
import { stableLauncherPaths } from "./cloud/stableLauncher.ts";
import {
databaseBackupBlocker,
Launcher,
Expand Down Expand Up @@ -516,6 +517,25 @@ if (context.update?.status === "pending") {
}),
);

// regression: #17225 macOS privacy grants are keyed to the launch agent's program path
it.effect("publishes the committed runtime to a launch agent's stable launcher", () =>
Effect.gen(function* () {
const render = (plan: Parameters<typeof renderBootServicePlist>[0]) =>
renderBootServicePlist(
{ ...plan, program: [stableLauncherPaths(plan.baseDir).entryPath, "__service-launcher"] },
{ homeDir: "/Users/test", environmentPath: "/usr/bin" },
);
const { fs, root, unit } = yield* commitUpdate(render);
const launcher = stableLauncherPaths(root);
assert.equal(yield* fs.readFileString(unit.path), render(unitPlan(root, "1.0.0")));
assert.equal(yield* fs.readFileString(launcher.sentinelPath), "1.1.0\n");
assert.equal(
yield* fs.readFileString(launcher.entryPath),
yield* fs.readFileString(`${root}/runtime/versions/1.1.0/t3`),
);
}),
);

it.effect(
"keeps a durable commit through unit failure and retries a failed reload on restart",
() =>
Expand Down
26 changes: 24 additions & 2 deletions apps/server/src/serviceLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as NodeFSP from "node:fs/promises";
import * as NodePath from "node:path";
import * as NodeUtil from "node:util";
import { acquireServiceSelectionLock } from "./cloud/serviceSelectionLock.ts";
import { publishStableLauncher, stableLauncherPaths } from "./cloud/stableLauncher.ts";

import type {
PendingServiceUpdate,
Expand All @@ -23,6 +24,7 @@ import {
isExactServiceVersion,
parseServiceState,
serviceUnitBaseDirOf,
serviceUnitProgramOf,
SERVICE_ENV_FILE,
SERVICE_LAUNCHER_CONTEXT_ENV,
SERVICE_LAUNCHER_PROTOCOL,
Expand Down Expand Up @@ -994,8 +996,28 @@ export class Launcher {
) {
return;
}
const repointed = repointServiceUnit(contents, version);
if (repointed !== contents) await replaceFileDurably(unit.path, repointed);
const launcher = stableLauncherPaths(this.#baseDir);
const program = serviceUnitProgramOf(contents);
if (
program !== undefined &&
NodePath.resolve(program) === NodePath.resolve(launcher.entryPath)
) {
// Like a unit, a launcher already at this version or a newer one stays.
const published = await NodeFSP.readFile(launcher.sentinelPath, "utf8").catch(() => "");
if (
!isExactServiceVersion(published.trim()) ||
compareExactServiceVersions(published.trim(), version) < 0
) {
await publishStableLauncher(
this.#baseDir,
runtimePaths(this.#baseDir, version).entryPath,
version,
);
}
} else {
const repointed = repointServiceUnit(contents, version);
if (repointed !== contents) await replaceFileDurably(unit.path, repointed);
}
// A previous rename may have succeeded before reload failed.
await unit.reload();
} finally {
Expand Down
9 changes: 7 additions & 2 deletions docs/internals/server-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,13 @@ reloads the manager, so the next service start runs that version's launcher; the
protocol check is what makes that safe. Each start after a finished update retries this, because
a previous reload may have failed, and leaves a unit naming a newer runtime or another
T3 home alone. Until a deferred local upgrade restarts, systemd (before daemon-reload)
and launchd (before bootstrap) can respawn the old launcher, which the new runtime
refuses to run under.
can respawn the old launcher, which the new runtime refuses to run under.

launchd instead starts a [copy](../../apps/server/src/cloud/stableLauncher.ts) of the
selected runtime's executable at a fixed path, because TCC keys a launch agent's
privacy grants to its program path. Installs and the launcher replace that copy by
rename under the unit lease rather than rewriting the plist, so a running launcher
keeps its old inode.

## Commit boundary

Expand Down
Loading
Loading