diff --git a/apps/server/src/cloud/serviceProtocol.ts b/apps/server/src/cloud/serviceProtocol.ts index 2d32a996ee2c..1d6c65c02c9f 100644 --- a/apps/server/src/cloud/serviceProtocol.ts +++ b/apps/server/src/cloud/serviceProtocol.ts @@ -14,6 +14,8 @@ export const SERVICE_STOP_MARKER_FILE = ".service-stopping"; it when it starts (whoever restarted the service), so while it exists the service is known to be behind its unit and status reports it that way. */ export const SERVICE_RESTART_PENDING_FILE = ".restart-pending"; +/** Optional KEY=VALUE file under T3 home. The service launcher merges it at start. */ +export const SERVICE_ENV_FILE = "service.env"; export interface PendingServiceUpdate { readonly id: string; diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index df2ab9e9cd06..958b948d2f26 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -4,11 +4,19 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { Launcher, readServiceState, writeServiceState } from "./serviceLauncher.ts"; +import { + applyServiceEnvFile, + Launcher, + parseServiceEnvFile, + readServiceEnvFile, + readServiceState, + writeServiceState, +} from "./serviceLauncher.ts"; import { compareExactServiceVersions, decodeServiceState, isExactServiceVersion, + SERVICE_ENV_FILE, SERVICE_LAUNCHER_PROTOCOL, SERVICE_RESTART_PENDING_FILE, SERVICE_STOP_MARKER_FILE, @@ -33,6 +41,41 @@ it("orders exact semantic versions without treating build metadata as precedence assert.equal(compareExactServiceVersions("2.0.0+one", "2.0.0+two"), 0); }); +it("parses KEY=VALUE assignments from the T3 home service env file", () => { + assert.deepEqual( + parseServiceEnvFile( + [ + "# Bitbucket credentials", + "export T3CODE_BITBUCKET_EMAIL=you@example.com", + 'T3CODE_BITBUCKET_API_TOKEN="token with spaces"', + "T3CODE_PORT=1234", + "T3CODE_HOST=0.0.0.0", + "NOTE=A & ", + "EMPTY=", + "PATH=/should-not-override", + "T3CODE_HOME=/should-not-override", + "T3_BOOT_SERVICE_UNIT=should-not-override", + "T3_SERVICE_LAUNCHER_CONTEXT=should-not-override", + "Path=/should-not-override-case", + "t3code_home=/should-not-override-case", + "T3_boot_service_unit=should-not-override-case", + "t3_service_launcher_context=should-not-override-case", + "123BAD=x", + "INVALID NAME=x", + "", + ].join("\n"), + ), + { + T3CODE_BITBUCKET_EMAIL: "you@example.com", + T3CODE_BITBUCKET_API_TOKEN: "token with spaces", + T3CODE_PORT: "1234", + T3CODE_HOST: "0.0.0.0", + NOTE: "A & ", + EMPTY: "", + }, + ); +}); + it("rejects contradictory service state", () => { assert.isUndefined( decodeServiceState({ @@ -98,6 +141,99 @@ const writeFakeRuntime = ( }); it.layer(NodeServices.layer)("service state persistence", (it) => { + it.effect("merges T3 home service.env into a provided environment and into the child", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-env-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + const seenPath = path.join(root, "seen-env.json"); + yield* fs.writeFileString( + path.join(root, SERVICE_ENV_FILE), + [ + "T3CODE_BITBUCKET_EMAIL=you@example.com", + 'T3CODE_BITBUCKET_API_TOKEN="token with spaces"', + "T3CODE_PORT=1234", + "T3CODE_HOME=/should-not-override", + "PATH=/should-not-override", + "Path=/should-not-override-case", + "t3code_home=/should-not-override-case", + "", + ].join("\n"), + ); + + assert.deepEqual( + yield* Effect.promise(() => readServiceEnvFile(path.join(root, "missing"))), + {}, + ); + const env: NodeJS.ProcessEnv = { + PATH: "/bin", + T3CODE_HOME: root, + T3CODE_PORT: "old", + }; + const serviceEnv = yield* Effect.promise(() => applyServiceEnvFile(root, env)); + assert.equal(env.T3CODE_BITBUCKET_EMAIL, "you@example.com"); + assert.equal(env.T3CODE_BITBUCKET_API_TOKEN, "token with spaces"); + assert.equal(env.T3CODE_PORT, "1234"); + assert.equal(env.T3CODE_HOME, root); + assert.equal(env.PATH, "/bin"); + assert.isUndefined(serviceEnv.Path); + assert.isUndefined(serviceEnv.t3code_home); + assert.isUndefined(env.Path); + assert.isUndefined(env.t3code_home); + // Later file edits wait for a service restart; children reuse the startup map. + yield* fs.writeFileString(path.join(root, SERVICE_ENV_FILE), "T3CODE_PORT=9999\n"); + + const encodedSeenPath = JSON.stringify(seenPath); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", "1.0.0"), + `import { writeFileSync } from "node:fs"; +writeFileSync(${encodedSeenPath}, JSON.stringify({ + email: process.env.T3CODE_BITBUCKET_EMAIL, + token: process.env.T3CODE_BITBUCKET_API_TOKEN, + port: process.env.T3CODE_PORT, + home: process.env.T3CODE_HOME, + path: process.env.PATH, +})); +process.exit(0); +`, + ); + yield* Effect.promise(() => + writeServiceState(statePath, { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + }), + ); + + const launcher = new Launcher( + root, + yield* Effect.promise(() => readServiceState(statePath)), + serviceEnv, + ); + yield* Effect.promise(() => + launcher.run().then( + () => Promise.reject(new Error("launcher unexpectedly completed")), + () => Promise.resolve(), + ), + ); + + const seen = JSON.parse(yield* fs.readFileString(seenPath)) as { + email: string; + token: string; + port: string; + home: string | undefined; + path: string | undefined; + }; + assert.equal(seen.email, "you@example.com"); + assert.equal(seen.token, "token with spaces"); + assert.equal(seen.port, "1234"); + assert.notEqual(seen.home, "/should-not-override"); + assert.notEqual(seen.path, "/should-not-override"); + }), + ); + it.effect("durably replaces and strictly reads one state document", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index cfdd3d75b57e..a1f9a78b01da 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -9,6 +9,7 @@ import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; import type { PendingServiceUpdate, @@ -23,6 +24,7 @@ import { decodeServiceLauncherChildMessage, isExactServiceVersion, parseServiceState, + SERVICE_ENV_FILE, SERVICE_LAUNCHER_CONTEXT_ENV, SERVICE_LAUNCHER_PROTOCOL, SERVICE_STATE_FILE, @@ -34,6 +36,13 @@ const HANDOFF_DELAY_MS = 2_000; const PREPARED_TIMEOUT_MS = 120_000; const TERMINATE_GRACE_MS = 5_000; +const SERVICE_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; +const MANAGED_SERVICE_ENV_NAMES = new Set( + ["PATH", "T3CODE_HOME", "T3_BOOT_SERVICE_UNIT", SERVICE_LAUNCHER_CONTEXT_ENV].map((name) => + name.toUpperCase(), + ), +); + type TerminalStatus = "committed" | "rolled-back" | "failed"; type ChildRole = "active" | "trial"; @@ -82,6 +91,48 @@ async function pathExists(target: string): Promise { } } +/** + * Reads KEY=VALUE assignments from the documented T3 home env file. Managed + * names the unit already owns are ignored, regardless of key case, so a + * user cannot redirect the service by writing PATH or T3CODE_HOME here. + */ +export function parseServiceEnvFile(contents: string): Record { + const parsed = NodeUtil.parseEnv(contents); + const env: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if ( + value === undefined || + !SERVICE_ENV_NAME.test(key) || + MANAGED_SERVICE_ENV_NAMES.has(key.toUpperCase()) + ) { + continue; + } + env[key] = value; + } + return env; +} + +export async function readServiceEnvFile(baseDir: string): Promise> { + try { + return parseServiceEnvFile( + await NodeFSP.readFile(NodePath.join(baseDir, SERVICE_ENV_FILE), "utf8"), + ); + } catch (cause) { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") return {}; + throw cause; + } +} + +/** Merge `service.env` into a process environment. Missing file is a no-op. */ +export async function applyServiceEnvFile( + baseDir: string, + env: NodeJS.ProcessEnv = process.env, +): Promise> { + const serviceEnv = await readServiceEnvFile(baseDir); + Object.assign(env, serviceEnv); + return serviceEnv; +} + // Opened read-write: Windows refuses to flush a handle without write access. async function syncFile(filePath: string): Promise { const handle = await NodeFSP.open(filePath, "r+"); @@ -280,6 +331,8 @@ const restartPendingPath = (baseDir: string) => export class Launcher { readonly #baseDir: string; readonly #statePath: string; + // Startup snapshot from service.env. Later file edits wait for a service restart. + readonly #serviceEnv: Record; #state: ServiceState; #child: ManagedChild | null = null; #timer: NodeJS.Timeout | undefined; @@ -289,10 +342,11 @@ export class Launcher { #done = false; readonly #completion = Promise.withResolvers(); - constructor(baseDir: string, state: ServiceState) { + constructor(baseDir: string, state: ServiceState, serviceEnv: Record = {}) { this.#baseDir = baseDir; this.#statePath = NodePath.join(baseDir, "runtime", SERVICE_STATE_FILE); this.#state = state; + this.#serviceEnv = serviceEnv; } async run(): Promise { @@ -427,7 +481,11 @@ export class Launcher { }; const spawnArguments = runtimeSpawnArguments(paths); const child = NodeChildProcess.spawn(spawnArguments.command, spawnArguments.args, { - env: { ...process.env, [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify(context) }, + env: { + ...process.env, + ...this.#serviceEnv, + [SERVICE_LAUNCHER_CONTEXT_ENV]: JSON.stringify(context), + }, stdio: ["inherit", "inherit", "inherit", "ipc"], }); await new Promise((resolve, reject) => { @@ -631,7 +689,8 @@ export async function main(): Promise { if (baseDir === undefined || baseDir === "") { throw new Error("T3CODE_HOME is required by the T3 Code service launcher."); } + const serviceEnv = await applyServiceEnvFile(baseDir); const statePath = NodePath.join(baseDir, "runtime", SERVICE_STATE_FILE); const state = await readServiceState(statePath); - await new Launcher(baseDir, state).run(); + await new Launcher(baseDir, state, serviceEnv).run(); } diff --git a/docs/user/background-service.md b/docs/user/background-service.md index 9964fba54f74..4ed9804f4a02 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -20,6 +20,16 @@ Uninstalling the service leaves your projects, threads, and settings intact. Running `t3 service install` again repairs a service that `t3 service status` reports as broken. +Extra environment for the service belongs in `service.env` under T3 home +(`~/.t3/service.env` by default). The service launcher merges that file when it +starts, so Bitbucket credentials and `T3CODE_PORT` / `T3CODE_HOST` pins survive +`t3 update` and `t3 service install`. Restart the service after editing it: + +```sh +T3CODE_BITBUCKET_EMAIL=you@example.com +T3CODE_BITBUCKET_API_TOKEN=your-token +``` + `t3 update` downloads the newest release on your channel and switches `t3` and the service to it. Restarting interrupts running agent turns, terminals, and remote clients, so it asks first; answer no and the service keeps running diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 344f4c97917d..ea0540e6622c 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -68,7 +68,9 @@ configure it. Saved tokens can't be viewed again; enter a new one to replace it, **Remove**. If no credentials are saved, T3 Code falls back to these variables in the server's environment. -Restart the server after changing them: +Restart the server after changing them. If T3 Code runs as a background service, put them in +`service.env` under T3 home (`~/.t3/service.env` by default) instead of a shell export or the +service unit. See [Running T3 Code in the background](./background-service.md). ```bash export T3CODE_BITBUCKET_ACCESS_TOKEN="your-access-token" @@ -170,7 +172,7 @@ does not show its diff, so marks are made and read on web and desktop. - **Not authenticated:** run the provider's login command on the server, then rescan. For Bitbucket, check the credentials saved in Settings → Source Control, or confirm the running server received - the environment variables. + the environment variables, or `service.env` under T3 home if it runs as a background service. - **GitHub sign-in cannot be verified:** update GitHub CLI to at least 2.81.0, or save a token in Settings → Source Control. - **Push fails despite a connected account:** check the Git remote's credentials. SSH and HTTPS remotes can require separate setup from the hosting provider's API access.