Skip to content

Add portable dashboard lifecycle commands#75

Open
justin808 wants to merge 40 commits into
mainfrom
codex/63-portable-dashboard-lifecycle
Open

Add portable dashboard lifecycle commands#75
justin808 wants to merge 40 commits into
mainfrom
codex/63-portable-dashboard-lifecycle

Conversation

@justin808

@justin808 justin808 commented Jul 17, 2026

Copy link
Copy Markdown
Member

Closes #63

Summary

  • add public start, stop, restart, status, logs, and open commands without tmux
  • reload a protected environment file on every start/restart while clearing absent API variables
  • manage only owned processes with protected runtime metadata, health/doctor checks, portable host probing, hard-bounded logs, and safe lock recovery
  • keep non-machine peers read-only and preserve the existing foreground command

Decision log

  • use --config-env-file because Node 24 reserves --env-file; the documented protected default remains available
  • use generation-specific lifecycle lock owner records, process-birth markers, bounded identity retries, and fail-closed behavior for live but unverifiable owners
  • reap a verified zombie lifecycle-lock owner only after its exact process-birth marker still matches
  • inventory process-group states on Linux and macOS; only a known nonempty all-zombie group is treated as exited
  • preserve both lifecycle-operation and lock-release failures when they occur together; unsafe release remains a nonzero result
  • probe the persisted bind address for localhost runtimes so later resolver changes do not misreport or open the wrong endpoint
  • probe IPv6-wildcard-to-IPv4 coverage at runtime instead of assuming dual-stack behavior
  • reject IPv4 and IPv6 link-local lifecycle probes and write peers before same-machine interface matching
  • require localhost DNS resolution to remain within IPv4/IPv6 loopback
  • enforce the lifecycle log cap at each marked-server stdout/stderr write; oversized startup logs retain a rollover marker and newest bounded tail on the verified descriptor
  • perform real temporary-bind and enumerated-interface preflights; reject inconclusive same-port widening before stopping an owned service
  • treat diagnostic summaries as bounded/best-effort so a healthy lifecycle operation is not failed by slow or malformed summary responses

Validation

  • exact head: 39793c603cf1a0b821336b16b57ef97932bea270
  • full repository suite: 41 files / 1,321 passed / 5 platform-skipped
  • focused final hosted-review regression matrix: 16/16 passed
  • Linux Docker zombie/fail-closed focused pair passed; lifecycle-lock dead-owner, verified-zombie, and PID-reuse matrix passed
  • typecheck, production build, .agents/bin/validate, and git diff --check
  • exact-diff autoreview: no actionable correctness, security, or maintainability findings
  • zero maker lifecycle process residue; protected user dashboard at 127.0.0.1:4317 remained healthy and untouched
  • independent manual QA on earlier reviewed heads covered Linux secondary loopback, runtime-metadata tampering, dual-stack transitions, oversized/symlink/FIFO/foreign-owner logs, link-local rejection, degraded data, secret safety, and the read-only remote boundary
  • final exact-head independent QA will be posted after current-head hosted checks and reviews settle

Security

  • tokens are excluded from argv, runtime metadata, and logs
  • environment file must be current-user-owned, non-symlink, regular, mode 0600, and is opened nonblocking to reject FIFOs safely
  • malformed double-quoted environment values with an escaped final quote fail closed
  • lifecycle state/log operations use verified regular files and do not follow symlinks or FIFOs
  • wildcard HOST values require ALLOWED_HOSTS containing only specific hostnames or IP addresses
  • coordination writes are accepted only from loopback or the exact current non-link-local machine interface; other LAN, link-local, and remote peers remain read-only
  • README documents the kernel TCP source-address trust assumption and recommends loopback or host firewall controls on shared/untrusted networks
  • exact-head live security preflight: SECURITY_PREFLIGHT_OK

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The packaged CLI adds detached lifecycle commands with protected configuration, locking, ownership checks, health readiness, recovery, and process cleanup. Doctor diagnostics can validate and probe URLs through machine-local network interfaces.

Changes

Portable dashboard lifecycle

Layer / File(s) Summary
Machine-local doctor probing
bin/agent-coordination-dashboard.js, bin/interface-address.js, scripts/cli.test.ts, scripts/interface-address.test.js
Adds --local-interface-url, machine-interface address validation, locally bound bounded HTTP fetching, and coverage for canonical URLs, redirects, malformed responses, and address restrictions.
Lifecycle state, locking, and protected configuration
bin/lifecycle.js, bin/lifecycle.d.ts, scripts/lifecycle.test.ts
Adds lifecycle paths, stale-lock recovery, atomic runtime metadata, protected environment parsing, safe log access, endpoint validation, process ownership verification, and declarations for exported lifecycle helpers.
Detached startup, readiness, and shutdown
bin/lifecycle.js, src/server/index.ts, bin/agent-coordination-dashboard.js
Wires lifecycle dispatch and child execution, starts detached server instances with bounded logging, polls health, reports diagnostics, and terminates only owned process groups.
Lifecycle behavior and integration coverage
scripts/lifecycle.test.ts, README.md
Documents lifecycle semantics and tests idempotency, concurrency, recovery, token rotation, ownership, logging, filesystem protections, and degraded coordination reporting.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant Operator
  participant LifecycleCLI
  participant LifecycleChild
  participant Dashboard
  Operator->>LifecycleCLI: start
  LifecycleCLI->>LifecycleChild: spawn detached instance
  LifecycleChild->>Dashboard: launch server
  LifecycleCLI->>Dashboard: poll /api/health
  Dashboard-->>LifecycleCLI: readiness and coordination status
  LifecycleCLI-->>Operator: lifecycle result
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes implement the requested macOS/Linux lifecycle commands, protected env loading, ownership checks, idempotency, and health/doctor validation.
Out of Scope Changes check ✅ Passed The additional docs, tests, and supporting CLI/server helpers stay aligned with the lifecycle-command work and do not appear unrelated.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding portable dashboard lifecycle commands.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/63-portable-dashboard-lifecycle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread bin/lifecycle.js Outdated
Comment thread bin/lifecycle.js Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1c62e0d3df

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bin/lifecycle.js Outdated
Comment thread bin/agent-coordination-dashboard.js Outdated
@claude

