/** * macOS launchd plist writer — PLAN_RELAY_AGENT T17. The service runs as the LOGGED-IN USER, not * root (EXPLORE §4d least privilege); no secrets in the plist (key/cert stay in the keystore). * * PLAN_NATIVE_TUNNEL S2: the plist can inject a caller-supplied per-host env map * (BIND_HOST, ALLOWED_ORIGINS, PORT, SHELL_PATH, IDLE_TTL, USE_TMUX, …) as a launchd * `EnvironmentVariables` block. Values are XML-escaped and keys are * sorted for deterministic, immutable output. * * PLAN_TUNNEL_AUTOMATION B5 (FIX M-host-2service): the writer is PARAMETERIZED on `label` + * `programArguments`, so a native-tunnel install emits TWO distinct plists — the base-app * (`node dist/server.js`, carrying the base-app env) and the agent (` run`, which supervises * frpc). Base-app env is routed to the base-app plist ONLY by the caller (`install.ts`). */ /** Shared env-map shape for the durable-service writers (launchd + systemd). Immutable. */ export type ServiceEnv = Readonly> /** The native-tunnel agent unit (supervises frpc). */ const AGENT_LABEL = 'com.web-terminal.agent' /** The base-app unit (`node dist/server.js`, loopback-bound). */ const BASE_APP_LABEL = 'com.web-terminal.base-app' export function agentLabel(): string { return AGENT_LABEL } export function baseAppLabel(): string { return BASE_APP_LABEL } /** Back-compat alias for the agent label. */ export function launchdLabel(): string { return AGENT_LABEL } export function launchdPlistPath(homedir: string, label: string = AGENT_LABEL): string { return `${homedir}/Library/LaunchAgents/${label}.plist` } /** Escape the five XML-significant characters so env keys/values/paths are plist-safe. */ function escapeXml(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } /** Build the `EnvironmentVariables` lines (empty array when env is empty). */ function environmentVariablesBlock(env: ServiceEnv): readonly string[] { const entries = Object.entries(env).sort(([a], [b]) => a.localeCompare(b)) if (entries.length === 0) return [] const lines = [' EnvironmentVariables', ' '] for (const [key, value] of entries) { lines.push(` ${escapeXml(key)}`) lines.push(` ${escapeXml(value)}`) } lines.push(' ') return lines } /** Build the `ProgramArguments` lines. */ function programArgumentsBlock(programArguments: readonly string[]): readonly string[] { const lines = [' ProgramArguments', ' '] for (const arg of programArguments) { lines.push(` ${escapeXml(arg)}`) } lines.push(' ') return lines } /** * Build a plist for `label` running `programArguments` (e.g. `[bin, 'run']` or `[node, serverJs]`); * RunAtLoad + KeepAlive (restart on failure). When `env` is non-empty, a launchd * `EnvironmentVariables` dict is injected. Pure/immutable — returns a fresh string. */ export function buildLaunchdPlist( programArguments: readonly string[], env: ServiceEnv = {}, label: string = AGENT_LABEL, logPath?: string, ): string { return [ '', '', '', '', ' Label', ` ${escapeXml(label)}`, ...programArgumentsBlock(programArguments), ' RunAtLoad', ' ', ' KeepAlive', ' ', // launchd's default has no log sink (unlike systemd's journald), so a crashing unit is silent. // Route stdout+stderr to a file so `pair --install` failures are diagnosable out of the box. ...(logPath !== undefined ? [ ' StandardOutPath', ` ${escapeXml(logPath)}`, ' StandardErrorPath', ` ${escapeXml(logPath)}`, ] : []), ...environmentVariablesBlock(env), '', '', '', ].join('\n') } export function launchdLoadCommand(plistPath: string): { cmd: string; args: readonly string[] } { return { cmd: 'launchctl', args: ['load', plistPath] } } export function launchdUnloadCommand(plistPath: string): { cmd: string; args: readonly string[] } { return { cmd: 'launchctl', args: ['unload', plistPath] } }