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

@@ -1,9 +1,27 @@
/**
* Linux systemd unit writer — PLAN_RELAY_AGENT T17. Runs as the LOGGED-IN USER (never root,
* EXPLORE §4d), restart-on-failure; no secrets in the unit (key/cert stay in the keystore).
*
* PLAN_NATIVE_TUNNEL S2: the unit can now inject the per-host tunnel env via an
* `EnvironmentFile=` line (preferred — keeps values out of the world-readable unit) and/or
* inline `Environment=` lines from a caller-supplied env map. Inline `Environment=` is emitted
* after `EnvironmentFile=` so an explicit value overrides the file on conflict.
*/
import type { ServiceEnv } from './launchd.js'
const UNIT_NAME = 'web-terminal-agent.service'
/** DEL (0x7F) and everything below the printable ASCII range are rejected in env values. */
const FIRST_PRINTABLE_ASCII = 0x20
const DEL_CODE = 0x7f
export interface SystemdUnitOptions {
/** Inline env map → one `Environment="KEY=value"` line each (keys sorted, values escaped). */
readonly env?: ServiceEnv
/** Path referenced by a single `EnvironmentFile=` line (preferred over inline for secrets). */
readonly envFile?: string
}
export function systemdUnitName(): string {
return UNIT_NAME
}
@@ -12,8 +30,53 @@ export function systemdUnitPath(homedir: string): string {
return `${homedir}/.config/systemd/user/${UNIT_NAME}`
}
/** Build the unit. ExecStart = `<bin> run`; Restart=on-failure; User=<user> (never root). */
export function buildSystemdUnit(binPath: string, user: string): string {
/**
* True if `value` contains any control character (C0 range below 0x20, or DEL 0x7F). A raw
* newline/CR would terminate the single `Environment=` line and let the remainder inject arbitrary
* directives into the `[Service]` section — so such values are rejected rather than escaped.
*/
function hasControlChar(value: string): boolean {
for (const char of value) {
const code = char.codePointAt(0)
if (code === undefined) continue
if (code < FIRST_PRINTABLE_ASCII || code === DEL_CODE) return true
}
return false
}
/** Quote a systemd `Environment=` value: reject control chars, then double-quote escaping `\` and `"`. */
function quoteEnvAssignment(key: string, value: string): string {
if (hasControlChar(value)) {
throw new Error(
`refusing to write systemd Environment for '${key}': value contains a control character ` +
'(newline/CR/etc.) that could corrupt the [Service] section',
)
}
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
return `"${key}=${escaped}"`
}
/** Build the `EnvironmentFile=` / `Environment=` lines (empty array when neither is supplied). */
function environmentLines(options: SystemdUnitOptions): readonly string[] {
const lines: string[] = []
if (options.envFile) lines.push(`EnvironmentFile=${options.envFile}`)
const entries = Object.entries(options.env ?? {}).sort(([a], [b]) => a.localeCompare(b))
for (const [key, value] of entries) {
lines.push(`Environment=${quoteEnvAssignment(key, value)}`)
}
return lines
}
/**
* Build the unit. ExecStart = `<bin> run`; Restart=on-failure; User=<user> (never root). When
* `options.env`/`options.envFile` are supplied, the per-host tunnel env is injected into the
* `[Service]` section. Pure/immutable — returns a fresh string.
*/
export function buildSystemdUnit(
binPath: string,
user: string,
options: SystemdUnitOptions = {},
): string {
return [
'[Unit]',
'Description=web-terminal host agent (rendezvous relay)',
@@ -25,6 +88,7 @@ export function buildSystemdUnit(binPath: string, user: string): string {
'Restart=on-failure',
'RestartSec=1',
`User=${user}`,
...environmentLines(options),
'',
'[Install]',
'WantedBy=default.target',