claude Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review: Portable dashboard lifecycle commands

Overview

This PR adds start/stop/restart/status/logs/open lifecycle commands to the CLI (bin/lifecycle.js, new, ~715 lines) so the dashboard can run detached without tmux. It layers in: a protected-env-file reload on start/restart that clears stale API credentials, a lock-directory-based mutex with PID + process-birth-marker identity verification (to avoid PID-reuse false positives), portable host/port probing (including IPv6 loopback and LAN interfaces), and a --local-interface-url restriction added to the existing doctor command so deep diagnostics can only target addresses actually assigned to the local machine.

Code quality

  • The lock/lifecycle-ownership design (birth-marker verification, generation-specific owner records, bounded stale-lock reclamation) is unusually careful for this kind of local-daemon bookkeeping, and it's backed by real concurrency stress tests (scripts/lifecycle.test.ts), including a simulated transient ps failure and a "never stops an unrelated listener" test.
  • Runtime/lock/log files are consistently written with 0600/0700 modes and atomic tmp-file-then-rename patterns, with rollback on partial failure (e.g. writeRuntime, acquireLifecycleLock).
  • stopDashboard/startDashboard correctly refuse to touch a process/listener the lifecycle doesn't own ("unowned" path), matching the security goals stated in the PR description.
  • Exit code convention (0 success / 1 unhealthy / 2 unowned / 3 stopped / 64 usage) is consistent and well-tested.

Issues found (posted inline)

  1. TOCTOU in readProtectedEnv (bin/lifecycle.js, ~L266-L291): the file is validated with lstat (regular file, current-user-owned, mode 0600, non-symlink) and then re-opened by path via a separate readFile call. A different local process with write access to the containing directory could swap the file between the two calls, bypassing the very symlink/ownership protection this check exists for. Narrow blast radius (needs another local writer to the config directory), but worth closing by validating and reading through a single open file descriptor.
  2. Minor/dead code (bin/lifecycle.js, ~L468): stopDetachedGroup's catch checks isFileNotFound(error) (i.e. ENOENT) on a process.kill() error, but kill() only ever produces ESRCH/EPERM, so that half of the condition can never be true. Harmless, just confusing.

Other observations (not blocking)

  • runDeepDoctor's use of --local-interface-url only matches literal IP addresses against os.networkInterfaces(), not hostnames — if HOST in the protected env file is a DNS name that happens to resolve locally, lifecycle's coordination-diagnostics step will report "unknown"/fail even though the server itself started fine. Given the docs only describe binding to IPs (including LAN/IPv6 loopback), this is likely an accepted scope limit rather than a bug, but may be worth a doc note.
  • --local-interface-url is a real, working flag on the public doctor subcommand but isn't mentioned in the HELP usage text (it's used internally by lifecycle.js). Minor documentation gap.
  • No security concerns beyond the TOCTOU item above — tokens are kept out of argv/metadata/logs as claimed, the lock/state directories are permission- and ownership-checked, and the new --config-env-file/--local-interface-url argument parsing rejects unknown/repeated flags.

