diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts
index 4d89805bb19d..a98ef4951372 100644
--- a/apps/server/src/cloud/bootService.test.ts
+++ b/apps/server/src/cloud/bootService.test.ts
@@ -200,7 +200,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[] = [];
@@ -344,7 +344,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,
@@ -910,6 +910,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/service/t3" : `versions/${version}/t3`;
const envBytes = new TextEncoder().encode(
'# user configuration\r\nT3CODE_TOKEN="literal $HOME # token"',
);
@@ -928,7 +930,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) =>
@@ -950,7 +952,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)),
@@ -1405,8 +1407,10 @@ 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, makeService, fs, baseDir, statePath, commands, timeouts } =
+ yield* makeHarness("darwin");
const path = yield* Path.Path;
+ const launcherDir = path.join(baseDir, "runtime", "service");
const plan = yield* service.install();
expect(
@@ -1422,13 +1426,19 @@ process.disconnect();
activeVersion: "1.2.3",
});
expect(yield* fs.readFileString(plan.unitPath)).toContain(
- ` ${runtime.entryPath}\n __service-launcher`,
+ ` ${path.join(launcherDir, "t3")}\n __service-launcher`,
);
expect(yield* service.status).toMatchObject({
current: true,
installedVersion: "1.2.3",
});
- expect(yield* service.uninstall).toBe(true);
+ 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);
+ const otherHome = yield* makeService(undefined, "1.2.3", path.join(baseDir, "other-home"));
+ expect(yield* otherHome.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.
@@ -1552,6 +1562,30 @@ process.disconnect();
}),
);
+ 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", "service");
+ 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* () {
diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts
index 0a884948a97a..b8e1a2d6d38c 100644
--- a/apps/server/src/cloud/bootService.ts
+++ b/apps/server/src/cloud/bootService.ts
@@ -18,6 +18,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 {
serviceLauncherIsRunning,
serviceLauncherLockPath,
@@ -39,6 +40,7 @@ import {
SERVICE_UNIT_ENV,
compareExactServiceVersions,
disabledServiceTask,
+ escapeXmlText,
serviceTaskIsDisabled,
serviceUnitBaseDirOf as bootServiceBaseDirOf,
serviceUnitEnvironmentValueOf,
@@ -144,10 +146,6 @@ export function renderBootServiceUnit(plan: BootServicePlan): string {
].join("\n");
}
-function escapeXmlText(value: string): string {
- return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
-}
-
/** Launch agents cannot rely on the user's shell or PATH. */
export function renderBootServicePlist(
plan: BootServicePlan,
@@ -966,6 +964,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
});
});
const logPath = path.join(input.logsDir, BOOT_SERVICE_LOG_FILE);
+ const launcherPathsFor = (manager: BootServiceManager | undefined) =>
+ manager?.kind === "launchd" ? stableLauncherPaths(input.baseDir) : runtimePaths;
const resolveTarget = (systemdUnitDir: string | undefined) => {
const manager = selectBootServiceManager({
platform,
@@ -990,7 +990,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
});
const unitPath = manager?.unitPath ?? "";
const plan: BootServicePlan = {
- program: [runtimePaths.entryPath, "__service-launcher"],
+ program: [launcherPathsFor(manager).entryPath, "__service-launcher"],
baseDir: input.baseDir,
logPath,
unitPath,
@@ -1391,6 +1391,13 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
return yield* new BootServiceUpdatePendingError();
}
}
+ if (manager.kind === "launchd") {
+ yield* Effect.tryPromise({
+ try: () =>
+ publishStableLauncher(input.baseDir, runtimePaths.entryPath, input.cliVersion),
+ catch: (cause) => new BootServiceInstallError({ cause }),
+ }).pipe(Effect.uninterruptible);
+ }
const unit = manager.render(plan);
yield* writeDurably(unitPath, unit);
if (!start && manager.kind === "schtasks") yield* refreshWindowsTask(unit);
@@ -1524,9 +1531,22 @@ 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.readFileString(unitPath).pipe(
+ Effect.tap(() => fs.remove(unitPath)),
+ Effect.flatMap((unit) =>
+ manager.kind === "launchd"
+ ? fs.remove(
+ stableLauncherPaths(bootServiceBaseDirOf(unit) ?? input.baseDir).directory,
+ { recursive: true, force: true },
+ )
+ : Effect.void,
+ ),
+ Effect.mapError((cause) => new BootServiceInstallError({ cause })),
+ Effect.uninterruptible,
+ ),
+ );
yield* runSteps(manager.finalize);
return true;
}).pipe(Effect.withSpan("cloud.boot_service.uninstall"));
@@ -1539,10 +1559,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(launcherPathsFor(manager)),
fs.readFileString(statePath).pipe(Effect.option),
]);
const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined;
@@ -1573,9 +1603,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
problems.length === 0 &&
!(manager.kind === "systemd" && systemdUnsafeValue(planForUnit(unit))) &&
normalizeUnit(unit) === normalizeUnit(manager.render(planForUnit(unit))) &&
- runtimeEntryExists &&
- Option.isSome(runtimeSentinel) &&
- runtimeSentinel.value.trim() === input.cliVersion &&
+ runtimeIsCurrent &&
+ launcherIsCurrent &&
state?.activeVersion === input.cliVersion &&
state?.update?.status !== "pending",
unitPath,
diff --git a/apps/server/src/cloud/serviceProtocol.ts b/apps/server/src/cloud/serviceProtocol.ts
index c0a43bfe23e1..be2022c5a67e 100644
--- a/apps/server/src/cloud/serviceProtocol.ts
+++ b/apps/server/src/cloud/serviceProtocol.ts
@@ -30,6 +30,9 @@ const unquoteSystemdValue = (raw: string) =>
: raw
).replaceAll("%%", "%");
+export const escapeXmlText = (value: string) =>
+ value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
+
const unescapeXmlText = (value: string) =>
value.replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&");
diff --git a/apps/server/src/cloud/stableLauncher.ts b/apps/server/src/cloud/stableLauncher.ts
new file mode 100644
index 000000000000..7a3329f834d0
--- /dev/null
+++ b/apps/server/src/cloud/stableLauncher.ts
@@ -0,0 +1,68 @@
+// @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", "service");
+ 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((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 {
+ 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 });
+ }
+}
+
+async function syncPath(filePath: string): Promise {
+ const handle = await NodeFSP.open(filePath, "r");
+ try {
+ await handle.sync();
+ } finally {
+ await handle.close();
+ }
+}
diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts
index ac699b9c7d6c..5efc83b4317d 100644
--- a/apps/server/src/serviceLauncher.test.ts
+++ b/apps/server/src/serviceLauncher.test.ts
@@ -20,6 +20,7 @@ import {
renderBootServiceTask,
renderBootServiceUnit,
} from "./cloud/bootService.ts";
+import { stableLauncherPaths } from "./cloud/stableLauncher.ts";
import {
databaseBackupBlocker,
Launcher,
@@ -276,46 +277,57 @@ it.layer(NodeServices.layer)("service state persistence", (it) => {
}),
);
- it.effect("finds its unit through the manager's environment, not service.env", () =>
- Effect.gen(function* () {
- const fs = yield* FileSystem.FileSystem;
- const path = yield* Path.Path;
- const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-unit-" });
- const home = path.join(root, "home");
- const unitPath = path.join(home, "Library", "LaunchAgents", "t3code.plist");
- const render = (version: string) =>
- renderBootServicePlist(unitPlan(root, version), { homeDir: home, environmentPath: "" });
- yield* fs.makeDirectory(path.dirname(unitPath), { recursive: true });
- yield* fs.writeFileString(unitPath, render("1.0.0"));
- // The server may run with another HOME; the launch agent still lives in the manager's.
- yield* fs.writeFileString(path.join(root, SERVICE_ENV_FILE), "HOME=/elsewhere\n");
- yield* writeFakeRuntime(
- fs,
- path,
- path.join(root, "runtime", "versions", "1.1.0"),
- "process.exit(0);\n",
- );
- yield* Effect.promise(() =>
- writeServiceState(path.join(root, "runtime", "service-state.json"), {
- protocol: SERVICE_LAUNCHER_PROTOCOL,
- activeVersion: "1.1.0",
- update: {
- id: "update-1",
- fromVersion: "1.0.0",
- targetVersion: "1.1.0",
- status: "committed",
- },
- }),
- );
- vi.stubEnv("HOME", home);
- vi.stubEnv("T3_BOOT_SERVICE_UNIT", "t3code.plist");
+ // regression: #17225 a launcher from before the stable launcher committed the remote update
+ it.effect(
+ "moves the launch agent it finds through the manager's environment to the stable launcher",
+ () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-unit-" });
+ const home = path.join(root, "home");
+ const unitPath = path.join(home, "Library", "LaunchAgents", "t3code.plist");
+ const render = (program: string) =>
+ renderBootServicePlist(
+ { ...unitPlan(root, "1.0.0"), program: [program, "__service-launcher"] },
+ { homeDir: home, environmentPath: "" },
+ );
+ yield* fs.makeDirectory(path.dirname(unitPath), { recursive: true });
+ // A launcher from before the stable launcher pointed it at the committed runtime.
+ yield* fs.writeFileString(unitPath, render(`${root}/runtime/versions/1.1.0/t3`));
+ // The server may run with another HOME; the launch agent still lives in the manager's.
+ yield* fs.writeFileString(path.join(root, SERVICE_ENV_FILE), "HOME=/elsewhere\n");
+ yield* writeFakeRuntime(
+ fs,
+ path,
+ path.join(root, "runtime", "versions", "1.1.0"),
+ "process.exit(0);\n",
+ );
+ yield* Effect.promise(() =>
+ writeServiceState(path.join(root, "runtime", "service-state.json"), {
+ protocol: SERVICE_LAUNCHER_PROTOCOL,
+ activeVersion: "1.1.0",
+ update: {
+ id: "update-1",
+ fromVersion: "1.0.0",
+ targetVersion: "1.1.0",
+ status: "committed",
+ },
+ }),
+ );
+ vi.stubEnv("HOME", home);
+ vi.stubEnv("T3_BOOT_SERVICE_UNIT", "t3code.plist");
- yield* Effect.promise(() =>
- expect(runLauncher(root)).rejects.toThrow("Active child exited unexpectedly (0)."),
- );
+ yield* Effect.promise(() =>
+ expect(runLauncher(root)).rejects.toThrow("Active child exited unexpectedly (0)."),
+ );
- assert.equal(yield* fs.readFileString(unitPath), render("1.1.0"));
- }),
+ assert.equal(
+ yield* fs.readFileString(unitPath),
+ render(stableLauncherPaths(root).entryPath),
+ );
+ assert.equal(yield* fs.readFileString(stableLauncherPaths(root).sentinelPath), "1.1.0\n");
+ }),
);
it.effect(
@@ -1046,15 +1058,6 @@ if (context.update?.status === "pending") {
render: renderBootServiceUnit,
legacyInstall: true,
},
- {
- name: "launchd",
- legacyInstall: false,
- render: (plan: Parameters[0]) =>
- renderBootServicePlist(plan, {
- homeDir: "/Users/test & ",
- environmentPath: "/usr/bin:/custom/runtime/versions/1.0.0",
- }),
- },
{
name: "Task Scheduler",
legacyInstall: false,
@@ -1076,6 +1079,57 @@ if (context.update?.status === "pending") {
}),
);
+ const launchAgent =
+ (program: "stable launcher" | "versioned runtime" | "newer runtime") =>
+ (plan: Parameters[0]) =>
+ renderBootServicePlist(
+ program === "versioned runtime"
+ ? plan
+ : {
+ ...plan,
+ program: [
+ program === "stable launcher"
+ ? stableLauncherPaths(plan.baseDir).entryPath
+ : `${plan.baseDir}/runtime/versions/2.0.0/t3`,
+ "__service-launcher",
+ ],
+ },
+ {
+ homeDir: "/Users/test & ",
+ environmentPath: "/usr/bin:/custom/runtime/versions/1.0.0",
+ },
+ );
+
+ // regression: #17225 macOS privacy grants are keyed to the launch agent's program path
+ it.effect.each(["stable launcher", "versioned runtime"] as const)(
+ "publishes the committed runtime to a launch agent naming its %s",
+ (program) =>
+ Effect.gen(function* () {
+ const { fs, root, unit } = yield* commitUpdate(launchAgent(program));
+ const launcher = stableLauncherPaths(root);
+ assert.equal(
+ yield* fs.readFileString(unit.path),
+ launchAgent("stable launcher")(unitPlan(root, "1.1.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("leaves a launch agent naming a newer runtime than the committed one", () =>
+ Effect.gen(function* () {
+ const { fs, root, unit } = yield* commitUpdate(launchAgent("newer runtime"));
+ assert.equal(
+ yield* fs.readFileString(unit.path),
+ launchAgent("newer runtime")(unitPlan(root, "1.1.0")),
+ );
+ assert.isFalse(yield* fs.exists(stableLauncherPaths(root).entryPath));
+ }),
+ );
+
it.effect(
"keeps a durable commit through unit failure and retries a failed reload on restart",
() =>
diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts
index 1a6c07cf4365..9cf154f6405d 100644
--- a/apps/server/src/serviceLauncher.ts
+++ b/apps/server/src/serviceLauncher.ts
@@ -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,
@@ -20,6 +21,7 @@ import type {
import {
compareExactServiceVersions,
decodeServiceLauncherChildMessage,
+ escapeXmlText,
isExactServiceVersion,
parseLegacyServiceState,
disabledServiceTask,
@@ -27,6 +29,7 @@ import {
serviceTaskIsDisabled,
serviceUnitBaseDirOf,
serviceUnitLogPathOf,
+ serviceUnitProgramOf,
SERVICE_ENV_FILE,
SERVICE_LAUNCHER_LOCK_FILE,
SERVICE_LAUNCHER_CONTEXT_ENV,
@@ -197,12 +200,10 @@ async function serviceUnitFromEnvironment(
};
}
-// systemd's ExecStart value, or the plist string or task argument before __service-launcher.
+// systemd's ExecStart value, or the task argument before __service-launcher.
// Only the version segment changes, so quoted or escaped paths stay as rendered.
-// The launch agent's PATH names a versions directory without the executable; leave it.
const SERVICE_UNIT_PROGRAMS = [
/^(ExecStart=.*?[\\/]runtime[\\/]versions[\\/])([^\\/\s"]+)([\\/]t3)(?=["\s])/m,
- /([^<]*?[\\/]runtime[\\/]versions[\\/])([^\\/<]+)([\\/]t3<\/string>\s*__service-launcher<\/string>)/,
/(--headless "[^"<]*?[\\/]runtime[\\/]versions[\\/])([^\\/"<]+)([\\/]t3\.exe" "__service-launcher")/,
] as const;
@@ -224,6 +225,33 @@ function repointServiceUnit(contents: string, version: string): string {
return contents;
}
+// The launch agent's PATH names a versions directory without the executable; leave it.
+const LAUNCH_AGENT_RUNTIME_PROGRAM =
+ /[^<]*?[\\/]runtime[\\/]versions[\\/]([^\\/<]+)[\\/]t3<\/string>(\s*__service-launcher<\/string>)/;
+
+/**
+ * A launch agent that names a versioned runtime, moved to the stable launcher. One naming a
+ * runtime newer than `version` is returned unchanged, like in `repointServiceUnit`.
+ */
+function moveLaunchAgentToStableLauncher(
+ contents: string,
+ version: string,
+ launcherEntryPath: string,
+): string {
+ const current = LAUNCH_AGENT_RUNTIME_PROGRAM.exec(contents)?.[1];
+ if (
+ current === undefined ||
+ (isExactServiceVersion(current) && compareExactServiceVersions(current, version) > 0)
+ ) {
+ return contents;
+ }
+ return contents.replace(
+ LAUNCH_AGENT_RUNTIME_PROGRAM,
+ (_program, _version, launcherArgument: string) =>
+ `${escapeXmlText(launcherEntryPath)}${launcherArgument}`,
+ );
+}
+
const SERVICE_LOG_MAX_BYTES = 10 * 1024 * 1024;
const SERVICE_LOG_CHECK_INTERVAL_MS = 10 * 60_000;
@@ -1277,8 +1305,37 @@ 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)
+ ) {
+ 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 moved = moveLaunchAgentToStableLauncher(contents, version, launcher.entryPath);
+ if (moved !== contents) {
+ await publishStableLauncher(
+ this.#baseDir,
+ runtimePaths(this.#baseDir, version).entryPath,
+ version,
+ );
+ await replaceFileDurably(unit.path, moved);
+ } 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 {
diff --git a/docs/internals/server-updates.md b/docs/internals/server-updates.md
index 10b196a72adf..abe26b283783 100644
--- a/docs/internals/server-updates.md
+++ b/docs/internals/server-updates.md
@@ -36,6 +36,15 @@ runtime that installed it. Until a deferred local upgrade restarts, systemd (bef
daemon-reload) and launchd (before bootstrap) can respawn the old launcher, which
rejects the state the new installer wrote.
+A launchd plist names a [copy](../../apps/server/src/cloud/stableLauncher.ts) of the
+selected runtime's executable at a fixed path instead of the runtime, 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. A launcher that finds a plist still naming a versioned runtime
+moves it to the copy, so services that only update remotely migrate too. When an older
+launcher committed the update, that happens once the new launcher starts, and launchd
+uses the copy from the bootstrap after that.
+
Task Scheduler tracks only the console host it started, not the launcher. `schtasks /End`
kills that host and nothing else; the launcher takes its closed console as SIGTERM. The task
reads as stopped at once, and `/Run` then starts a second launcher beside the first, so a
diff --git a/docs/user/background-service.md b/docs/user/background-service.md
index 1bc41addf8c9..21b1b202c55c 100644
--- a/docs/user/background-service.md
+++ b/docs/user/background-service.md
@@ -155,7 +155,10 @@ On macOS, check **System Settings → General → Login Items** if the service n
longer starts at login. If agent work cannot access Desktop, Documents, or
Downloads, it may need Full Disk Access for the `t3` executable listed in
`ProgramArguments` in
-`~/Library/LaunchAgents/com.t3tools.t3code.service.plist`.
+`~/Library/LaunchAgents/com.t3tools.t3code.service.plist`. That path stays the
+same across updates, so the grant carries over. A service installed by an
+older version moves to this path after an update and needs the grant once
+more. After a remote update, the move can take until the second login or restart.
For failures after signing in to T3 Connect, see
[connection troubleshooting](./t3-connect-troubleshooting.md).