fix(cli): preserve user-explicit-vs-default host signal through lifecycle

Guard review on cafcfa5 caught a real bug: the CLI daemon-start
lifecycle always exported `OPENRIG_HOST=127.0.0.1` to the daemon
process even when the operator never set it (config default
propagated through `startDaemon` → `buildDaemonEnv`). The daemon's
`index.ts` saw `OPENRIG_HOST` set and took the explicit-host branch,
single-binding loopback only — the multi-bind tailnet auto-detect
path never ran on a default product launch.

Fix: thread the user-explicit signal through the lifecycle.

- `packages/cli/src/commands/daemon.ts`: probe ConfigStore for the
  host setting's source (`resolveWithSource("daemon.host")`). When the
  source is `default` AND no `--host` flag, pass `host: undefined` to
  `startDaemon`. When source is `env`, `file`, or a CLI flag is
  present, pass the resolved host (operator opt-in path).
- `packages/cli/src/daemon-lifecycle.ts`:
  * `StartOptions.host` was already optional; `startDaemon` now
    threads `explicitHost = opts.host` (may be undefined) into
    `buildDaemonEnv` and uses `probeHost = explicitHost ?? DEFAULT_HOST`
    for the healthz probe + persisted state.host.
  * `buildDaemonEnv` makes `opts.host` optional in its signature and
    only sets `env["OPENRIG_HOST"]` when defined. If the operator's
    shell already has `OPENRIG_HOST` in baseEnv, the env-passthrough
    loop preserves that value (shell-level opt-in is honored).

After this fix, the three input paths each correctly signal the
daemon:
  - `OPENRIG_HOST` set in shell → preserved through baseEnv →
    daemon explicit path.
  - `rig config set daemon.host X` → resolveWithSource source=`file`
    → CLI exports `OPENRIG_HOST=X` → daemon explicit path.
  - No env, no config, no `--host` → source=`default` → CLI does NOT
    export `OPENRIG_HOST` → daemon falls through to
    multi-bind (loopback + tailscale auto-detect).

Tests:
- 3 new cases in daemon-lifecycle.test.ts asserting
  `buildDaemonEnv` omits/sets/respects-shell-OPENRIG_HOST correctly.
- daemon-lifecycle.test.ts: 52/52 green (was 49).
- Full sweep (env vars cleaned): 4222/4223 — the one failure is
  environmental (`packages/cli/dist/index.js` missing because the
  worktree is unbuilt; unrelated to this change).
- daemon tsc + cli tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-11 03:49:12 -07:00
1 parent cafcfa5e41
commit f84161289c
3 files changed
+82 -9

No files matched your search