Overall this is a thorough, well-tested addition; the one substantive finding is the env-file TOCTOU race, which is worth fixing given the explicit anti-symlink security intent of that check.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@bin/agent-coordination-dashboard.js`:
- Around line 100-109: Update parseLocalInterfaceDashboardUrl() so valid HTTP
URLs explicitly using the default port :80 are accepted. Adjust the
normalizedRaw versus parsed.origin validation to canonicalize or otherwise
account for the default HTTP port, while preserving rejection of non-root paths,
credentials, query strings, fragments, and unsupported protocols.

In `@bin/lifecycle.js`:
- Around line 481-486: Update terminateDetachedGroup to retain and propagate the
result of the final waitForProcessExit call after SIGKILL. Ensure callers such
as stopDashboard do not delete runtime.json or report success when the process
remains alive, preserving runtime metadata for subsequent lifecycle commands.
- Around line 266-290: Update readProtectedEnv to open envFile once with a
no-follow read-only descriptor using O_RDONLY | O_NOFOLLOW, then perform
ownership/mode/regular-file validation via fstat on that descriptor and read
contents from the same handle. Remove the separate lstat/readFile path while
preserving the existing optional-file behavior, validation errors, parsing, and
reliable descriptor cleanup.

In `@scripts/lifecycle.test.ts`:
- Around line 507-569: The simultaneous-start test currently always expects a
fake ps failure, but Linux resolves process identity through /proc instead.
Separate the cross-platform concurrency assertions from the injected
process-identity failure scenario in the test, and only run the fake ps failure
setup and psFailureLog assertion on platforms that use the ps fallback (or
provide a deterministic fallback seam).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 56dabc4a-1d5d-44cc-9b4c-45178a1bdd1a

📥 Commits

Reviewing files that changed from the base of the PR and between 5552fdc and 1c62e0d.

📒 Files selected for processing (5)
  • README.md
  • bin/agent-coordination-dashboard.js
  • bin/lifecycle.js
  • scripts/cli.test.ts
  • scripts/lifecycle.test.ts

Comment thread bin/agent-coordination-dashboard.js Outdated
Comment thread bin/lifecycle.js Outdated
Comment thread bin/lifecycle.js Outdated
Comment thread scripts/lifecycle.test.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1ed4c8eaae

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bin/lifecycle.js Outdated
Comment thread bin/lifecycle.js
Comment thread bin/agent-coordination-dashboard.js
@claude

claude Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review summary

Reviewed the diff (README.md, bin/agent-coordination-dashboard.js, bin/lifecycle.js [new], scripts/cli.test.ts, scripts/lifecycle.test.ts [new]).

Overview

Adds a tmux-free, detached lifecycle CLI (start/stop/restart/status/logs/open) built around:

  • a directory-based advisory lock (lifecycle.lock/) with PID + process-birth-marker verification to safely reap locks left by dead commands without racing a live one,
  • process-group-scoped start/stop so only owned processes are ever signaled,
  • a protected environment file (0600, owned, non-symlink) reloaded on every start/restart, with API URL/token vars explicitly cleared before being reapplied,
  • a raw http.request-based probe path (--local-interface-url) that binds the outgoing socket to loopback so deep diagnostics can reach a LAN-bound dashboard without weakening the server's own loopback-only /api/doctor boundary.

Code quality

  • Very thorough, deliberate design — the lock-reaping state machine, PID-reuse protection via birth markers, and the fail-closed behavior for unverifiable owners are all well thought out and backed by targeted tests (stale lock recovery, concurrent-start stress test, PID-reuse simulation, unowned-listener protection).
  • Test coverage is excellent: idempotency, degraded-coordination reporting, token rotation/removal on restart, IPv6/LAN binding, and failure-mode tests (missing/invalid/symlinked env file, runtime-metadata write failure, port already in use).
  • Left two inline notes on smaller points:
    • stopDashboard (bin/lifecycle.js) doesn't check whether the final SIGKILL wait actually confirmed process death before deleting runtime.json and reporting success — a rare but real edge case (stuck/D-state process) where "Dashboard stopped." could be reported while the process is still alive. Self-recovering (next start fails loudly on port-in-use) but worth a return-value check.
    • fetchDoctorJsonFromLocalSource (bin/agent-coordination-dashboard.js) duplicates the bounded-read/redirect/timeout logic of the existing fetch()-based fetchDoctorJson/readBoundedJson, necessary since fetch() can't pin localAddress, but it's logic that will need to be kept in sync by hand going forward.

Other observations (no action required)

  • startDashboard unconditionally forces NODE_ENV=production after merging the protected env file (bin/lifecycle.js:537), silently overriding any NODE_ENV set there. Likely intentional for a packaged/production launcher, but not called out in the README — worth a one-line doc mention if intentional.
  • Minor duplication of the mkdir(stateDir) + chmod(stateDir, 0o700) boilerplate across acquireLifecycleLock, writeRuntime, and startDashboard; could be a small shared helper, purely cosmetic.

Security

  • Token handling looks correct: cleared from argv, runtime metadata, and logs; only ever placed in the child process environment. Verified against the "reloads a rotated token and returns to filesystem mode" test.
  • Env file and lock/runtime file permission checks (owner match, non-symlink, exact mode) are enforced consistently before use.
  • --local-interface-url only permits binding the local diagnostic socket to addresses the CLI can enumerate on the current host via os.networkInterfaces(), so it can't be used to probe or spoof a source address against a remote host — it only affects how this machine talks to itself.

Overall this is a well-engineered, heavily-tested feature. The two inline points are minor robustness/maintainability suggestions rather than blocking issues.

Comment thread bin/lifecycle.js
Comment thread bin/agent-coordination-dashboard.js
@claude

claude Bot commented Jul 17, 2026

Copy link
Copy Markdown

Code Review

Overview

This PR adds a portable, tmux-free lifecycle CLI (start/stop/restart/status/logs/open) to the packaged agent-coordination-dashboard binary, plus a protected-environment-file loader and local-interface diagnostics for doctor. It's implemented almost entirely in a new bin/lifecycle.js (~885 lines) with a large accompanying test suite (scripts/lifecycle.test.ts, ~1160 lines).

Code quality

The lifecycle module is unusually careful for process-management code:

  • Atomic writes: runtime.json is written to a per-pid temp file and rename()d into place, with cleanup on failure (writeRuntime).
  • TOCTOU-safe env file validation: readProtectedEnv opens with O_NOFOLLOW and validates ownership/mode via fstat on the already-open handle rather than a separate stat() + open(), closing a classic race window.
  • Ownership verification before signaling: runtimeOwnership never trusts a bare pid/pgid — it cross-checks the recorded random 128-bit instance_id against the live process's command line before a start/stop/restart is allowed to signal anything, and fails closed ("nothing was stopped") whenever that can't be verified (e.g. ps failing, redirected pgid).
  • Lock reaping (reapStaleLock/acquireLifecycleLock) correctly handles the standard "owner died mid-write" and "PID reused" races via birth-marker verification, and uses a rename-then-unlink handshake to avoid two processes reaping the same stale lock simultaneously.
  • Restart validates before it stops: prepareStart runs (and can fail) inside the lock before stopDashboard is invoked, so a bad replacement config never takes down a healthy running instance — this is directly tested.

Overall the design matches what's described in the PR body (generation-specific lock records, bounded identity retries, fail-closed behavior), and the test suite exercises the gnarly cases well: concurrent starts, killed wrapper with orphaned server group, ps failures, unrelated listeners, token rotation/clearing, degraded coordination backend, etc.

Suggestions (posted inline)

  1. Duplicated bounded-HTTP-fetch logicserviceIsHealthy's non-loopback branch in bin/lifecycle.js reimplements the same raw-socket/localAddress/size-cap/redirect-handling pattern as fetchDoctorJsonFromLocalSource in bin/agent-coordination-dashboard.js. Both are security-sensitive (body-size cap, loopback-source binding); worth factoring into one shared helper so a future fix doesn't silently apply to only one copy.
  2. Undocumented --local-interface-url doctor flag — it's a real capability (probe any address bound to a local interface, not just loopback) but isn't in HELP or the README, only ever passed internally by runDeepDoctor. Consider documenting it or noting explicitly why it's intentionally hidden.

Minor / non-blocking observations

  • childEnv = { ...process.env } in prepareStart means PORT/HOST/ALLOWED_HOSTS (unlike the API URL/token vars) are inherited from whatever shell environment invokes start/restart, not just the protected env file, unless the file overrides them. This is likely intentional (shell overrides are convenient) but could surprise an operator who assumes the env file is the sole source of truth — might be worth a one-line callout in the README next to the existing "explicit empty values" paragraph.
  • The macOS/BSD process-identity fallback (ps -o lstart=, one-second resolution) is weaker than the Linux /proc/pid/stat starttime (clock-tick resolution), but this is mitigated in practice since ownership also requires the process's command line to contain the exact random 32-hex-char instance id, so it's not independently exploitable.

Security

Token handling (excluded from argv/metadata/logs, explicit clearing of API env vars before file values are applied), env-file protections (0600, owned, non-symlink via O_NOFOLLOW), and the loopback-only/local-interface-only URL validation all look sound. No injection risks spotted — spawn is used in array form throughout (no shell interpolation), including for the open/xdg-open launch of the dashboard URL.

Test coverage

Very strong — concurrency, recovery from a killed wrapper, lock staleness/reuse, restart-config-validation-before-stop, token rotation, and degraded-backend reporting are all covered. One small gap: no test exercises the open command's unsupported-platform error path (guarded early, low risk).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ffde9ec3e0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bin/agent-coordination-dashboard.js
Comment thread bin/lifecycle.js
Comment thread bin/lifecycle.js
Comment thread bin/lifecycle.js

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b46ed8e42c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bin/lifecycle.js Outdated
@claude

claude Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review: Add portable dashboard lifecycle commands (#75)

Overview
This PR adds start/stop/restart/status/logs/open lifecycle commands to the CLI, replacing tmux-based detached execution with a self-contained implementation: a user-owned lock directory, PID + process-birth-marker identity tracking to avoid signaling processes the tool does not own, a protected (0600, non-symlink, owner-checked) environment file reload on start/restart, and health/doctor-based readiness checks including a "local-interface" probing mode for non-loopback binds. The change is large (+2339/-11 across 5 files) but scoped to bin/agent-coordination-dashboard.js, the new bin/lifecycle.js, README docs, and two test files (1200+ new test lines).

Strengths

  • Ownership safety is taken seriously. runtimeOwnership() verifies both PID liveness and a process-birth marker (via /proc/<pid>/stat on Linux, ps -o lstart= elsewhere) before treating a PID as "owned," and falls back to checking the process group's command lines when the lock-holding wrapper itself has exited but its child server survives (orphaned_owned). Combined with the lock's own owner-identity + stale-reap logic, this is a solid design for avoiding accidental kills of unrelated processes sharing a stale PID.
  • Env handling is safe by default. API_ENV_KEYS are explicitly cleared before the protected file's values are applied, so removing credential entries from the env file returns the dashboard to filesystem mode instead of inheriting stale tokens from the parent shell environment - matches the PR's stated security goals, and tokens never appear in argv or lifecycle metadata.
  • Good URL-parsing hardening: hasCanonicalHttpOriginSpelling() rejects things like http://localhost:04319 (leading-zero ports) that would otherwise round-trip differently through WHATWG URL normalization, a nice bit of defense against origin-spelling ambiguity, and it is covered by new tests.
  • Restart validates new config before touching the running service, and the lock directory + owner file writes are done via O_EXCL/rename-based atomic patterns that avoid classic TOCTOU pitfalls in lock creation.

Issues found (posted inline)

  1. Duplicated raw-HTTP client logic (bin/lifecycle.js:479 serviceIsHealthy non-loopback branch vs. bin/agent-coordination-dashboard.js:168 fetchDoctorJsonFromLocalSource) - both hand-roll a bounded/timeout-aware http.request with localAddress binding, and the code itself acknowledges they must be kept in sync manually. Recommend extracting a shared helper.
  2. restart's "leaves the existing service intact" guarantee is narrower than the README implies (bin/lifecycle.js:840-845) - only env/PORT-range/HOST-syntax are validated before the stop; actual port-availability (portIsListening) is checked only after the old dashboard is already stopped. A new PORT occupied by an unrelated process would leave the dashboard stopped rather than intact.

Other observations (not blocking)

  • bin/lifecycle.js is an 889-line single file covering lock management, env-file parsing, ps-based process identity, HTTP health probing, and CLI orchestration. Not a bug, but a future split into smaller modules (lock/, env/, health/) would help readability given how much is going on here.
  • The identity-verification machinery (birth markers, process-group command matching) depends on the ps binary being present; on minimal containers without ps this would surface as a generic "process identity could not be determined" error rather than an actionable one. Worth a one-line doc note if not already covered elsewhere.
  • Test coverage looks thorough (lock recovery, IPv4/IPv6/hostname rejection, symlinked/malformed env files, concurrent-start behavior per the PR description's stress runs) - no obvious gaps spotted in the diff.

Security
No issues found beyond the restart-ordering note above. Token handling, file permission checks (0600/0700, owner, non-symlink via O_NOFOLLOW), and the local-interface doctor probe's restriction to addresses actually bound to this machine all look correct.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@bin/lifecycle.js`:
- Around line 668-672: Update the ALLOWED_HOSTS validation near bindHost to
parse each comma-separated entry, trim it, and require every non-empty entry to
be an explicit valid browser hostname or IP address. Reject catch-all values
such as "*" and treat missing or invalid entries as disallowed when bindHost is
"0.0.0.0" or "::"; preserve the existing error behavior for wildcard bindings.
- Around line 481-499: The health probe in the non-loopback branch must bind its
source connection to the configured target host instead of forcing loopback
addresses. Update the localAddress calculation used by httpRequest to use
targetUrl.hostname, while preserving the existing loopback-derived Host header
construction unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0f530089-7379-4f27-8a26-c42d2a23f126

