- 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).
270 lines
10 KiB
TypeScript
270 lines
10 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import type { AgentConfig } from '../src/config/agentConfig.js'
|
|
import {
|
|
RootRefusedError,
|
|
buildInstallOptions,
|
|
detectPlatform,
|
|
installService,
|
|
uninstallService,
|
|
type InstallDeps,
|
|
} from '../src/service/install.js'
|
|
import { buildLaunchdPlist } from '../src/service/launchd.js'
|
|
import { 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',
|
|
}
|
|
|
|
function deps(uid = 501): InstallDeps & { writes: Array<[string, string]>; runs: Array<[string, readonly string[]]> } {
|
|
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',
|
|
}
|
|
}
|
|
|
|
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 (T17)', () => {
|
|
it('refuses to install as root (negative, least privilege)', async () => {
|
|
await expect(installService(CFG, 'systemd', deps(0))).rejects.toBeInstanceOf(RootRefusedError)
|
|
})
|
|
|
|
it('systemd: writes a run-as-user unit and enables it', async () => {
|
|
const d = deps()
|
|
await installService(CFG, 'systemd', d)
|
|
const [, unit] = d.writes[0]!
|
|
expect(unit).toContain('ExecStart=/usr/local/bin/web-terminal-agent run')
|
|
expect(unit).toContain('User=alice')
|
|
expect(unit).not.toContain('User=root')
|
|
expect(unit).toContain('Restart=on-failure')
|
|
expect(d.runs[0]![0]).toBe('systemctl')
|
|
})
|
|
|
|
it('launchd: writes a plist with ProgramArguments run + KeepAlive', async () => {
|
|
const d = deps()
|
|
await installService(CFG, 'launchd', d)
|
|
const [path, plist] = d.writes[0]!
|
|
expect(path).toContain('LaunchAgents')
|
|
expect(plist).toContain('<string>run</string>')
|
|
expect(plist).toContain('<key>KeepAlive</key>')
|
|
expect(d.runs[0]![0]).toBe('launchctl')
|
|
})
|
|
|
|
it('uninstall unloads cleanly', async () => {
|
|
const d = deps()
|
|
await uninstallService('launchd', d)
|
|
expect(d.runs[0]).toEqual(['launchctl', ['unload', '/home/alice/Library/LaunchAgents/com.web-terminal.agent.plist']])
|
|
})
|
|
})
|
|
|
|
const TUNNEL_ENV = {
|
|
BIND_HOST: '127.0.0.1',
|
|
ALLOWED_ORIGINS: 'https://t1.terminal.yaojia.wang',
|
|
PORT: '3000',
|
|
} as const
|
|
|
|
describe('env injection into the writers (PLAN_NATIVE_TUNNEL S2)', () => {
|
|
it('launchd: default (no options) omits the EnvironmentVariables block', async () => {
|
|
const d = deps()
|
|
await installService(CFG, 'launchd', d)
|
|
const [, plist] = d.writes[0]!
|
|
expect(plist).not.toContain('EnvironmentVariables')
|
|
})
|
|
|
|
it('launchd: injects a sorted, XML-escaped EnvironmentVariables dict', async () => {
|
|
const d = deps()
|
|
await installService(CFG, 'launchd', d, { env: TUNNEL_ENV })
|
|
const [, plist] = d.writes[0]!
|
|
expect(plist).toContain('<key>EnvironmentVariables</key>')
|
|
expect(plist).toContain('<key>BIND_HOST</key>')
|
|
expect(plist).toContain('<string>127.0.0.1</string>')
|
|
// keys are sorted (ALLOWED_ORIGINS before BIND_HOST before PORT)
|
|
expect(plist.indexOf('ALLOWED_ORIGINS')).toBeLessThan(plist.indexOf('BIND_HOST'))
|
|
expect(plist.indexOf('BIND_HOST')).toBeLessThan(plist.indexOf('>PORT<'))
|
|
})
|
|
|
|
it('launchd: escapes XML-significant characters in env values', () => {
|
|
const plist = buildLaunchdPlist('/bin/agent', { X: `a&b<c>d"e'f` })
|
|
expect(plist).toContain('<string>a&b<c>d"e'f</string>')
|
|
expect(plist).not.toContain('a&b<c>d')
|
|
})
|
|
|
|
it('systemd: default (no options) omits Environment lines', async () => {
|
|
const d = deps()
|
|
await installService(CFG, 'systemd', d)
|
|
const [, unit] = d.writes[0]!
|
|
expect(unit).not.toContain('Environment')
|
|
})
|
|
|
|
it('systemd: emits sorted, quoted Environment= lines from the env map', async () => {
|
|
const d = deps()
|
|
await installService(CFG, 'systemd', d, { env: TUNNEL_ENV })
|
|
const [, unit] = d.writes[0]!
|
|
expect(unit).toContain('Environment="BIND_HOST=127.0.0.1"')
|
|
expect(unit).toContain('Environment="PORT=3000"')
|
|
expect(unit.indexOf('ALLOWED_ORIGINS')).toBeLessThan(unit.indexOf('BIND_HOST'))
|
|
})
|
|
|
|
it('systemd: emits EnvironmentFile= (before inline Environment) when a path is given', async () => {
|
|
const d = deps()
|
|
await installService(CFG, 'systemd', d, { env: TUNNEL_ENV, envFile: '/etc/web-terminal.env' })
|
|
const [, unit] = d.writes[0]!
|
|
expect(unit).toContain('EnvironmentFile=/etc/web-terminal.env')
|
|
expect(unit.indexOf('EnvironmentFile=')).toBeLessThan(unit.indexOf('Environment='))
|
|
})
|
|
|
|
it('systemd: escapes backslash and double-quote in Environment values', () => {
|
|
const unit = buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a"b\\c' } })
|
|
expect(unit).toContain('Environment="X=a\\"b\\\\c"')
|
|
})
|
|
})
|
|
|
|
describe('tunnel-origin derivation (PLAN_NATIVE_TUNNEL S2)', () => {
|
|
it('merges https://<subdomain>.<zone>.<domain> into ALLOWED_ORIGINS when domain is given', async () => {
|
|
const d = deps()
|
|
await installService(CFG, 'launchd', d, { domain: 'yaojia.wang', zone: 'terminal' })
|
|
const [, plist] = d.writes[0]!
|
|
expect(plist).toContain('<key>ALLOWED_ORIGINS</key>')
|
|
expect(plist).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
|
|
})
|
|
|
|
it('defaults to the `term` zone when only a domain is supplied', async () => {
|
|
const d = deps()
|
|
await installService(CFG, 'launchd', d, { domain: 'yaojia.wang' })
|
|
const [, plist] = d.writes[0]!
|
|
expect(plist).toContain('<string>https://host-42.term.yaojia.wang</string>')
|
|
})
|
|
|
|
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 [, unit] = d.writes[0]!
|
|
expect(unit).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' })
|
|
const [, plist] = d.writes[0]!
|
|
expect(plist).not.toContain('ALLOWED_ORIGINS')
|
|
})
|
|
})
|
|
|
|
describe('buildInstallOptions — env → InstallOptions (PLAN_NATIVE_TUNNEL S0/S2)', () => {
|
|
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 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',
|
|
})
|
|
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',
|
|
})
|
|
})
|
|
|
|
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('install CLI seam end-to-end — resolved env reaches the units (PLAN_NATIVE_TUNNEL S2)', () => {
|
|
// Env the operator would export before `web-terminal-agent install` on a tunnel host.
|
|
const ENV = { PORT: '3000', SHELL_PATH: '/bin/zsh', TUNNEL_DOMAIN: 'yaojia.wang' } as const
|
|
|
|
it('launchd: the plist carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
|
|
const d = deps()
|
|
await installService(CFG, 'launchd', d, buildInstallOptions(ENV))
|
|
const [, plist] = d.writes[0]!
|
|
expect(plist).toContain('<key>EnvironmentVariables</key>')
|
|
expect(plist).toContain('<key>BIND_HOST</key>')
|
|
expect(plist).toContain('<string>127.0.0.1</string>')
|
|
expect(plist).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
|
|
expect(plist).toContain('<key>PORT</key>')
|
|
expect(plist).not.toContain('0.0.0.0')
|
|
})
|
|
|
|
it('systemd: the unit carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
|
|
const d = deps()
|
|
await installService(CFG, 'systemd', d, buildInstallOptions(ENV))
|
|
const [, unit] = d.writes[0]!
|
|
expect(unit).toContain('Environment="BIND_HOST=127.0.0.1"')
|
|
expect(unit).toContain('Environment="ALLOWED_ORIGINS=https://host-42.terminal.yaojia.wang"')
|
|
expect(unit).toContain('Environment="PORT=3000"')
|
|
expect(unit).not.toContain('0.0.0.0')
|
|
})
|
|
})
|
|
|
|
describe('systemd env value hardening (LOW: control-char injection)', () => {
|
|
it('rejects a newline in an env value so it cannot inject a [Service] directive', () => {
|
|
expect(() => buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a\nExecStartPre=/x' } })).toThrow(
|
|
/control character/,
|
|
)
|
|
})
|
|
|
|
it('rejects a carriage return in an env value', () => {
|
|
expect(() => buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a\rb' } })).toThrow(/control character/)
|
|
})
|
|
|
|
it('still accepts ordinary values with quotes and backslashes', () => {
|
|
const unit = buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a"b\\c' } })
|
|
expect(unit).toContain('Environment="X=a\\"b\\\\c"')
|
|
})
|
|
})
|