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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user