📥 Commits

Reviewing files that changed from the base of the PR and between 1c62e0d and b46ed8e.

📒 Files selected for processing (5)
  • README.md
  • bin/agent-coordination-dashboard.js
  • bin/lifecycle.js
  • scripts/cli.test.ts
  • scripts/lifecycle.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • README.md
  • bin/agent-coordination-dashboard.js

Comment thread bin/lifecycle.js
Comment thread bin/lifecycle.js Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6234131954

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bin/lifecycle.js
Comment thread bin/lifecycle.js Outdated
Comment thread bin/lifecycle.js
Comment thread bin/lifecycle.js Outdated
Comment thread bin/lifecycle.js
Comment thread src/server/security/machineLocal.ts
@claude

claude Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review summary

This is a large, exceptionally well-hardened PR (visible from the ~30 iterative "harden lifecycle X" commits) that adds detached start/stop/restart/status/logs/open lifecycle commands without tmux. I read through bin/lifecycle.js (1627 lines) in full, bin/interface-address.js, src/server/security/machineLocal.ts, the changes to src/server/app.ts/index.ts, and cross-checked against the test suites, plus had an independent second-opinion pass over the same code.

Strengths:

  • Lock acquisition/reaping correctly handles PID-reuse via process-birth-time markers, and the empty-dir staleness window correctly covers the mkdir → owner-file-write gap.
  • File opens for state/log/env consistently use O_NOFOLLOW/O_NONBLOCK, verify owner UID and exact file mode, and reject symlinks/FIFOs/non-regular files.
  • IPv4/IPv6 bind-host validation (wildcard, link-local, loopback, localhost resolution) is thorough and backed by dedicated tests.
  • runtime.json writes use a temp-file-then-rename pattern; ownership checks cross-verify both PID identity and process-group command lines before treating a process as "owned," avoiding false-positive kills of unrelated processes.
  • Log rollover correctly accounts for the shared O_APPEND fd offset to avoid sparse-file holes.

