feat(agent): inject S0 env into launchd/systemd via install CLI (S2)

- launchd writer emits <EnvironmentVariables>; systemd writer emits EnvironmentFile/Environment;
  originConfig zone parameterized (default 'term' preserved for relay callers).
- InstallOptions threaded CLI install -> createCliDeps -> installService seam -> writers, so generated
  units carry BIND_HOST (defaults 127.0.0.1 for tunnel hosts), ALLOWED_ORIGINS, PORT, SHELL_PATH,
  IDLE_TTL, USE_TMUX. systemd rejects control chars in env values.
Verified: agent typecheck+build clean; vitest 166/166 (154 baseline + 12 new).
This commit is contained in:
Yaojia Wang
2026-07-07 09:42:12 +02:00
parent bb0949553c
commit d0c249c739
9 changed files with 475 additions and 28 deletions

View File

@@ -9,16 +9,69 @@ import {
launchdLoadCommand,
launchdPlistPath,
launchdUnloadCommand,
type ServiceEnv,
} from './launchd.js'
import {
buildSystemdUnit,
systemdDisableCommand,
systemdEnableCommand,
systemdUnitPath,
type SystemdUnitOptions,
} from './systemd.js'
import { DEFAULT_ORIGIN_ZONE, mergeOrigins, subdomainOrigin } from './originConfig.js'
export type ServicePlatform = 'launchd' | 'systemd'
/**
* Per-host packaging inputs threaded to the unit writers (PLAN_NATIVE_TUNNEL S2). All optional so
* existing callers (and relay callers) are unaffected — an empty object writes the historical unit.
*/
export interface InstallOptions {
/** S0 env injected into the supervised process (BIND_HOST, ALLOWED_ORIGINS, PORT, …). */
readonly env?: ServiceEnv
/** systemd `EnvironmentFile=` path (preferred over inline for values best kept off the unit). */
readonly envFile?: string
/** DNS zone label for the tunnel origin (`terminal` for native-tunnel hosts, default `term`). */
readonly zone?: string
/** Parent domain (e.g. `yaojia.wang`); with `cfg.subdomain` derives the tunnel ALLOWED_ORIGINS. */
readonly domain?: string
}
/** S0 base-app env vars the install CLI bakes into the per-host unit, passed through verbatim. */
const BASE_APP_ENV_KEYS = ['ALLOWED_ORIGINS', 'PORT', 'SHELL_PATH', 'IDLE_TTL', 'USE_TMUX'] as const
/**
* Tunnel hosts MUST bind loopback. At the relay the device-cert mTLS is the ONLY auth gate, so a
* default `0.0.0.0` bind (`src/config.ts`) would serve an unauth'd shell on the LAN, bypassing mTLS
* entirely (PLAN_NATIVE_TUNNEL S0/R2). `buildInstallOptions` therefore defaults `BIND_HOST` here.
*/
export const TUNNEL_DEFAULT_BIND_HOST = '127.0.0.1'
/** Native-tunnel origin zone → `https://<subdomain>.terminal.<domain>`; overridable via TUNNEL_ZONE. */
export const TUNNEL_ORIGIN_ZONE = 'terminal'
/**
* Resolve the per-host `InstallOptions` from the process environment (PLAN_NATIVE_TUNNEL S0/S2).
* Sources the S0 base-app env — defaulting `BIND_HOST` to loopback so a tunnel install can never
* emit a LAN-exposed unit — plus the tunnel-origin `domain`/`zone` used to derive ALLOWED_ORIGINS.
* Pure/immutable: `env` is a parameter, so this is unit-testable without touching real process state.
*/
export function buildInstallOptions(env: NodeJS.ProcessEnv): InstallOptions {
const passthrough = Object.fromEntries(
BASE_APP_ENV_KEYS.map((key) => [key, env[key]] as [string, string | undefined]).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].length > 0,
),
)
const serviceEnv: ServiceEnv = { BIND_HOST: env.BIND_HOST || TUNNEL_DEFAULT_BIND_HOST, ...passthrough }
const domain = env.TUNNEL_DOMAIN
const envFile = env.AGENT_ENV_FILE
return {
env: serviceEnv,
...(domain ? { domain, zone: env.TUNNEL_ZONE || TUNNEL_ORIGIN_ZONE } : {}),
...(envFile ? { envFile } : {}),
}
}
export class RootRefusedError extends Error {
constructor() {
super('refusing to install the agent service as root — run as the logged-in user (least privilege)')
@@ -42,23 +95,40 @@ export function detectPlatform(os: NodeJS.Platform): ServicePlatform | null {
return null
}
/**
* Resolve the env map injected into the unit. When a `domain` (+ `cfg.subdomain`) is supplied, the
* tunnel origin `https://<subdomain>.<zone>.<domain>` is merged into ALLOWED_ORIGINS so the base app
* trusts its own tunnel host — never weakening any origin the caller already provided. Immutable.
*/
function resolveEnv(cfg: AgentConfig, options: InstallOptions): ServiceEnv {
const base = options.env ?? {}
if (!options.domain || !cfg.subdomain) return base
const origin = subdomainOrigin(cfg.subdomain, options.domain, options.zone ?? DEFAULT_ORIGIN_ZONE)
return { ...base, ALLOWED_ORIGINS: mergeOrigins(base.ALLOWED_ORIGINS, origin) }
}
/** Write + load the service unit for `platform`. Throws RootRefusedError if running as root. */
export async function installService(
_cfg: AgentConfig,
cfg: AgentConfig,
platform: ServicePlatform,
deps: InstallDeps,
options: InstallOptions = {},
): Promise<void> {
if (deps.getuid() === 0) throw new RootRefusedError()
const bin = deps.binPath()
const env = resolveEnv(cfg, options)
if (platform === 'launchd') {
const path = launchdPlistPath(deps.homedir())
deps.writeFile(path, buildLaunchdPlist(bin))
deps.writeFile(path, buildLaunchdPlist(bin, env))
const { cmd, args } = launchdLoadCommand(path)
await deps.runCommand(cmd, args)
return
}
const path = systemdUnitPath(deps.homedir())
deps.writeFile(path, buildSystemdUnit(bin, deps.username()))
const systemdOptions: SystemdUnitOptions = options.envFile
? { env, envFile: options.envFile }
: { env }
deps.writeFile(path, buildSystemdUnit(bin, deps.username(), systemdOptions))
const { cmd, args } = systemdEnableCommand()
await deps.runCommand(cmd, args)
}