import { describe, expect, it } from 'vitest' import type { AgentConfig } from '../src/config/agentConfig.js' import { BindHostError, RootRefusedError, assertNativeZone, buildInstallOptions, detectPlatform, installService, normalizeBindHost, uninstallService, type InstallDeps, } from '../src/service/install.js' import { agentLabel, baseAppLabel, buildLaunchdPlist } from '../src/service/launchd.js' import { agentUnitName, baseAppUnitName, buildSystemdUnit } from '../src/service/systemd.js' const CFG: AgentConfig = { relayUrl: 'wss://relay/agent', enrollUrl: 'https://x/enroll', stateDir: '/tmp/x', localTargetUrl: 'ws://127.0.0.1:3000', subdomain: 'host-42', hostId: 'h-1', } type FakeDeps = InstallDeps & { writes: Array<[string, string]> runs: Array<[string, readonly string[]]> } function deps(uid = 501): FakeDeps { const writes: Array<[string, string]> = [] const runs: Array<[string, readonly string[]]> = [] return { writes, runs, writeFile: (p, c) => writes.push([p, c]), runCommand: async (cmd, args) => { runs.push([cmd, args]) }, getuid: () => uid, homedir: () => '/home/alice', username: () => 'alice', binPath: () => '/usr/local/bin/web-terminal-agent', } } /** The unit content whose path contains `needle` (e.g. `'base-app'`, `'agent'`). */ function unitWith(d: FakeDeps, needle: string): string { const hit = d.writes.find(([path]) => path.includes(needle)) if (!hit) throw new Error(`no unit written whose path contains '${needle}'`) return hit[1] } describe('detectPlatform (T17)', () => { it('maps darwin→launchd, linux→systemd, else null', () => { expect(detectPlatform('darwin')).toBe('launchd') expect(detectPlatform('linux')).toBe('systemd') expect(detectPlatform('win32')).toBeNull() }) }) describe('installService — least privilege (T17)', () => { it('refuses to install as root (negative, least privilege)', async () => { await expect(installService(CFG, 'systemd', deps(0))).rejects.toBeInstanceOf(RootRefusedError) }) it('root refusal emits nothing', async () => { const d = deps(0) await expect(installService(CFG, 'systemd', d)).rejects.toBeInstanceOf(RootRefusedError) expect(d.writes).toHaveLength(0) expect(d.runs).toHaveLength(0) }) }) describe('installService — two distinct units (FIX M-host-2service)', () => { it('systemd: writes a base-app unit AND an agent unit, both run-as-user, both enabled', async () => { const d = deps() await installService(CFG, 'systemd', d) // exactly two units expect(d.writes).toHaveLength(2) const baseApp = unitWith(d, baseAppUnitName()) const agent = unitWith(d, agentUnitName()) // agent unit supervises frpc via ` run` (absolute node so a minimal service PATH // that lacks /usr/local/bin can't fail with EX_CONFIG); base-app runs the node server (loopback) expect(agent).toContain('/usr/local/bin/web-terminal-agent run') expect(agent).toMatch(/ExecStart=\S*node\S* \/usr\/local\/bin\/web-terminal-agent run/) expect(baseApp).toContain('ExecStart=') expect(baseApp).toContain('server.js') expect(baseApp).not.toContain('web-terminal-agent run') // both least-privilege + restart-on-failure for (const unit of [baseApp, agent]) { expect(unit).toContain('User=alice') expect(unit).not.toContain('User=root') expect(unit).toContain('Restart=on-failure') } // both enabled expect(d.runs.every(([cmd]) => cmd === 'systemctl')).toBe(true) expect(d.runs).toHaveLength(2) }) it('launchd: writes a base-app plist AND an agent plist, both with KeepAlive, both loaded', async () => { const d = deps() await installService(CFG, 'launchd', d) expect(d.writes).toHaveLength(2) const baseApp = unitWith(d, baseAppLabel()) const agent = unitWith(d, agentLabel()) expect(agent).toContain('run') expect(baseApp).toContain('server.js') expect(baseApp).not.toContain('run') for (const plist of [baseApp, agent]) { expect(plist).toContain('KeepAlive') } expect(d.writes.every(([path]) => path.includes('LaunchAgents'))).toBe(true) expect(d.runs.every(([cmd]) => cmd === 'launchctl')).toBe(true) expect(d.runs).toHaveLength(2) }) it('routes base-app env to the base-app unit ONLY (never onto the agent unit)', async () => { const d = deps() await installService(CFG, 'systemd', d, { env: { BIND_HOST: '127.0.0.1', PORT: '3000' } }) const baseApp = unitWith(d, baseAppUnitName()) const agent = unitWith(d, agentUnitName()) expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"') expect(baseApp).toContain('Environment="PORT=3000"') // the agent unit must NOT carry the base-app env expect(agent).not.toContain('BIND_HOST') expect(agent).not.toContain('PORT=3000') }) it('uninstall tears down BOTH units (launchd unload)', async () => { const d = deps() await uninstallService('launchd', d) const targets = d.runs.map(([, args]) => args[args.length - 1]) expect(d.runs.every(([cmd]) => cmd === 'launchctl')).toBe(true) expect(targets.some((t) => t?.includes(baseAppLabel()))).toBe(true) expect(targets.some((t) => t?.includes(agentLabel()))).toBe(true) }) it('uninstall tears down BOTH units (systemd disable)', async () => { const d = deps() await uninstallService('systemd', d) const units = d.runs.map(([, args]) => args[args.length - 1]) expect(d.runs.every(([cmd]) => cmd === 'systemctl')).toBe(true) expect(units).toContain(baseAppUnitName()) expect(units).toContain(agentUnitName()) }) }) describe('BIND_HOST loopback S-GATE (FIX C-host-1, CRITICAL)', () => { it('normalizeBindHost defaults an absent value to loopback', () => { expect(normalizeBindHost(undefined)).toBe('127.0.0.1') expect(normalizeBindHost('')).toBe('127.0.0.1') }) it('normalizeBindHost accepts loopback forms (127.0.0.0/8, ::1, localhost)', () => { expect(normalizeBindHost('127.0.0.1')).toBe('127.0.0.1') expect(normalizeBindHost('127.0.0.2')).toBe('127.0.0.2') expect(normalizeBindHost('::1')).toBe('::1') expect(normalizeBindHost('localhost')).toBe('localhost') }) it('normalizeBindHost REJECTS 0.0.0.0 and other non-loopback values', () => { expect(() => normalizeBindHost('0.0.0.0')).toThrow(BindHostError) expect(() => normalizeBindHost('192.168.1.10')).toThrow(BindHostError) expect(() => normalizeBindHost('::')).toThrow(BindHostError) }) it('REGRESSION: rejects a suffixed hostname that merely starts with 127. (S-GATE bypass)', () => { // These are hostnames, not loopback literals — Node would DNS-resolve them before bind(). expect(() => normalizeBindHost('127.0.0.1.attacker.example.com')).toThrow(BindHostError) expect(() => normalizeBindHost('127.evil.net')).toThrow(BindHostError) expect(() => normalizeBindHost('127.0.0.1x')).toThrow(BindHostError) expect(() => buildInstallOptions({ BIND_HOST: '127.0.0.1.attacker.example.com' })).toThrow( BindHostError, ) }) it('buildInstallOptions throws on BIND_HOST=0.0.0.0 (fail-closed at env read)', () => { expect(() => buildInstallOptions({ BIND_HOST: '0.0.0.0' })).toThrow(BindHostError) }) it('NEGATIVE: installService with BIND_HOST=0.0.0.0 throws AND emits nothing', async () => { const d = deps() await expect( installService(CFG, 'systemd', d, { env: { BIND_HOST: '0.0.0.0', PORT: '3000' } }), ).rejects.toBeInstanceOf(BindHostError) expect(d.writes).toHaveLength(0) expect(d.runs).toHaveLength(0) }) it('NEGATIVE (launchd): a 0.0.0.0 install emits no plist', async () => { const d = deps() await expect( installService(CFG, 'launchd', d, { env: { BIND_HOST: '0.0.0.0' } }), ).rejects.toBeInstanceOf(BindHostError) expect(d.writes).toHaveLength(0) }) it('the emitted base-app unit can NEVER contain BIND_HOST=0.0.0.0 (normalized when absent)', async () => { const d = deps() await installService(CFG, 'systemd', d, { env: { PORT: '3000' } }) const baseApp = unitWith(d, baseAppUnitName()) expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"') expect(baseApp).not.toContain('0.0.0.0') }) }) describe('buildInstallOptions — env → InstallOptions (S0/S2 + S-GATE)', () => { it('defaults BIND_HOST to loopback so a tunnel install is never LAN-exposed (S0/R2)', () => { const options = buildInstallOptions({}) expect(options.env).toEqual({ BIND_HOST: '127.0.0.1' }) }) it('honours an explicit loopback BIND_HOST and passes through the S0 base-app env vars', () => { const options = buildInstallOptions({ BIND_HOST: '127.0.0.2', PORT: '3000', SHELL_PATH: '/bin/zsh', IDLE_TTL: '86400', USE_TMUX: '1', ALLOWED_ORIGINS: 'https://keep.me', SCROLLBACK_BYTES: '2097152', MAX_PAYLOAD_BYTES: '1048576', }) expect(options.env).toEqual({ BIND_HOST: '127.0.0.2', PORT: '3000', SHELL_PATH: '/bin/zsh', IDLE_TTL: '86400', USE_TMUX: '1', ALLOWED_ORIGINS: 'https://keep.me', SCROLLBACK_BYTES: '2097152', MAX_PAYLOAD_BYTES: '1048576', }) }) it('AG3: passes SCROLLBACK_BYTES and MAX_PAYLOAD_BYTES through as base-app config', () => { const options = buildInstallOptions({ SCROLLBACK_BYTES: '2097152', MAX_PAYLOAD_BYTES: '1048576' }) expect(options.env).toEqual({ BIND_HOST: '127.0.0.1', SCROLLBACK_BYTES: '2097152', MAX_PAYLOAD_BYTES: '1048576', }) }) it('omits unset/empty passthrough vars', () => { const options = buildInstallOptions({ PORT: '', SHELL_PATH: '/bin/bash' }) expect(options.env).toEqual({ BIND_HOST: '127.0.0.1', SHELL_PATH: '/bin/bash' }) }) it('derives domain + default `terminal` zone from TUNNEL_DOMAIN', () => { const options = buildInstallOptions({ TUNNEL_DOMAIN: 'yaojia.wang' }) expect(options.domain).toBe('yaojia.wang') expect(options.zone).toBe('terminal') }) it('lets TUNNEL_ZONE override the origin zone and carries AGENT_ENV_FILE through', () => { const options = buildInstallOptions({ TUNNEL_DOMAIN: 'yaojia.wang', TUNNEL_ZONE: 'term', AGENT_ENV_FILE: '/etc/wt.env', }) expect(options.zone).toBe('term') expect(options.envFile).toBe('/etc/wt.env') }) it('omits domain/zone when no TUNNEL_DOMAIN is set', () => { const options = buildInstallOptions({}) expect(options.domain).toBeUndefined() expect(options.zone).toBeUndefined() }) }) describe('tunnel-origin derivation into the base-app unit (FIX L-host-zone)', () => { it('assertNativeZone accepts `terminal` and rejects `term`/undefined', () => { expect(() => assertNativeZone('terminal')).not.toThrow() expect(() => assertNativeZone('term')).toThrow(/terminal/) expect(() => assertNativeZone(undefined)).toThrow(/terminal/) }) it('merges https://.terminal. into the base-app ALLOWED_ORIGINS', async () => { const d = deps() await installService(CFG, 'launchd', d, { domain: 'yaojia.wang', zone: 'terminal' }) const baseApp = unitWith(d, baseAppLabel()) expect(baseApp).toContain('ALLOWED_ORIGINS') expect(baseApp).toContain('https://host-42.terminal.yaojia.wang') }) it('preserves a caller-provided ALLOWED_ORIGINS and appends the tunnel origin', async () => { const d = deps() await installService(CFG, 'systemd', d, { env: { ALLOWED_ORIGINS: 'https://keep.me' }, domain: 'yaojia.wang', zone: 'terminal', }) const baseApp = unitWith(d, baseAppUnitName()) expect(baseApp).toContain('https://keep.me,https://host-42.terminal.yaojia.wang') }) it('does not derive an origin when the config has no subdomain', async () => { const d = deps() await installService({ ...CFG, subdomain: null }, 'launchd', d, { domain: 'yaojia.wang', zone: 'terminal', }) const baseApp = unitWith(d, baseAppLabel()) expect(baseApp).not.toContain('ALLOWED_ORIGINS') }) }) describe('install CLI seam end-to-end — resolved env reaches the base-app unit (S2)', () => { const ENV = { PORT: '3000', SHELL_PATH: '/bin/zsh', TUNNEL_DOMAIN: 'yaojia.wang' } as const it('launchd: the base-app plist carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => { const d = deps() await installService(CFG, 'launchd', d, buildInstallOptions(ENV)) const baseApp = unitWith(d, baseAppLabel()) expect(baseApp).toContain('BIND_HOST') expect(baseApp).toContain('127.0.0.1') expect(baseApp).toContain('https://host-42.terminal.yaojia.wang') expect(baseApp).toContain('PORT') expect(baseApp).not.toContain('0.0.0.0') }) it('systemd: the base-app unit carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => { const d = deps() await installService(CFG, 'systemd', d, buildInstallOptions(ENV)) const baseApp = unitWith(d, baseAppUnitName()) expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"') expect(baseApp).toContain('Environment="ALLOWED_ORIGINS=https://host-42.terminal.yaojia.wang"') expect(baseApp).toContain('Environment="PORT=3000"') expect(baseApp).not.toContain('0.0.0.0') }) it('systemd: emits EnvironmentFile= (before inline Environment) on the base-app unit', async () => { const d = deps() await installService(CFG, 'systemd', d, { env: { BIND_HOST: '127.0.0.1', PORT: '3000' }, envFile: '/etc/web-terminal.env', }) const baseApp = unitWith(d, baseAppUnitName()) expect(baseApp).toContain('EnvironmentFile=/etc/web-terminal.env') expect(baseApp.indexOf('EnvironmentFile=')).toBeLessThan(baseApp.indexOf('Environment=')) }) }) describe('unit writers — escaping & control-char hardening', () => { it('launchd: escapes XML-significant characters in env values', () => { const plist = buildLaunchdPlist(['/bin/agent', 'run'], { X: `a&bd"e'f` }) expect(plist).toContain('a&b<c>d"e'f') expect(plist).not.toContain('a&bd') }) it('launchd: injects a sorted, XML-escaped EnvironmentVariables dict', () => { const plist = buildLaunchdPlist(['/bin/agent', 'run'], { BIND_HOST: '127.0.0.1', ALLOWED_ORIGINS: 'https://a', PORT: '3000', }) expect(plist).toContain('EnvironmentVariables') expect(plist.indexOf('ALLOWED_ORIGINS')).toBeLessThan(plist.indexOf('BIND_HOST')) expect(plist.indexOf('BIND_HOST')).toBeLessThan(plist.indexOf('>PORT<')) }) it('launchd: no env → no EnvironmentVariables block', () => { const plist = buildLaunchdPlist(['/bin/agent', 'run']) expect(plist).not.toContain('EnvironmentVariables') }) it('systemd: escapes backslash and double-quote in Environment values', () => { const unit = buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a"b\\c' } }) expect(unit).toContain('Environment="X=a\\"b\\\\c"') }) it('systemd: rejects a newline in an env value (no [Service] directive injection)', () => { expect(() => buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a\nExecStartPre=/x' } }), ).toThrow(/control character/) }) it('systemd: rejects a carriage return in an env value', () => { expect(() => buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a\rb' } })).toThrow( /control character/, ) }) it('AG2: rejects a newline in the ExecStart command (no [Service] directive injection)', () => { expect(() => buildSystemdUnit('/bin/agent run\nExecStartPre=/x', 'alice'), ).toThrow(/control character/) }) it('AG2: rejects a newline in the User field', () => { expect(() => buildSystemdUnit('/bin/agent run', 'alice\nExecStartPre=/x')).toThrow( /control character/, ) }) it('AG2: rejects a newline in the Description field', () => { expect(() => buildSystemdUnit('/bin/agent run', 'alice', {}, 'desc\n[Service]\nExecStartPre=/x'), ).toThrow(/control character/) }) it('AG2: rejects a newline in the EnvironmentFile path', () => { expect(() => buildSystemdUnit('/bin/agent run', 'alice', { envFile: '/etc/x.env\nExecStartPre=/y' }), ).toThrow(/control character/) }) it('AG2: rejects a newline in an Environment KEY (not just the value)', () => { expect(() => buildSystemdUnit('/bin/agent run', 'alice', { env: { 'X\nExecStartPre=/y': 'v' } }), ).toThrow(/control character/) }) it('systemd: default (no env) omits Environment lines', () => { const unit = buildSystemdUnit('/bin/agent run', 'alice') expect(unit).not.toContain('Environment') }) })