Findings posted inline (roughly in order of what I'd want addressed before merge):

  1. macOS zombie-group detection gap (bin/lifecycle.js ~833-838) — processGroupHasLiveProcesses only checks actual process states on Linux; on macOS it treats any existing pgid as "live," even if every process in it is a zombie. This can cause stop/restart to spuriously fail with a ~6s timeout ("process group did not exit") in a scenario the Linux-specific code path already handles correctly, using a ps mechanism this file already relies on cross-platform elsewhere.
  2. Lock-release error can mask a successful operation (bin/lifecycle.js ~201-208) — withLifecycleLock's finally { await release() } will replace a successful start/stop/restart result with a confusing lock error if the release-time owner-file re-check fails for any reason.
  3. Minor env-file parsing edge case (bin/lifecycle.js ~391-396) — the unterminated-quote check doesn't detect a trailing escaped quote (e.g. KEY="abc\"), so it's silently accepted with the escape dropped instead of raising a clear parse error. Low impact since the file must already be owned/0600 by the current user.
  4. Security design note (may be intentional)isMachineLocalAddress (src/server/security/machineLocal.ts) widens the write-access "same machine" check from strict loopback to any address matching one of the host's own network interfaces, trusting req.socket.remoteAddress equality. This is a meaningfully different trust model than before (needed once HOST can bind beyond loopback), with a residual theoretical exposure to LAN source-IP spoofing that's mitigated by OS-level anti-spoofing but not guaranteed. Suggest documenting this trade-off explicitly for operators, e.g. in the README's lifecycle section.

Nothing found rises to a hard security bypass or data-corruption bug — the codebase's existing hardening already closes the classic TOCTOU/race patterns I looked for.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6fcb383843

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bin/lifecycle.js
Comment thread bin/lifecycle.js Outdated
Comment thread bin/lifecycle.js
Comment thread bin/lifecycle.js
Comment thread bin/interface-address.js
Comment thread src/server/index.ts Outdated
@claude

claude Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review summary

This is a large, well-engineered PR (1663-line new bin/lifecycle.js plus supporting changes) adding detached lifecycle commands (start/stop/restart/status/logs/open) with a real process-management/locking subsystem and a widened "same-machine peer" write boundary. The decision log and test suite (scripts/lifecycle.test.ts, ~137KB, exercises stale-lock reaping, PID-reuse, zombie detection, symlink/FIFO rejection, mode checks, log rollover, etc. via real subprocesses) reflect serious attention to the hard edge cases here — lock acquisition/reap, process-identity verification (PID + birth-marker + zombie check), and the env/log file protections are all implemented consistently and carefully.

I verified the write-access boundary changes in src/server/app.ts are scoped correctly: /api/doctor and the foreground cache-bypass remain loopback-only (unchanged isLoopbackAddress calls), while only /api/settings PUT, annotations, and batch import/stop were widened to the new isMachineLocalAddress check — matching what the README documents.

I found no critical bugs. A few concrete issues worth addressing, posted as inline comments:

  1. bin/lifecycle.js processCommand (~786-797) — missing the try/catch guard every sibling process-inspection helper has, so a host without ps throws an ungraceful raw error instead of degrading like the rest of the file.
  2. bin/lifecycle.js openLifecycleLogForAppend (~656-662) — silently truncates an oversized log with no rollover marker/tail, unlike every other size-cap path in the file (resetLifecycleLogWithTail). Loses log history with no breadcrumb explaining why.
  3. bin/interface-address.jsdashboardHostsForInterfaceAddress doesn't exclude link-local/APIPA addresses the way machineLocal.ts does. Not currently exploitable (the outbound probe is always forced to a loopback source address regardless), but it's an undocumented, load-bearing coupling between two independent "is this my address" implementations that have already drifted.
  4. src/server/index.ts:14 — minor: app.listen(...) has no .on("error", ...) handler, so an EADDRINUSE bind failure surfaces as a raw unhandled-error stack trace rather than the clean error messaging used elsewhere in this PR.

One more inconsistency that isn't part of this diff so I couldn't attach it inline: src/server/security/loopback.ts treats only the literal 127.0.0.1 as loopback, while the new machineLocal.ts (this PR) treats the whole 127.0.0.0/8 range as loopback/machine-local. It's fail-closed (stricter, not a vulnerability) but worth reconciling or documenting given this PR intentionally broadens "local" elsewhere — a request from 127.0.0.5 would be accepted as machine-local for writes but rejected by the stricter loopback-only diagnostic routes.

Performance: no new synchronous/blocking work was added to hot GET request paths; the machine-local check is only invoked on write routes. The lifecycle log writer's fsWriteSync/fsFstatSync calls are synchronous by design (they run on the child's own stdout/stderr write path, not the HTTP request path), which is appropriate here.