+8 -1
View File
@@ -85,7 +85,14 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
const configStore = new ConfigStore();
const config = configStore.resolve();
const effectivePort = opts.port ? parseInt(opts.port, 10) : config.daemon.port;
// bug-fix slice auth-bearer-tailscale-trust: distinguish
// user-explicit from default-fallback so the daemon can
// multi-bind (loopback + tailscale auto-detect) when the operator
// never opted in to a specific host.
const hostResolution = configStore.resolveWithSource("daemon.host");
const hostUserExplicit = opts.host !== undefined || hostResolution.source !== "default";
const effectiveHost = opts.host ?? config.daemon.host;
const hostForDaemon = hostUserExplicit ? effectiveHost : undefined;
// Run preflight before starting
const preflight = new SystemPreflight({
@@ -108,7 +115,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
const state = await startDaemon(
{
port: effectivePort,
host: effectiveHost,
host: hostForDaemon,
db: opts.db ?? config.db.path,
transcriptsEnabled: config.transcripts.enabled,
transcriptsPath: config.transcripts.path,
+31 -8
View File
@@ -242,7 +242,17 @@ export function buildDaemonEnv(
baseEnv: Record<string, string>,
opts: {
port: number;
host: string;
/**
* Operator-explicit bind host. When undefined the CLI is signaling
* "no operator opt-in" so the daemon's index.ts can fall through to
* the default loopback+tailscale-auto multi-bind path (bug-fix slice
* auth-bearer-tailscale-trust). When defined, the daemon takes the
* explicit-host branch and applies the bearer invariant to it.
* If undefined here AND the operator shell already has OPENRIG_HOST
* set in baseEnv, the env-passthrough loop preserves that value
* (their shell-level opt-in still wins).
*/
host?: string;
db: string;
transcriptsEnabled?: boolean;
transcriptsPath?: string;
@@ -263,7 +273,9 @@ export function buildDaemonEnv(
// Explicit OPENRIG_* overrides — always win over inherited values
env["OPENRIG_PORT"] = String(opts.port);
env["OPENRIG_HOST"] = opts.host;
if (opts.host !== undefined) {
env["OPENRIG_HOST"] = opts.host;
}
env["OPENRIG_DB"] = opts.db;
if (opts.transcriptsEnabled !== undefined) {
env["OPENRIG_TRANSCRIPTS_ENABLED"] = String(opts.transcriptsEnabled);
@@ -283,8 +295,15 @@ export function buildDaemonEnv(
export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Promise<DaemonState> {
const port = opts.port ?? DEFAULT_PORT;
const host = opts.host ?? DEFAULT_HOST;
const db = opts.db ?? DEFAULT_DB;
// bug-fix slice auth-bearer-tailscale-trust: preserve the
// user-explicit-vs-default distinction. `opts.host` is undefined when
// the operator never opted in; in that case healthz probes still use
// the loopback default, but buildDaemonEnv must NOT export
// OPENRIG_HOST so the daemon falls through to the multi-bind default
// (loopback + tailscale auto-detect).
const explicitHost = opts.host;
const probeHost = explicitHost ?? DEFAULT_HOST;
// Check if already running
const existing = readState(deps);
@@ -301,7 +320,7 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
} else {
let recoveredRunning = false;
try {
await fetchDaemonProbe(deps, `http://${host}:${port}/healthz`);
await fetchDaemonProbe(deps, `http://${probeHost}:${port}/healthz`);
recoveredRunning = true;
} catch (err) {
if (err instanceof HealthProbeTimeoutError) {
@@ -324,7 +343,7 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
const child = deps.spawn(process.execPath, [daemonEntry], {
env: buildDaemonEnv(process.env as Record<string, string>, {
port,
host,
host: explicitHost,
db,
transcriptsEnabled: opts.transcriptsEnabled,
transcriptsPath: opts.transcriptsPath,
@@ -339,8 +358,9 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
const pid = child.pid!;
// Poll healthz
const healthzUrl = `http://${host}:${port}/healthz`;
// Poll healthz against the configured probe host (loopback default
// when operator didn't opt in to a specific bind).
const healthzUrl = `http://${probeHost}:${port}/healthz`;
let healthy = false;
for (let i = 0; i < HEALTHZ_RETRIES; i++) {
try {
@@ -364,7 +384,10 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
const state: DaemonState = {
pid,
port,
host,
// Persist the probe host (operator-explicit when set, loopback
// default otherwise) so subsequent status reads can reach the
// daemon regardless of whether tailscale was added at multi-bind.
host: probeHost,
db,
startedAt: new Date().toISOString(),
};
@@ -938,6 +938,49 @@ describe("buildDaemonEnv", () => {
expect(result["OPENRIG_DB"]).toBe("/new/path.db");
});
// bug-fix slice auth-bearer-tailscale-trust forward-fix:
// Default-product-launch must NOT export OPENRIG_HOST so daemon's
// index.ts falls through to the loopback+tailscale multi-bind path.
describe("auth-bearer-tailscale-trust: explicit-vs-default host signal", () => {
it("omits OPENRIG_HOST when opts.host is undefined (default-product-launch)", () => {
const baseEnv: Record<string, string> = { HOME: "/Users/tester", PATH: "/usr/bin" };
const result = buildDaemonEnv(baseEnv, {
port: 7433,
db: "/tmp/test.db",
// host intentionally omitted — operator never set it (default path)
});
expect(result["OPENRIG_HOST"]).toBeUndefined();
// port + db still flow through
expect(result["OPENRIG_PORT"]).toBe("7433");
expect(result["OPENRIG_DB"]).toBe("/tmp/test.db");
});
it("sets OPENRIG_HOST when opts.host is explicit (user opt-in)", () => {
const baseEnv: Record<string, string> = { HOME: "/Users/tester", PATH: "/usr/bin" };
const result = buildDaemonEnv(baseEnv, {
port: 7433,
host: "0.0.0.0",
db: "/tmp/test.db",
});
expect(result["OPENRIG_HOST"]).toBe("0.0.0.0");
});
it("does NOT scrub OPENRIG_HOST from baseEnv when opts.host is undefined (operator shell env wins)", () => {
// If the operator has OPENRIG_HOST set in their shell, that's an
// explicit signal — preserve it through the env-passthrough loop.
const baseEnv: Record<string, string> = {
HOME: "/Users/tester",
PATH: "/usr/bin",
OPENRIG_HOST: "0.0.0.0",
};
const result = buildDaemonEnv(baseEnv, {
port: 7433,
db: "/tmp/test.db",
});
expect(result["OPENRIG_HOST"]).toBe("0.0.0.0");
});
});
describe("V1 pre-release CLI/daemon Item 1 — transcript rotation tunables projection", () => {
it("projects transcriptsLines + transcriptsPollIntervalSeconds into OPENRIG_* env vars when provided", () => {
const baseEnv: Record<string, string> = { HOME: "/Users/tester", PATH: "/usr/bin" };