From 1a33f483849f61371b2ee39ba3802f4b77aeb0af Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 01:56:09 -0700 Subject: [PATCH] fix(server): `t3 service protect` can ask for your sudo password Effect starts child processes detached on POSIX, so sudo ran in a new session with no controlling terminal and failed with "a terminal is required to authenticate". Protection only worked with passwordless sudo. Spawn sudo attached. Also say what protect does before listing drop-ins. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/service.ts | 10 +++++++++- apps/server/src/cloud/serviceProtection.test.ts | 13 +++++++++---- apps/server/src/cloud/serviceProtection.ts | 3 +++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index 62edb31d14b62..884cd28c9df87 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -314,6 +314,14 @@ const serviceProtectCommand = Command.make("protect", { ); } + yield* Console.log( + [ + "Keeps T3 Code running when agents fill this machine's memory:", + " - reserves memory for the T3 Code server, so the kernel takes memory from agents first", + " - makes the out-of-memory killer pick agents before the server", + "It adds systemd drop-in files and changes nothing else. Undo with `t3 service protect --undo`.", + ].join("\n"), + ); const names = remote.map((unit) => unit.replace(/\.service$/, "")).join(" and "); const protectRemote = remote.length > 0 && @@ -328,7 +336,7 @@ const serviceProtectCommand = Command.make("protect", { }); yield* Console.log( [ - "As root, through sudo:", + "Drop-ins it writes, as root through sudo:", ...formatDropIns(plan.system), "As you:", ...formatDropIns([plan.managerDefaults, ...plan.user]), diff --git a/apps/server/src/cloud/serviceProtection.test.ts b/apps/server/src/cloud/serviceProtection.test.ts index b2e4a1506cb31..eef78cd3f82e7 100644 --- a/apps/server/src/cloud/serviceProtection.test.ts +++ b/apps/server/src/cloud/serviceProtection.test.ts @@ -11,6 +11,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import type * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as ProcessRunner from "../processRunner.ts"; @@ -122,13 +123,13 @@ it.each([ expect(ServiceProtection.loadedRemoteAccessUnits(shown)).toEqual(expected); }); -const fakeSudo = (exitCode: number, onRun: () => void) => +const fakeSudo = (exitCode: number, onRun: (command: ChildProcess.StandardCommand) => void) => ChildProcessSpawner.make((command) => Effect.gen(function* () { if (command._tag !== "StandardCommand" || command.command !== "sudo") { return yield* Effect.die("Unexpected command"); } - onRun(); + onRun(command); return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(123), stdout: Stream.empty, @@ -180,11 +181,13 @@ it.effect("lowers no score before the manager default is in place, and undoes in writesManagerDropIn = false, ) => { let atSudo: ReturnType | undefined; + let sudoOptions: ChildProcess.CommandOptions | undefined; return effect.pipe( Effect.provideService( ChildProcessSpawner.ChildProcessSpawner, - fakeSudo(exitCode, () => { + fakeSudo(exitCode, (command) => { atSudo = present(); + sudoOptions = command.options; if (writesManagerDropIn) { NodeFS.mkdirSync(NodePath.dirname(managerDropIn), { recursive: true }); NodeFS.writeFileSync(managerDropIn, "[Service]\nOOMScoreAdjust=-900\n"); @@ -192,7 +195,7 @@ it.effect("lowers no score before the manager default is in place, and undoes in }), ), Effect.exit, - Effect.map((exit) => ({ exit, atSudo })), + Effect.map((exit) => ({ exit, atSudo, sudoOptions })), ); }; const refused = yield* withSudo(ServiceProtection.applyProtection(plan), 1); @@ -209,6 +212,8 @@ it.effect("lowers no score before the manager default is in place, and undoes in const applied = yield* withSudo(ServiceProtection.applyProtection(plan), 0); expect(applied.atSudo).toEqual({ managerDefaults: true, user: 0 }); + // sudo needs the controlling terminal to ask for a password. + expect(applied.sudoOptions).toMatchObject({ detached: false, stdin: "inherit" }); expect(present()).toEqual({ managerDefaults: true, user: plan.user.length }); const removed = yield* withSudo(ServiceProtection.removeProtection(plan), 0); diff --git a/apps/server/src/cloud/serviceProtection.ts b/apps/server/src/cloud/serviceProtection.ts index b23cd36e3a545..780aee83b4c48 100644 --- a/apps/server/src/cloud/serviceProtection.ts +++ b/apps/server/src/cloud/serviceProtection.ts @@ -240,7 +240,10 @@ const runPrivileged = Effect.fn("ServiceProtection.runPrivileged")(function* (sc const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const exitCode = yield* spawner .exitCode( + // Effect detaches children on POSIX by default; sudo then has no controlling + // terminal and cannot ask for a password. ChildProcess.make("sudo", ["/bin/sh", "-c", script], { + detached: false, stdin: "inherit", stdout: "inherit", stderr: "inherit",