Security: the isMachineLocalAddress/canonicalAddress core logic correctly handles case-folding, IPv4-mapped-IPv6 normalization, and link-local exclusion, and isn't fooled by DNS since matching is against literal socket peer addresses, not hostnames. Tokens are kept out of argv/logs/metadata as claimed. The env-file and log-file protections (owner check, mode 0600, O_NOFOLLOW/O_NONBLOCK to reject symlinks/FIFOs) are solid.

Overall: solid work, small polish items above rather than blockers.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 39793c603c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bin/lifecycle.js

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 10b217af5b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bin/lifecycle.js
@justin808

Copy link
Copy Markdown
Member Author

Address-review summary

Scan scope: full PR history through exact head 5e4a75fea5ea6ae6a1951395a2b360af183f59c4 on 2026-07-18.

Mattered

  • Resolved-address allowlist mismatch: fixed in 5fb2f80; explicit ALLOWED_HOSTS now permits both advertised localhost and the persisted concrete loopback used by open. A red/green regression proves a normal /api/settings request changes from 403 to 200.
  • IPv4 link-local lifecycle bind mismatch: fixed in 5e4a75f; direct and IPv4-mapped 169.254/16 hosts now fail before bind/process/runtime creation, using the same classifier as doctor probing.
  • Both originating review threads were answered after push and resolved. The fresh current-head hosted test, Claude review, CodeRabbit status, approval, and CI-readiness inventory are clean.

Optional

  • Declined late refactors that would consolidate the bounded HTTP readers or narrow ps scans. These are cold-path maintainability suggestions; the readers intentionally use different endpoint caps/diagnostic semantics, and changing them now would expand a security-sensitive diff without a current correctness failure.
  • Declined documentation/API expansion for internal __lifecycle-serve and --local-interface-url plumbing. They remain implementation details and expose no authority beyond existing foreground/doctor behavior.
  • Declined widening strict diagnostic loopback handling from 127.0.0.1 to all of 127/8; the existing difference is fail-closed and preserves the documented loopback-only diagnostic boundary.
  • Cross-platform process-identity and local-route observations are covered by fail-closed ownership behavior, macOS-targeted tests, and independent/manual lifecycle QA; no verified defect remained at this head.

Skipped

  • Historical review boilerplate, walkthrough/status posts, duplicates, and already-resolved findings required no new action. All 93 review threads are resolved; no pending viewer review draft remains.

Next default scan starts after this comment. Say check all reviews to rescan the full PR.

Comment thread bin/lifecycle.js
Comment on lines +538 to +561

function resetLifecycleLogWithTail(fd, chunk, maxBytes) {
const marker = Buffer.from(LIFECYCLE_LOG_ROLLOVER_MARKER);
const boundedMarker = marker.subarray(0, Math.min(marker.length, maxBytes));
const tailCapacity = maxBytes - boundedMarker.length;
const tail = chunk.subarray(Math.max(0, chunk.length - tailCapacity));
// Lifecycle startup opens this shared inherited descriptor with O_APPEND.
// Reset before writing the newest tail so the stale shared offset cannot
// create a sparse hole and the file never exceeds the cap at write return.
fsFtruncateSync(fd, 0);
writeAllToLifecycleLog(fd, boundedMarker);
writeAllToLifecycleLog(fd, tail);
}

