From d9f0b6f494685f4cedab5ab71658160664a45094 Mon Sep 17 00:00:00 2001 From: Frank Mayer Date: Thu, 12 Nov 2026 13:53:36 +0100 Subject: [PATCH 1/4] fix(server): macOS service keeps its privacy grants across updates The launch agent now starts a copy of the selected runtime's t3 at the fixed path runtime/service/t3, so TCC grants keyed to that path survive updates. Fixes #17225 --- apps/server/src/cloud/bootService.test.ts | 44 +++++++++++++-- apps/server/src/cloud/bootService.ts | 51 +++++++++++++---- apps/server/src/cloud/stableLauncher.ts | 68 +++++++++++++++++++++++ apps/server/src/serviceLauncher.test.ts | 20 +++++++ apps/server/src/serviceLauncher.ts | 25 ++++++++- docs/internals/server-updates.md | 6 ++ docs/user/background-service.md | 5 +- 7 files changed, 200 insertions(+), 19 deletions(-) create mode 100644 apps/server/src/cloud/stableLauncher.ts diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index c9f7d1a77c3d..53be7039b41d 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -165,7 +165,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[] = []; @@ -300,7 +300,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, @@ -862,6 +862,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"', ); @@ -880,7 +882,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) => @@ -902,7 +904,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)), @@ -1326,8 +1328,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", "service"); const plan = yield* service.install(); expect( @@ -1343,13 +1346,18 @@ 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", }); + 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. @@ -1473,6 +1481,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 1f5bc0c0d355..28a75c1a7d89 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, @@ -941,6 +942,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, @@ -965,7 +968,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, @@ -1364,6 +1367,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); @@ -1497,9 +1507,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, + }) + : Effect.void, + ), + Effect.mapError((cause) => new BootServiceInstallError({ cause })), + Effect.uninterruptible, + ), + ); yield* runSteps(manager.finalize); return true; }).pipe(Effect.withSpan("cloud.boot_service.uninstall")); @@ -1512,10 +1534,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; @@ -1545,9 +1577,8 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { current: problems.length === 0 && 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/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 2ff0a42cb441..e108cc353bbe 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, @@ -752,6 +753,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[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", () => diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index e7c02fe00326..10c49e196959 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, @@ -27,6 +28,7 @@ import { serviceTaskIsDisabled, serviceUnitBaseDirOf, serviceUnitLogPathOf, + serviceUnitProgramOf, SERVICE_ENV_FILE, SERVICE_LAUNCHER_LOCK_FILE, SERVICE_LAUNCHER_CONTEXT_ENV, @@ -1176,8 +1178,27 @@ 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 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..aba00fecb27b 100644 --- a/docs/internals/server-updates.md +++ b/docs/internals/server-updates.md @@ -36,6 +36,12 @@ 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. + 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 28a68e0dfe4a..76df2d316379 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 the next time you run `t3 update` or +`t3 service install` on the host, and needs the grant once more. For failures after signing in to T3 Connect, see [connection troubleshooting](./t3-connect-troubleshooting.md). From 192e6fe2d9a6b3efccf5d0d1614cb23862363c43 Mon Sep 17 00:00:00 2001 From: Frank Mayer Date: Thu, 12 Nov 2026 14:07:12 +0100 Subject: [PATCH 2/4] fix(server): uninstall removes the launcher of the home the plist serves --- apps/server/src/cloud/bootService.test.ts | 6 ++++-- apps/server/src/cloud/bootService.ts | 13 +++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 53be7039b41d..464574b12e7b 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -1328,7 +1328,8 @@ process.disconnect(); it.effect("installs, reports current state, and uninstalls on macOS", () => Effect.gen(function* () { - const { service, fs, baseDir, statePath, commands, timeouts } = 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(); @@ -1356,7 +1357,8 @@ process.disconnect(); expect((yield* service.status).current).toBe(false); yield* service.install(); expect((yield* service.status).current).toBe(true); - expect(yield* service.uninstall).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); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 28a75c1a7d89..c0204537eac0 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -1509,13 +1509,14 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { yield* runSteps(manager.deactivate); yield* withSelectionLock( unitPath, - fs.remove(unitPath).pipe( - Effect.andThen( + fs.readFileString(unitPath).pipe( + Effect.tap(() => fs.remove(unitPath)), + Effect.flatMap((unit) => manager.kind === "launchd" - ? fs.remove(stableLauncherPaths(input.baseDir).directory, { - recursive: true, - force: true, - }) + ? fs.remove( + stableLauncherPaths(bootServiceBaseDirOf(unit) ?? input.baseDir).directory, + { recursive: true, force: true }, + ) : Effect.void, ), Effect.mapError((cause) => new BootServiceInstallError({ cause })), From 0d38019c3c12a984e5d984e6272f52e56c3b0d3a Mon Sep 17 00:00:00 2001 From: Frank Mayer Date: Sat, 14 Nov 2026 02:20:09 +0100 Subject: [PATCH 3/4] fix(server): launcher moves legacy macOS launch agents to the stable launcher A service installed before the stable launcher only moved on a local t3 update or service install, so hosts updated remotely kept a versioned program path and kept re-prompting for privacy grants. --- apps/server/src/cloud/bootService.ts | 5 +- apps/server/src/cloud/serviceProtocol.ts | 3 + apps/server/src/serviceLauncher.test.ts | 76 ++++++++++++++++-------- apps/server/src/serviceLauncher.ts | 46 ++++++++++++-- docs/internals/server-updates.md | 3 +- docs/user/background-service.md | 4 +- 6 files changed, 100 insertions(+), 37 deletions(-) diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 24ecedad4791..b8e1a2d6d38c 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -40,6 +40,7 @@ import { SERVICE_UNIT_ENV, compareExactServiceVersions, disabledServiceTask, + escapeXmlText, serviceTaskIsDisabled, serviceUnitBaseDirOf as bootServiceBaseDirOf, serviceUnitEnvironmentValueOf, @@ -145,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, 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/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index 04337d9214f3..f426c310cedd 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -284,10 +284,13 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { 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: "" }); + 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 }); - yield* fs.writeFileString(unitPath, render("1.0.0")); + yield* fs.writeFileString(unitPath, render(`${root}/runtime/versions/1.0.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( @@ -315,7 +318,7 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { 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)); }), ); @@ -1047,15 +1050,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, @@ -1077,22 +1071,54 @@ 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("publishes the committed runtime to a launch agent's stable launcher", () => - Effect.gen(function* () { - const render = (plan: Parameters[0]) => - renderBootServicePlist( - { ...plan, program: [stableLauncherPaths(plan.baseDir).entryPath, "__service-launcher"] }, - { homeDir: "/Users/test", environmentPath: "/usr/bin" }, + 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")), ); - 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.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(launcher.entryPath), - yield* fs.readFileString(`${root}/runtime/versions/1.1.0/t3`), + yield* fs.readFileString(unit.path), + launchAgent("newer runtime")(unitPlan(root, "1.1.0")), ); + assert.isFalse(yield* fs.exists(stableLauncherPaths(root).entryPath)); }), ); diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index 5b4ee725b39f..9cf154f6405d 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -21,6 +21,7 @@ import type { import { compareExactServiceVersions, decodeServiceLauncherChildMessage, + escapeXmlText, isExactServiceVersion, parseLegacyServiceState, disabledServiceTask, @@ -199,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; @@ -226,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; @@ -1297,8 +1323,18 @@ export class Launcher { ); } } else { - const repointed = repointServiceUnit(contents, version); - if (repointed !== contents) await replaceFileDurably(unit.path, repointed); + 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(); diff --git a/docs/internals/server-updates.md b/docs/internals/server-updates.md index aba00fecb27b..c95b5db67cce 100644 --- a/docs/internals/server-updates.md +++ b/docs/internals/server-updates.md @@ -40,7 +40,8 @@ A launchd plist names a [copy](../../apps/server/src/cloud/stableLauncher.ts) of 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. +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. 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 diff --git a/docs/user/background-service.md b/docs/user/background-service.md index f668fbbfda1b..50c828116b7f 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -157,8 +157,8 @@ Downloads, it may need Full Disk Access for the `t3` executable listed in `ProgramArguments` in `~/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 the next time you run `t3 update` or -`t3 service install` on the host, and needs the grant once more. +older version moves to this path after an update and needs the grant once +more. After a remote update, the move takes effect at the next login or restart. For failures after signing in to T3 Connect, see [connection troubleshooting](./t3-connect-troubleshooting.md). From f00e99a4444173e1a9bf7063853c22008f91e005 Mon Sep 17 00:00:00 2001 From: Frank Mayer Date: Sat, 14 Nov 2026 02:26:26 +0100 Subject: [PATCH 4/4] test(server): cover a launch agent repointed by an older launcher An older launcher commits the remote update and points the plist at the new versioned runtime; the new launcher must still move it on start. --- apps/server/src/serviceLauncher.test.ts | 88 ++++++++++++++----------- docs/internals/server-updates.md | 4 +- docs/user/background-service.md | 2 +- 3 files changed, 52 insertions(+), 42 deletions(-) diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index f426c310cedd..5efc83b4317d 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -277,49 +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 = (program: string) => - renderBootServicePlist( - { ...unitPlan(root, "1.0.0"), program: [program, "__service-launcher"] }, - { homeDir: home, environmentPath: "" }, + // 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* fs.makeDirectory(path.dirname(unitPath), { recursive: true }); - yield* fs.writeFileString(unitPath, render(`${root}/runtime/versions/1.0.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(() => + 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(stableLauncherPaths(root).entryPath)); - }), + assert.equal( + yield* fs.readFileString(unitPath), + render(stableLauncherPaths(root).entryPath), + ); + assert.equal(yield* fs.readFileString(stableLauncherPaths(root).sentinelPath), "1.1.0\n"); + }), ); it.effect( diff --git a/docs/internals/server-updates.md b/docs/internals/server-updates.md index c95b5db67cce..abe26b283783 100644 --- a/docs/internals/server-updates.md +++ b/docs/internals/server-updates.md @@ -41,7 +41,9 @@ selected runtime's executable at a fixed path instead of the runtime, because TC 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. +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 diff --git a/docs/user/background-service.md b/docs/user/background-service.md index 50c828116b7f..21b1b202c55c 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -158,7 +158,7 @@ Downloads, it may need Full Disk Access for the `t3` executable listed in `~/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 takes effect at the next login or restart. +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).