Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server/src/cloud/serviceProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
138 changes: 137 additions & 1 deletion apps/server/src/serviceLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 & <B>",
"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 & <B>",
EMPTY: "",
},
);
});

it("rejects contradictory service state", () => {
assert.isUndefined(
decodeServiceState({
Expand Down Expand Up @@ -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;
Expand Down
65 changes: 62 additions & 3 deletions apps/server/src/serviceLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,6 +24,7 @@ import {
decodeServiceLauncherChildMessage,
isExactServiceVersion,
parseServiceState,
SERVICE_ENV_FILE,
SERVICE_LAUNCHER_CONTEXT_ENV,
SERVICE_LAUNCHER_PROTOCOL,
SERVICE_STATE_FILE,
Expand All @@ -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";

Expand Down Expand Up @@ -82,6 +91,48 @@ async function pathExists(target: string): Promise<boolean> {
}
}

/**
* 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<string, string> {
const parsed = NodeUtil.parseEnv(contents);
const env: Record<string, string> = {};
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<Record<string, string>> {
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<Record<string, string>> {
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<void> {
const handle = await NodeFSP.open(filePath, "r+");
Expand Down Expand Up @@ -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<string, string>;
#state: ServiceState;
#child: ManagedChild | null = null;
#timer: NodeJS.Timeout | undefined;
Expand All @@ -289,10 +342,11 @@ export class Launcher {
#done = false;
readonly #completion = Promise.withResolvers<void>();

constructor(baseDir: string, state: ServiceState) {
constructor(baseDir: string, state: ServiceState, serviceEnv: Record<string, string> = {}) {
this.#baseDir = baseDir;
this.#statePath = NodePath.join(baseDir, "runtime", SERVICE_STATE_FILE);
this.#state = state;
this.#serviceEnv = serviceEnv;
}

async run(): Promise<void> {
Expand Down Expand Up @@ -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<void>((resolve, reject) => {
Expand Down Expand Up @@ -631,7 +689,8 @@ export async function main(): Promise<void> {
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();
}
10 changes: 10 additions & 0 deletions docs/user/background-service.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions docs/user/source-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down
Loading