diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 841aa78f6c879..b87d1eeddac4c 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -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[] = []; @@ -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, @@ -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"', ); @@ -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) => @@ -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)), @@ -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( @@ -1050,13 +1053,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. @@ -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* () { diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index e7de29ad8953d..21ed27534b934 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -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, @@ -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 && @@ -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, @@ -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({ + try: () => + publishStableLauncher(input.baseDir, runtimePaths.entryPath, input.cliVersion), + catch: (cause) => new BootServiceInstallError({ cause }), + }).pipe(Effect.uninterruptible); + } yield* writeDurably(unitPath, manager.render(plan)); }), ), @@ -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, + }) + : Effect.void, + ), + Effect.mapError((cause) => new BootServiceInstallError({ cause })), + Effect.uninterruptible, + ), + ); yield* runSteps(manager.finalize); return true; }).pipe(Effect.withSpan("cloud.boot_service.uninstall")); @@ -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; @@ -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, diff --git a/apps/server/src/cloud/stableLauncher.ts b/apps/server/src/cloud/stableLauncher.ts new file mode 100644 index 0000000000000..7fde72b134226 --- /dev/null +++ b/apps/server/src/cloud/stableLauncher.ts @@ -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((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 }); + } +} + +// The unit is written durably right after this, so the program it names must be too. +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 3304211d9e7c6..f021afdf61281 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -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, @@ -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[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 e6d3431f39b3b..06fa91f321afc 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, @@ -23,6 +24,7 @@ import { isExactServiceVersion, parseServiceState, serviceUnitBaseDirOf, + serviceUnitProgramOf, SERVICE_ENV_FILE, SERVICE_LAUNCHER_CONTEXT_ENV, SERVICE_LAUNCHER_PROTOCOL, @@ -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 { diff --git a/docs/internals/server-updates.md b/docs/internals/server-updates.md index e0f1d286a1608..a0fe27d8bbc35 100644 --- a/docs/internals/server-updates.md +++ b/docs/internals/server-updates.md @@ -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 diff --git a/docs/user/background-service.md b/docs/user/background-service.md index 51a16878d2bbc..b086df34cac16 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -154,7 +154,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).