Files
backnotprop__plannotator/packages/server/tailscale-serve.ts
Michael Ramos ed6f44bf2e fix(release): tailscale gate exit codes and lease gating, conditional SIGHUP, informative guide validation error (#1286)
- annotate --tailscale publish failures now exit through
  annotateStartupFailureExitCode: exit 2 under a strict gate
  (--require-approval / --result-file), where exit 1 is reserved for "the
  reviewer did not approve, decision record published". Non-strict annotate
  and review keep the documented exit 1.
- the annotate client lease (auto-dismiss on abandonment) is forced off
  while tailnetPublished is set: --tailscale reads as local to the CLI
  predicate, but clients connect through the serve proxy, and a proxy
  disconnect longer than the grace would dismiss a live review. Same
  rationale as remote/shared sessions; decided at the single point both
  the /api/plan advert and the SSE endpoint read.
- the SIGHUP-to-process.exit route moved from an unconditional CLI-entry
  listener into enableTailscaleServe's success path, installed only once a
  serve mapping exists. Any SIGHUP listener overrides the ignored
  disposition nohup depends on, so plain sessions now keep zero listeners
  and "nohup plannotator review &" survives terminal close again;
  --tailscale sessions still tear their mapping down on HUP (exit 129).
- validateGuideOutput explains a fully-invalidated guide whose refs named
  files outside the changeset (count plus up to 3 example paths, with a
  pointer to the Commits panel) instead of the bare generic message; the
  generic message stays for genuinely structural emptiness. The informative
  error now flows through onJobComplete to the job failure card; Pi picks
  the change up via the vendored guide-review copy.
2026-08-12 14:04:59 -07:00

171 lines
6.5 KiB
TypeScript

/**
* `--tailscale` session publishing (Bun CLI only).
*
* The server stays LOOPBACK-bound — this is not remote mode. `tailscale
* serve` reverse-proxies an HTTPS tailnet port to 127.0.0.1, so the session
* is reachable from the user's other tailnet devices with TLS while nothing
* listens beyond loopback (and nothing is ever exposed publicly — this is
* serve, never funnel).
*
* Invariants:
* - One serve mapping per session port. A pre-existing mapping on our port
* (background OR foreground — Tailscale prefers foreground handlers)
* aborts with an actionable error instead of being stolen; mappings on
* other ports are never touched, and unrecognizable `serve status`
* output fails closed.
* - Mappings this process creates are torn down on normal completion and
* on SIGINT/SIGTERM/SIGHUP (the CLI entry routes SIGINT/SIGTERM through
* process.exit, which fires "exit" handlers; SIGHUP is routed by THIS
* module, installed only once a mapping exists — an unconditional SIGHUP
* listener would override the ignored disposition `nohup` depends on and
* kill plain nohup'd sessions on terminal close). Teardown failures retry
* once, then warn with the exact manual command; a port is only
* forgotten after a successful off. `--bg` mappings survive a hard kill
* (SIGKILL) or reboot — that is Tailscale's persistence model, and the
* warning names the cleanup command for that case too.
*/
import {
buildServeArgs,
buildServeOffArgs,
checkServeStatusPort,
describeTailscaleFailure,
extractServeHttpsUrl,
runTailscale,
TAILSCALE_SERVE_TIMEOUT_MS,
type TailscaleRunner,
} from "@plannotator/shared/tailscale";
const activePorts = new Set<number>();
let exitCleanupInstalled = false;
let cleanupRunner: TailscaleRunner = runTailscale;
/** One off attempt plus one retry; true only when the CLI reported success. */
function runServeOff(port: number, run: TailscaleRunner): boolean {
for (let attempt = 0; attempt < 2; attempt++) {
try {
const result = run(buildServeOffArgs(port), TAILSCALE_SERVE_TIMEOUT_MS);
if (!result.error && result.status === 0) return true;
} catch {
// Fall through to the retry / warning.
}
}
return false;
}
function warnLeakedMapping(port: number): void {
process.stderr.write(
`[plannotator] Warning: could not remove the tailscale serve mapping for port ${port}. ` +
`Remove it manually with: tailscale serve --https=${port} off\n`,
);
}
function cleanupAllServeMappings(): void {
for (const port of [...activePorts]) {
if (runServeOff(port, cleanupRunner)) {
activePorts.delete(port);
} else {
warnLeakedMapping(port);
}
}
}
function onProcessExit(): void {
cleanupAllServeMappings();
}
/** SIGHUP (terminal close) routed through process.exit so the "exit" handler
* above tears the serve mapping down. 129 = 128 + SIGHUP. */
function onSigHup(): void {
process.exit(129);
}
/**
* Publish a loopback-bound session port over the tailnet. Returns the HTTPS
* URL tailscale advertises. Throws with an actionable message when the CLI
* is missing, the daemon is down/logged out, the port already has a serve
* mapping (background or foreground), the status output is unrecognizable,
* or the serve output carries no https URL for the requested port.
*/
export function enableTailscaleServe(
port: number,
run: TailscaleRunner = runTailscale,
): { url: string } {
const status = run(["serve", "status", "--json"], TAILSCALE_SERVE_TIMEOUT_MS);
if (status.error || status.status !== 0) {
throw new Error(`--tailscale: ${describeTailscaleFailure(status)}`);
}
const portCheck = checkServeStatusPort(status.stdout, port);
if (portCheck === "malformed") {
throw new Error(
"--tailscale: could not parse `tailscale serve status --json` output; " +
"refusing to modify the serve config. Inspect it with `tailscale serve status`.",
);
}
if (portCheck === "conflict") {
throw new Error(
`--tailscale: tailscale serve already routes port ${port} (background or foreground session). ` +
`Clear it with \`tailscale serve --https=${port} off\` if it is stale, ` +
`or set PLANNOTATOR_PORT to a free port.`,
);
}
const serve = run(buildServeArgs(port), TAILSCALE_SERVE_TIMEOUT_MS);
if (serve.error || serve.status !== 0) {
throw new Error(`--tailscale: could not start tailscale serve. ${describeTailscaleFailure(serve)}`);
}
const url = extractServeHttpsUrl(`${serve.stdout}\n${serve.stderr}`, port);
if (!url) {
// The mapping may exist even though we could not read a URL for our
// port; take our own port back down rather than leak it.
if (!runServeOff(port, run)) warnLeakedMapping(port);
throw new Error(
`--tailscale: could not find an https:// URL for port ${port} in \`tailscale serve\` output.`,
);
}
activePorts.add(port);
cleanupRunner = run;
if (!exitCleanupInstalled) {
exitCleanupInstalled = true;
process.on("exit", onProcessExit);
// SIGHUP is routed through process.exit ONLY once a mapping exists.
// Installing any SIGHUP listener overrides the ignored disposition
// `nohup` relies on, so sessions without a serve mapping must never gain
// one — `nohup plannotator review &` has to keep surviving terminal
// close. With a mapping, terminal close must run the exit cleanup above
// or the `--bg` mapping leaks.
process.once("SIGHUP", onSigHup);
}
return { url };
}
/**
* Tear down one mapping this process created. No-op for unknown ports. The
* port is only forgotten after a successful off; a persistent failure warns
* with the manual command and leaves the port registered so the exit-time
* cleanup retries it.
*/
export function disableTailscaleServe(port: number, run: TailscaleRunner = runTailscale): void {
if (!activePorts.has(port)) return;
if (runServeOff(port, run)) {
activePorts.delete(port);
} else {
warnLeakedMapping(port);
}
}
/**
* Test-only: restore module state (registered ports, exit listener, cleanup
* runner) so a test that published mappings through an injected runner cannot
* leak an exit-time cleanup against the real CLI. Bun runs every test file in
* one process; call from afterEach/finally.
*/
export function resetTailscaleServeForTests(): void {
activePorts.clear();
cleanupRunner = runTailscale;
if (exitCleanupInstalled) {
exitCleanupInstalled = false;
process.removeListener("exit", onProcessExit);
process.removeListener("SIGHUP", onSigHup);
}
}