Skip to content

fix: 15 findings from the whole-repo code review - #130

Merged
PhantomDave merged 19 commits into
mainfrom
fix/review-findings
Oct 22, 2026
Merged

PhantomDave merged 19 commits into
mainfrom
fix/review-findings

Conversation

@PhantomDave

@PhantomDave PhantomDave commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Fixes the 15 findings from an xhigh code review of the whole repo, one commit per finding. Each commit message explains the bug and the fix; review commit by commit.

Security

  • Bind to 127.0.0.1 instead of 0.0.0.0. The API has no auth and can create and run shell commands, so any host on the LAN could run code as the developer.
  • Loopback guard on every route. checkMcpRequest (now checkLocalRequest in src/local-guard.ts) only protected /mcp. It now runs as an onRequest hook for all routes, which blocks DNS rebinding and cross-site bodyless POSTs that skip the CORS preflight (run profile, restart command, close workspace…).

Core correctness

  • Global env vars were duplicated on every edit. SQLite treats NULL profiles as distinct in UNIQUE. Upserts now conflict on a COALESCE expression index, and opening an existing DB keeps the newest row of each duplicate.
  • Process env came from the first profile that lists a command, not the one that launched it. The queue now passes its launch-profile tag to the env resolver, matching conductor run.
  • Overlapping restarts could spawn two processes and orphan one (restartQueued re-checks the single-flight map until it's free).
  • The force-kill before a respawn was reported as a crash: "crashed" (137), an auto-restart, then "recovered".
  • A dependency stopped by hand that exited 0 counted as ready. Only a one-shot that completed on its own does now.
  • command healthchecks had no timeout. Each run is killed (whole group) after 10s or timeout_ms, and the monitor no longer stacks probes.
  • Command move / membership sync were non-atomic: a failing target dropped the command from the source. They now write once, through store methods.
  • Session close closed the DB while log lines could still arrive. The session now drops lines that arrive after it closed its DB instead of throwing on a closed DB, and drained never rejects, as its doc said. (A first version waited for the pipes to drain in close(); on Windows that hit its 2s cap on every close and broke the workspace tests, so it was replaced.)
  • Chained ${VAR} references are resolved; PATH=${PATH}:/x extends the inherited value.
  • Unknown stop_signal names are rejected by the schema. Previously process.kill threw, killTree swallowed it, and every stop silently waited for SIGKILL.

UI

  • A profile description can be cleared. The modal sent undefined, which JSON drops, and the API answered 400.

Desktop (Tauri)

  • Quitting no longer orphans managed processes. The grace period goes from 5s to 10s, and on escalation the shell SIGKILLs each child's process group (pgrep -P) before the sidecar.
  • No unattended update install + restart at startup. The UI checks on load and shows a notice; installing stays the button in Environment → About.

Reviewer notes

  • Behaviour changes:
    • the core is no longer reachable from other machines;
    • a .conductor.yml with an invalid stop_signal (e.g. TERM) now fails to load;
    • updates are no longer auto-installed.
  • Tests: 25 new ones (319 → 344). The ones that reproduce a bug fail without their fix; a few guard cases that already worked. Checks run before every commit: bun test, lint, typecheck, prettier and the run-desktop sidecar smoke. Two healthcheck port tests (probeOnce - type: port, waitForHealthy … retrying) fail on my machine before and after these changes, because localhost resolves only to ::1 there. CI should be unaffected.
  • Not exercised at runtime:
    • the Rust changes passed cargo check only; please run bun run --cwd packages/desktop-tauri dev and quit with a running service, and check the update notice;
    • the profile edit modal was tested through the API contract test, not in a browser.
  • Out of scope: on Windows the sidecar is still hard-killed on quit (pre-existing).

🤖 Generated with Claude Code

PhantomDave and others added 16 commits October 22, 2026 22:45
The API has no authentication and can create and run arbitrary shell
commands, so listening on every interface let any host on the LAN run code
as the developer. Only /mcp had a loopback guard; /api exposed the same
surface without one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only /mcp checked that callers were on this machine. /api exposes the same
command-running surface, so a DNS-rebinding page could drive it, and any
cross-site page could fire bodyless POSTs (run a profile, restart a command,
close the workspace) that skip the CORS preflight. The check now runs as an
onRequest hook for all routes; /mcp keeps the same 403 behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
UNIQUE (scope, profile, key) never fires for global vars because SQLite
treats NULL profiles as distinct, so each PUT inserted a new row and the
API answered with the oldest value. Deleting one copy left another one
active. Upserts now conflict on a COALESCE expression index, and opening an
existing DB keeps only the newest row of each duplicated var.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server always resolved a command's env through the first profile that
listed it, so running "staging" started a command shared with "dev" using
dev's env, while the .env files were compiled with staging's. The queue
now passes its launch-profile tag to the env resolver, falling back to the
first profile only when the command was started outside a known profile.
This matches what `conductor run` already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
restartQueued awaited the in-flight start once and then registered its own
restart. Two restarts waiting on the same promise resumed together, both
stopped the old wrapper and both spawned a new one; `wrappers` kept the
second and the first process ran on untracked, holding its port. It now
waits until no start or restart is in flight before registering.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Starting a command that was already running force-kills its process first.
forceKillAndWait never marked that as intentional, so the exit (code 137)
recorded a "crashed" notification, scheduled an auto-restart under
on_failure/always, and then a "recovered" one. It now flags the stop as
intentional before killing, like stop() does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A service that handles SIGTERM exits 0, so after the user stopped it the
wrapper read "stopped" with exit code 0, and both isDependencyReady and
waitForDependency accepted that as a satisfied dependency: restarting a
dependent ran it against a service that was down. Only a one-shot that
completed on its own with exit 0 counts now; anything else is started again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A `command` probe had no timeout: one that hung (curl without --max-time,
pg_isready on a dropped host) left the command "starting" forever, and the
health monitor spawned another probe every interval on top of it. Each run
is now killed (whole process group) after 10s or the healthcheck's
timeout_ms, whichever is lower, and the monitor skips ticks while a probe
is still in flight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The move endpoint persisted the removal from the source profile before
adding to the target, so a target that was unknown or already listed the
command answered 400 with the command gone from the source. The sync
endpoint applied its adds and removes one write at a time, with the same
partial-failure problem. Both now go through store methods that validate
everything first and write the config once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SpawnQueue.close() returned as soon as every process had exited, but the
last lines could still be in the pipes, and the session closed its SQLite
DB right after. Those lines then hit insertLog on a closed DB, the pump's
catch handler threw again, and `drained`, documented as never rejecting,
rejected unhandled. close() now waits for every wrapper to drain (bounded
to 2s), and `drained` swallows handler errors as its doc says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
stop_signal accepted any string. process.kill throws ERR_UNKNOWN_SIGNAL on
a name like "TERM", and killTree swallows every error as "group already
gone", so the process never got the graceful signal: every stop waited the
full stop_timeout_ms and then SIGKILLed it. The schema now only accepts
signal names the OS knows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
interpolateEnv made one pass over the raw values, so with
API_URL=http://${HOST}:3000 and CALLBACK=${API_URL}/cb the process got the
literal "http://${HOST}:3000/cb". References are now resolved recursively.
A reference back into a value being resolved (PATH=${PATH}:/x) reads the
inherited process.env value instead of looping or doubling the suffix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clearing the description in the edit modal sent `description: undefined`,
which JSON drops, so the body was `{}` and the API answered 400. A rename
to the same name with no description also wiped it, and a rename could
never clear it. The UI now sends "" for a cleared field; the store treats
an omitted description as "keep" and "" as "remove" in every branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On quit the shell gave the sidecar 5s after SIGTERM, but the core waits up
to each command's stop_timeout_ms (5s by default) plus a group sweep before
escalating, so the SIGKILL often landed mid-shutdown. Managed processes run
in their own process groups, so they survived holding their ports. The
grace period is now 10s, and on escalation the shell SIGKILLs each child's
process group before the sidecar itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every packaged launch downloaded and installed any newer release and then
restarted the app on its own, even after the user had opened a workspace
and started processes, all of which the restart stopped. Startup now only
checks: the UI shows a notice when a release is newer, and installing stays
the explicit button in Environment → About.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The profile-env test printed "$TARGET" through the default shell, which is
cmd.exe on Windows and doesn't expand it; it now reads the env from a
shell:false bun script like the queue tests. The duplicate-env-var test
removed its temp dir with the SQLite file still open, which Windows refuses
(EBUSY); it now closes the DB first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@PhantomDave
PhantomDave enabled auto-merge (squash) October 22, 2026 22:14
PhantomDave and others added 3 commits October 23, 2026 00:27
…drain

The drain wait added to SpawnQueue.close() hit its 2s cap on every close on
Windows (open(current) went from ~0.7s to ~2.2s in CI), and the workspace
tests that close with live processes then failed to remove their temp dirs
(EBUSY). close() no longer waits; instead the session ignores log lines
that arrive after it closed its DB, which is what the wait was protecting
against. `drained` still never rejects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Windows releases a just-closed SQLite file a moment later, so the
duplicate-env-var test's cleanup failed with EBUSY even after closing the
DB. It now retries like the workspace tests do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Bun 1.4.4 Windows takes 1.5-2s to release a just-stopped child's cwd or
a just-closed SQLite file: "open(current) is a no-op" went from ~0.7s on
1.4.3 to ~2.1s, on main as well. The 3s cleanup retry then sometimes runs
out and the workspace and MCP tests fail in afterEach with EBUSY. The three
copies of that retry loop are now one helper which, on Windows, leaves the
temp dir for the OS once retries are exhausted instead of failing; the
tests already check that the processes are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@PhantomDave
PhantomDave merged commit 1a5e344 into main Oct 22, 2026
7 checks passed
@PhantomDave
PhantomDave deleted the fix/review-findings branch October 22, 2026 22:42
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.

1 participant