function writeBoundedLifecycleLogChunk(fd, chunk, maxBytes) {
const stats = validateLifecycleLogStats(fsFstatSync(fd));
if (stats.size + chunk.length <= maxBytes) {
writeAllToLifecycleLog(fd, chunk);
return;
}
// Rollover discards the previous log and any oldest bytes from an oversized
// chunk. Only the fixed marker and newest chunk suffix are retained.
resetLifecycleLogWithTail(fd, chunk, maxBytes);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: runtime log rollover discards nearly all log history instead of retaining a bounded tail

writeBoundedLifecycleLogChunk calls resetLifecycleLogWithTail(fd, chunk, maxBytes) on rollover, passing only the new chunk currently being written — it never reads back any of the previous on-disk content before truncating. Since a single write is typically a short line (tens/hundreds of bytes), the effect is that the entire multi-megabyte log is truncated to just the rollover marker plus that one small line, rather than retaining a "newest bounded tail" as the PR description claims.

This is inconsistent with the startup path, resetOversizedLifecycleLog (below, lines 563–572), which correctly reads back up to MAX_LIFECYCLE_LOG_STARTUP_TAIL_BYTES (1 MiB) of existing file content before calling this same reset helper.

Failure scenario: the dashboard runs for a while, the log grows to just under the 8 MiB cap, and the write that finally crosses it is an ordinary small line. logs/the raw log file then shows only the rollover marker plus that single line — megabytes of recent debugging context vanish silently, right when a user is likely to run logs to investigate an incident.

Consider mirroring the startup path: read back maxBytes - chunk.length bytes of existing tail content before truncating, so live rollover behaves the same as the startup rollover.

Comment thread bin/interface-address.js

export function localSourceAddressForDashboardHost(host) {
return isIP(host) === 4 && host.startsWith("127.") ? "127.0.0.1" : null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor robustness gap: this only special-cases IPv4 loopback (isIP(host) === 4 && host.startsWith("127.")). For an IPv6 loopback hostname the caller passes the bracketed form (e.g. "[::1]"), and isIP("[::1]") returns 0, so this always returns null for IPv6 loopback and falls through to the interface-enumeration fallback instead of recognizing it directly. That fallback happens to work today because Node conventionally reports an ::1 entry for lo/lo0, but in an environment where IPv6 is disabled or that entry is absent, doctor --deep --url http://[::1]/-style probes would fail closed unnecessarily. Not a security issue (this is the diagnostic probing path, not the write-gating boundary), just a correctness/availability edge case worth a follow-up.

@claude

claude Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review: Add portable dashboard lifecycle commands (#75)

Overview

Adds start/stop/restart/status/logs/open lifecycle commands for running the dashboard detached, without tmux. The core (bin/lifecycle.js) manages generation-scoped lock ownership, process-birth verification for safe zombie/PID-reuse reaping, a protected ~/.config/.../env file reload, bounded lifecycle logs, and cross-platform (Linux/macOS) process-group inventory. Companion changes tighten the write-access boundary (src/server/security/machineLocal.ts, bin/interface-address.js) so coordination writes are accepted only from loopback or the exact current non-link-local machine interface.

This was reviewed by splitting the diff across three focused passes: the lifecycle core (locking/reaping/logs/env handling), the network/loopback security boundary, and the CLI/app integration + docs + tests. Findings below are from direct code reading, not just the PR description text.

Findings

1. Runtime log rollover discards almost all log history (bin/lifecycle.js) — see inline comment
writeBoundedLifecycleLogChunk calls resetLifecycleLogWithTail on rollover, passing only the new write's chunk — it never reads back prior on-disk content. This contradicts both the PR description claim of retaining a "newest bounded tail" and the startup path (resetOversizedLifecycleLog), which correctly reads back up to 1 MiB of prior content before resetting. In practice, once the log nears the 8 MiB cap, an ordinary small write triggers rollover and the file collapses to just the marker plus that one line — exactly when a user is most likely to run logs to debug an incident. Worth fixing to mirror the startup path's read-back-then-truncate approach.

2. Minor: IPv6 loopback probing gap in bin/interface-address.js — see inline comment
localSourceAddressForDashboardHost only special-cases IPv4 loopback text (127.x); a bracketed [::1] host falls through to interface enumeration, which currently works only because Node conventionally reports an ::1 entry for lo/lo0. Not a security issue (this is the diagnostic probe path, not the write-gating boundary in machineLocal.ts), just a fail-closed availability edge case in environments without that interface entry.

What held up well

  • Lock lifecycle (acquireLifecycleLock/reapStaleLock/removeClaimedLock): correctly fails closed when a PID is alive but its birth marker is unverifiable; correctly distinguishes PID reuse from a genuinely live owner via birth-marker comparison; rename-then-delete reap avoids double-claim races.
  • Protected env file handling: O_NOFOLLOW|O_NONBLOCK open, regular-file/uid/mode-0600 verification, rejects symlinks/FIFOs; API URL/token vars are explicitly cleared before reapplying file contents, so removing them from the file reverts to filesystem mode instead of inheriting stale credentials.
  • No shell/command injection: all spawn() calls use array-form args.
  • Loopback/link-local classification (machineLocal.ts, interface-address.js): IPv6 canonicalization via new URL("http://[addr]") correctly handles case, compression, and IPv4-mapped forms before the fe80::/10, 127.0.0.0/8, and 169.254.0.0/16 checks; link-local is rejected before interface matching as claimed; all error paths in the write-gating logic fail closed (deny), not open. localhost resolution is verified to stay within loopback at startup via dns/promises.lookup, mitigating a poisoned-/etc/hosts bypass.
  • CLI/app wiring: command dispatch, child re-entry (__lifecycle-serve--lifecycle-instance), NODE_ENV=production for children, and the --config-env-file vs reserved --env-file rationale all check out against the code. scripts/lifecycle.test.ts exercises the real CLI binary end-to-end for all six lifecycle verbs (including --config-env-file and the EADDRINUSE error path), and src/server/app.test.ts covers the new machineInterfaces write boundary (assigned-interface allow, LAN/link-local reject, spoofed X-Forwarded-For).
  • Docs: the new README "Detached lifecycle commands" section was checked claim-by-claim against the implementation (permissions, env-clearing behavior, NODE_ENV, wildcard HOST/ALLOWED_HOSTS rule) and matches.

Summary

This is a large, security-conscious addition, and most of the security-critical logic (locking, env-file validation, loopback/link-local gating) is solid and fails closed on error paths. The one substantive bug is the log-rollover behavior above — low security impact but real operational impact (loses debugging context right when it's needed). Recommend fixing that before merge; the IPv6 loopback probe gap can be a fast follow.

Comment thread bin/lifecycle.js
Comment on lines +1687 to +1690
const logHandle = await openLifecycleLogForAppend(paths);
try {
if (await stopDashboard(context, paths, { quiet: true })) {
await startDashboard(options, context, paths, preparedStart, logHandle);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possible race: log rotation runs before the old server is stopped, while it can still be writing to the same fd.

In the restart branch, openLifecycleLogForAppend(paths) (which calls resetOversizedLifecycleLogresetLifecycleLogWithTailfsFtruncateSync + raw fsWriteSync on the shared fd when the log exceeds MAX_LIFECYCLE_LOG_BYTES) runs before stopDashboard(...) is called. The currently-running detached server process holds its own duplicated fd to the same log file (opened O_APPEND) via installLifecycleLogWriter, and it is not synchronized by withLifecycleLock — that lock only serializes lifecycle CLI invocations against each other, not against the long-running server process itself.

So if the log happens to be oversized at the moment a restart is issued (e.g., due to prior external tampering, or logs written before installLifecycleLogWriter bounded every write), there's a window where the still-alive old server can call its own bounded writer (fsFtruncateSync/fsWriteSync on the same fd) concurrently with this reset, interleaving with the rollover marker + tail write and potentially producing a corrupted/garbled log around the rollover boundary or defeating the “never exceed the cap at write return” invariant that the rest of this file goes to great lengths to guarantee.

This is intentionally ordered this way so that a bad replacement config aborts before the running dashboard is touched (per the README: “A restart validates the protected file... before stopping the running dashboard”), so moving the log-cap check after stopDashboard isn't a free fix — but as written the log-integrity guarantee has a gap specifically on the restart path that start doesn't have (since start only opens/rotates the log once ownership has already been confirmed as not running). Worth confirming this is an accepted/understood tradeoff, since the existing tests (e.g. "marks and retains the newest tail when capping an oversized log before restart") inflate the log via writeFile while the old server is idle, not while it's actively writing — so this interleaving path isn't covered.

Comment thread bin/interface-address.js
Comment on lines +1 to +21
import { isIP } from "node:net";

export function isNonLinkLocalInterfaceAddress(address) {
if (isIP(address) === 4) return !address.startsWith("169.254.");
if (isIP(address) !== 6) return false;
try {
const normalized = new URL(`http://[${address}]`).hostname.slice(1, -1);
const mapped = normalized.match(/^::ffff:([a-f0-9]+):([a-f0-9]+)$/);
if (mapped) {
const high = Number.parseInt(mapped[1], 16);
const low = Number.parseInt(mapped[2], 16);
return isNonLinkLocalInterfaceAddress(
`${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`
);
}
const firstHextet = Number.parseInt(normalized.split(":")[0], 16);
return (firstHextet & 0xffc0) !== 0xfe80;
} catch {
return false;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: link-local / IPv4-mapped canonicalization logic is duplicated between here and src/server/security/machineLocal.ts.

isNonLinkLocalInterfaceAddress here and canonicalAddress/the link-local check in src/server/security/machineLocal.ts independently re-implement the same IPv4-mapped-IPv6 unpacking and 0xfe80/0xffc0 link-local bitmask logic (and bin/lifecycle.js has its own isIpv6LinkLocal doing the same bitmask a third time). They currently agree, but since this logic is security-relevant (it gates which addresses are trusted as "this machine" for write access and for HOST binding), any future tweak to one copy without the others risks silently diverging the trust boundary between the CLI lifecycle code and the server's request-auth code. Consider consolidating into a single shared helper (e.g. exported from interface-address.js and imported by machineLocal.ts) rather than three parallel implementations.

@claude

claude Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review summary

This is a large, security-focused addition (detached lifecycle start/stop/restart/status/logs/open commands, a protected env-file loader, a hardened lifecycle lock, bounded log rotation, and a widened but carefully-scoped machine-local write-auth check). The code is unusually defensive: extensive input validation, fail-closed behavior on ambiguous states, symlink/FIFO/ownership checks on every file it touches, and a large accompanying test suite (scripts/lifecycle.test.ts, scripts/interface-address.test.js, src/server/security/machineLocal.test.ts).

I left two inline comments:

  1. bin/lifecycle.js (restart path, ~L1687-1691) - a plausible race: log-cap rotation runs before stopDashboard, using raw sync fsFtruncateSync/fsWriteSync on an fd the still-running old server process independently writes to via its own bounded writer. The two aren't synchronized (the lifecycle lock only serializes CLI invocations, not the long-running server). In practice the running server keeps the log at-or-under the cap on every write, so this only bites if the log was already oversized by some other means at the moment of restart - narrow, but not covered by the existing oversized-log-before-restart test, which inflates the log while the old server is idle rather than actively writing. Worth confirming this is an understood/accepted edge case given how careful the rest of the file is about exactly this kind of invariant.

  2. bin/interface-address.js - minor maintainability note: the link-local / IPv4-mapped-IPv6 canonicalization logic is independently re-implemented three times (interface-address.js, src/server/security/machineLocal.ts, and isIpv6LinkLocal in lifecycle.js). They currently agree, but since this gates a security boundary, a future edit to one copy without the others could quietly diverge the trust boundary between the CLI and the server. Consider a single shared helper.

Other things I checked and did not flag:

  • Widened write-auth boundary (isLoopbackAddress -> isMachineLocalAddress in src/server/app.ts): writes to /api/settings, annotations, and batch import/stop now trust any of the host's own non-link-local interface addresses as local, not just loopback, based on req.socket.remoteAddress. This is a real increase in trust surface, but it's the intentional point of this PR (issue Add portable dashboard lifecycle commands without tmux #63), it's documented clearly in the README with the caveat that this isn't application authentication and depends on the kernel-verified TCP source address, and it's well covered by tests (including a regression test proving X-Forwarded-For is not trusted). No action requested, just flagging for reviewer awareness since it's the most consequential security-relevant change in the diff.
  • Command construction throughout uses array-form spawn(...) (no shell), so no shell/argument injection.
  • Token/API-credential handling: excluded from argv and lifecycle metadata, explicitly zeroed before the protected env file is applied so removed values don't persist across restarts, NODE_OPTIONS explicitly stripped/forbidden.
  • Lock/state/log files are all opened with O_NOFOLLOW/O_NONBLOCK plus explicit uid/mode checks, consistently guarding against symlink and FIFO tricks.
  • I was not able to run npm install/typecheck/vitest in this sandbox (network-restricted), so I could not independently verify the PR description's test-run claims - reviewers should confirm CI is green.

Overall this looks like solid, careful work; my findings are one plausible-but-narrow race condition and one maintainability nit, not correctness or security blockers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add portable dashboard lifecycle commands without tmux

1 participant