fix(agent): native enroll + launchd install real-deploy shakedown

Fixes found deploying `pair --install` against the live control-plane:
- native enroll tolerates hostContentSecret:null and converts the base64-DER
  cert + caChain[] the CP returns into PEM for frpc/keystore
- launchd/systemd units use an absolute node (process.execPath) + a PATH with
  /usr/local/bin (bare `node` died with EX_CONFIG 78)
- the agent unit now carries ENROLL_URL/RELAY_URL/STATE_DIR/LOCAL_TARGET_URL
  (`run` loadConfig()-validates them; missing → supervisor exited 1)
281 tests pass. Follow-up: add unit tests for the native-enroll tolerance +
agent-unit env (only install node-path is covered so far).
This commit is contained in:
Yaojia Wang
2026-07-18 15:46:20 +02:00
parent 6e04eb0661
commit 9f7f5c0c54
4 changed files with 76 additions and 14 deletions

View File

@@ -11,6 +11,7 @@
* - FIX M-host-2service: base-app env (BIND_HOST/ALLOWED_ORIGINS/PORT/…) is routed to the
* base-app unit ONLY; the agent unit (which supervises frpc) never carries it.
*/
import { dirname } from 'node:path'
import type { AgentConfig } from '../config/agentConfig.js'
import {
agentLabel,
@@ -202,13 +203,31 @@ export async function installService(
// so nothing is ever written for a rejected install.
const baseAppEnv = resolveBaseAppEnv(cfg, options)
const bin = deps.binPath()
const baseAppExec = options.baseAppExec ?? DEFAULT_BASE_APP_EXEC
const rawExec = options.baseAppExec ?? DEFAULT_BASE_APP_EXEC
// launchd/systemd start with a minimal PATH that excludes /usr/local/bin (where a nvm/brew `node`
// symlink usually lives), so a bare `node` program dies with EX_CONFIG(78). Use the absolute node
// (process.execPath) and export a PATH so the units — and the base app's tmux/node-pty/frpc
// subprocesses — resolve their tools.
const nodePath = process.execPath
const unitPath = `${dirname(nodePath)}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin`
const baseAppExec = rawExec[0] === 'node' ? [nodePath, ...rawExec.slice(1)] : rawExec
const baseAppEnvWithPath = { ...baseAppEnv, PATH: unitPath }
// The agent unit runs `run`, which loadConfig()-validates ENROLL_URL(https)/RELAY_URL(wss) up front
// (fail-fast). Without these in the unit env the supervisor exits 1 on every launch — so inject the
// agent's own runtime config (NOT the base-app env) alongside PATH.
const agentEnv: Record<string, string> = {
PATH: unitPath,
ENROLL_URL: cfg.enrollUrl,
RELAY_URL: cfg.relayUrl,
STATE_DIR: cfg.stateDir,
LOCAL_TARGET_URL: cfg.localTargetUrl,
}
if (platform === 'launchd') {
const baseAppPath = launchdPlistPath(deps.homedir(), baseAppLabel())
deps.writeFile(baseAppPath, buildLaunchdPlist(baseAppExec, baseAppEnv, baseAppLabel()))
deps.writeFile(baseAppPath, buildLaunchdPlist(baseAppExec, baseAppEnvWithPath, baseAppLabel()))
const agentPath = launchdPlistPath(deps.homedir(), agentLabel())
deps.writeFile(agentPath, buildLaunchdPlist([bin, 'run'], {}, agentLabel()))
deps.writeFile(agentPath, buildLaunchdPlist([nodePath, bin, 'run'], agentEnv, agentLabel()))
for (const path of [baseAppPath, agentPath]) {
const { cmd, args } = launchdLoadCommand(path)
await deps.runCommand(cmd, args)
@@ -217,8 +236,8 @@ export async function installService(
}
const baseAppOptions: SystemdUnitOptions = options.envFile
? { env: baseAppEnv, envFile: options.envFile }
: { env: baseAppEnv }
? { env: baseAppEnvWithPath, envFile: options.envFile }
: { env: baseAppEnvWithPath }
const baseAppPath = systemdUnitPath(deps.homedir(), baseAppUnitName())
deps.writeFile(
baseAppPath,
@@ -227,7 +246,7 @@ export async function installService(
const agentPath = systemdUnitPath(deps.homedir(), agentUnitName())
deps.writeFile(
agentPath,
buildSystemdUnit(`${bin} run`, deps.username(), {}, 'web-terminal host agent (frpc supervisor)'),
buildSystemdUnit(`${nodePath} ${bin} run`, deps.username(), { env: agentEnv }, 'web-terminal host agent (frpc supervisor)'),
)
for (const unit of [baseAppUnitName(), agentUnitName()]) {
const { cmd, args } = systemdEnableCommand(unit)