Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c7a014c
fix(core): bind the API to 127.0.0.1 instead of 0.0.0.0
PhantomDave Oct 22, 2026
afc5fc5
fix(core): apply the loopback Host/Origin guard to every route
PhantomDave Oct 22, 2026
7d3b857
fix(core): stop duplicating global env vars on every edit
PhantomDave Oct 22, 2026
eee995e
fix(core): resolve a process's env from the profile that launched it
PhantomDave Oct 22, 2026
52af96b
fix(core): run overlapping restarts of a command one at a time
PhantomDave Oct 22, 2026
2df1199
fix(core): don't report the force-kill before a respawn as a crash
PhantomDave Oct 22, 2026
9f3fdec
fix(core): don't treat a dependency stopped by hand as ready
PhantomDave Oct 22, 2026
91929a2
fix(core): time out command healthchecks that hang
PhantomDave Oct 22, 2026
079a436
fix(core): make command move and membership sync all-or-nothing
PhantomDave Oct 22, 2026
6bf4ebd
fix(core): drain process output before a session closes its DB
PhantomDave Oct 22, 2026
70e42dd
fix(config): reject unknown stop_signal names
PhantomDave Oct 22, 2026
5d33433
fix(core): follow chained ${VAR} references in env values
PhantomDave Oct 22, 2026
e7bbad7
fix(ui,core): let a profile's description be cleared
PhantomDave Oct 22, 2026
8f8f8ff
fix(desktop): don't orphan managed processes when the sidecar is killed
PhantomDave Oct 22, 2026
2f3f02e
fix(desktop): offer updates instead of installing them at startup
PhantomDave Oct 22, 2026
93bc02c
test(core): make the env-var and profile-env tests pass on Windows
PhantomDave Oct 22, 2026
1436d78
fix(core): drop trailing log lines after close instead of waiting to …
PhantomDave Oct 22, 2026
acbcec9
test(core): retry removing the env-var test's temp dir on Windows
PhantomDave Oct 22, 2026
f4f620f
test(core): tolerate Windows' slow release of temp dirs under Bun 1.4.4
PhantomDave Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# HTTP API Reference

The Conductor HTTP API runs on port **4000** (configurable via `CONDUCTOR_PORT` env var when started with `bun run server`). It provides endpoints for configuration management, process control, env var maintenance, and log streaming. All mutating operations write to SQLite and record an audit log entry. CORS is scoped to localhost any port by default.
The Conductor HTTP API listens on `127.0.0.1` (loopback only), port **4000** (configurable via `CONDUCTOR_PORT` env var when started with `bun run server`). It provides endpoints for configuration management, process control, env var maintenance, and log streaming. All mutating operations write to SQLite and record an audit log entry. CORS is scoped to localhost any port by default.

## Base URL / Port

Expand Down
7 changes: 4 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,12 @@ conductor/
│ │ ├── manager.ts WorkspaceManager: open/close/switch, installs a session into the API deps
│ │ └── recent.ts workspaces.json recent list (newest first, max 20)
│ ├── mcp/ MCP endpoint at /mcp (see "MCP" below)
│ │ ├── server.ts checkMcpRequest loopback guard + registerMcp (stateless Streamable HTTP)
│ │ ├── server.ts registerMcp (stateless Streamable HTTP)
│ │ ├── tools.ts TOOLS registry: 47 tools, defined per area in tools/*.ts
│ │ ├── route.ts defineTool, R/D/M annotations, callRoute (dispatch via app.inject)
│ │ └── endpoint-file.ts ~/.conductor/endpoint.json: written on listen, removed on shutdown
│ ├── api.ts Fastify 5 HTTP server (~876 lines)
│ ├── local-guard.ts checkLocalRequest: loopback peer/Host/Origin guard for every route
│ └── index.ts 15-line barrel export
├── packages/cli/src/ CLI commands (Commander v15)
│ ├── config-context.ts CLI → config store bridge
Expand Down Expand Up @@ -130,7 +131,7 @@ The broadcaster at `packages/core/src/logs/broadcaster.ts` uses a pub/sub patter

`packages/core/src/mcp/` exposes the API to AI agents as MCP tools. Each tool is a `defineTool` entry that maps its arguments onto an existing route, and `callRoute` runs that route through `app.inject()` on the same Fastify instance. Validation, the workspace guard and the audit log therefore stay in one place, and a non-2xx response becomes an `isError` tool result. Two kinds of tool have their own logic. `process_wait` polls the process list, and `env_list`, `env_set` and `env_import` wrap `callRoute` to redact secret values (the HTTP routes return them as stored). Its contract is in [API.md](./API.md#mcp-endpoint).

Security: the server binds `0.0.0.0`, so `checkMcpRequest` runs before the transport sees any request. It rejects non-loopback peers and any `Host` or `Origin` that is not a loopback name, with 403.
Security: the server binds `127.0.0.1` only, and `checkLocalRequest` (`packages/core/src/local-guard.ts`) runs as an `onRequest` hook on every route, `/api/*` and `/mcp` alike. It rejects non-loopback peers and any `Host` or `Origin` that is not a loopback name, with 403, which stops DNS rebinding and cross-site POSTs that skip the CORS preflight.

The transport is stateless. Each POST builds a new `McpServer` and `StreamableHTTPServerTransport` (no session ID, JSON responses) and closes them when the response ends. Nothing is shared between requests, so there is no session table to leak or to expire.

Expand Down Expand Up @@ -171,7 +172,7 @@ profiles:

## Desktop App Architecture (packages/desktop-tauri)

`packages/desktop` (Electron) is retired — no source left, just stale build output; `packages/desktop-tauri` (Tauri 2) is the desktop shell. The core engine still compiles to the same platform-specific sidecar binary (`bun build --compile → packages/core/dist-bin/conductor-server`); `scripts/stage-sidecar.mjs` copies it into `src-tauri/binaries/` with the target-triple suffix Tauri's `externalBin` mechanism expects. The Rust host (`src-tauri/src/main.rs`) spawns that sidecar and sets `CONDUCTOR_UI_DIST` on it, pointing at the Vite-built `packages/ui/dist/` — same same-origin trick as before, just launched by Rust instead of Electron's main process. Auto-update goes through Tauri's updater plugin against GitHub Releases (`tauri.conf.json`'s `plugins.updater`, signed with a minisign key) instead of electron-builder.
`packages/desktop` (Electron) is retired — no source left, just stale build output; `packages/desktop-tauri` (Tauri 2) is the desktop shell. The core engine still compiles to the same platform-specific sidecar binary (`bun build --compile → packages/core/dist-bin/conductor-server`); `scripts/stage-sidecar.mjs` copies it into `src-tauri/binaries/` with the target-triple suffix Tauri's `externalBin` mechanism expects. The Rust host (`src-tauri/src/main.rs`) spawns that sidecar and sets `CONDUCTOR_UI_DIST` on it, pointing at the Vite-built `packages/ui/dist/` — same same-origin trick as before, just launched by Rust instead of Electron's main process. Updates go through Tauri's updater plugin against GitHub Releases (`tauri.conf.json`'s `plugins.updater`, signed with a minisign key) instead of electron-builder. Nothing installs on its own: the UI checks on load, shows a notice when a release is newer, and the user installs it (which restarts the app) from Environment → About.

## Monitoring

Expand Down
16 changes: 8 additions & 8 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Every field except `id`, `name`, and `run` has a default. **Commands live at the
| `watch` | string[] | `[]` | Globs relative to `cwd` (e.g. `src/**`, `*.csproj`). A matching change restarts the command after 500ms of quiet, then restarts every **running** command that transitively depends on it. Changes during a restart collapse into one follow-up restart. Build/tool dirs (`node_modules`, `.git`, `dist`, `obj`, `target`, `.next`, `.turbo`, `coverage`, `__pycache__`, `.venv`) and lockfiles (`package-lock.json`, `bun.lock`, `yarn.lock`, `pnpm-lock.yaml`, `Cargo.lock`, …) are always ignored. A command you stopped stays stopped. |
| `config_files` | (string \| ConfigFile)[] | `[]` | `.env` files kept in sync with the env before every start. See [Declared config files](#declared-config-files). |
| `readonly` | boolean | `false` | Informational flag; not enforced by the engine. |
| `stop_signal` | string | `"SIGTERM"` | Signal sent during graceful shutdown. Also accepts `SIGINT`, `SIGHUP`, etc. |
| `stop_signal` | string | `"SIGTERM"` | Signal sent during graceful shutdown. Also accepts `SIGINT`, `SIGHUP`, etc. An unknown name (e.g. `TERM`) is a config error. |
| `stop_timeout_ms` | number | `5000` | Time before force-kill (SIGKILL or Windows taskkill). |
| `stop_command` | string | _none_ | A command to run **before** stop_signal; useful for Docker Compose cleanup. |
| `restart` | `manual` \| `on_failure` \| `always` | `"manual"` | Auto-restart policy, keyed on process **exit code**. `on_failure` respawns only on a non-zero exit; `always` respawns any exit. A stop or restart you asked for never counts as a crash. Capped at 5 consecutive restarts with exponential backoff (1s → 30s); the budget resets after 60s of uptime or on a manual restart. |
Expand Down Expand Up @@ -108,13 +108,13 @@ Any environment variable whose **name** appears in the top-level `env_secrets` a

Choose one of five types per command:

| Type | Description | Required Fields |
| ------------------ | --------------------------------------------------------------------------- | ---------------------------- |
| `"none"` (default) | Consider the process healthy immediately after spawning. No waiting. | _none_ |
| `"port"` | Wait for a TCP port to accept connections. Socket timeout: 2 s. | `port` (number) |
| `"http"` | Wait for an HTTP endpoint to respond with status < 500. Fetch timeout: 2 s. | `url` (string, complete URL) |
| `"command"` | Execute a shell command and wait for exit code 0. | `command` (string) |
| `"log_line"` | Wait for a stdout/stderr line containing a substring. | `pattern` (string) |
| Type | Description | Required Fields |
| ------------------ | ------------------------------------------------------------------------------------------------------------ | ---------------------------- |
| `"none"` (default) | Consider the process healthy immediately after spawning. No waiting. | _none_ |
| `"port"` | Wait for a TCP port to accept connections. Socket timeout: 2 s. | `port` (number) |
| `"http"` | Wait for an HTTP endpoint to respond with status < 500. Fetch timeout: 2 s. | `url` (string, complete URL) |
| `"command"` | Execute a shell command and wait for exit code 0. Each run is killed after 10 s (or `timeout_ms`, if lower). | `command` (string) |
| `"log_line"` | Wait for a stdout/stderr line containing a substring. | `pattern` (string) |

`log_line` polls the process's own output (already collected for logging) rather than the network or a subprocess: on each of the `retries` attempts, spaced `interval_ms` apart, it checks whether any line since startup has contained `pattern`. This suits a service that only announces readiness on stdout (e.g. a dev server printing `compiled successfully`) with no port or endpoint to probe yet. It's a one-shot signal — a line either appeared or it didn't, and it can't un-appear — so unlike the other types, it does not run in the continuous post-startup health monitor.

Expand Down
4 changes: 3 additions & 1 deletion packages/core/bin/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ async function main() {

const app = await buildApi(deps);

await app.listen({ port: PORT, host: "0.0.0.0" });
// Loopback only: the API can create and run arbitrary shell commands and
// has no authentication, so it must never be reachable from the network.
await app.listen({ port: PORT, host: "127.0.0.1" });
// PORT 0 asks the OS for a free port; advertise the one actually bound.
const address = app.server.address();
const boundPort = typeof address === "object" && address ? address.port : PORT;
Expand Down
44 changes: 26 additions & 18 deletions packages/core/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { listAvailableShells } from "./executor/shell";
import { parseDockerCompose } from "./docker-compose/parser";
import { WorkspaceBusyError, type WorkspaceManager } from "./workspace/manager";
import { registerMcp } from "./mcp/server";
import { checkLocalRequest } from "./local-guard";
import {
CommandInputSchema,
CommandPatchSchema,
Expand Down Expand Up @@ -104,6 +105,20 @@ export async function buildApi(deps: ApiDependencies): Promise<FastifyInstance>
origin: /^https?:\/\/localhost(:\d+)?$/,
});

// Callback style for the same Bun reason as the workspace guard below.
app.addHook("onRequest", (request, reply, done) => {
const reason = checkLocalRequest({
remoteAddress: request.socket.remoteAddress,
host: request.headers.host,
origin: request.headers.origin,
});
if (reason) {
reply.status(403).send({ error: "forbidden" });
return;
}
done();
});

const workspaces = deps.workspaces;
if (workspaces) {
// Registered right after cors so preflights are answered and 409s still
Expand Down Expand Up @@ -535,21 +550,15 @@ export async function buildApi(deps: ApiDependencies): Promise<FastifyInstance>
}
try {
const profileName = request.params.profile;

// Add commands
if (parsed.data.add?.length) {
for (const cmdId of parsed.data.add) {
deps.store.addCommandToProfile(profileName, cmdId);
deps.queries.insertAuditEntry("sync-command-to-profile", `${profileName}/${cmdId}`);
}
const add = parsed.data.add ?? [];
const remove = parsed.data.remove ?? [];
// One write: a bad id rejects the whole batch instead of half of it.
deps.store.syncProfileCommands(profileName, add, remove);
for (const cmdId of add) {
deps.queries.insertAuditEntry("sync-command-to-profile", `${profileName}/${cmdId}`);
}

// Remove commands
if (parsed.data.remove?.length) {
for (const cmdId of parsed.data.remove) {
deps.store.removeCommandFromProfile(profileName, cmdId);
deps.queries.insertAuditEntry("sync-remove-command", `${profileName}/${cmdId}`);
}
for (const cmdId of remove) {
deps.queries.insertAuditEntry("sync-remove-command", `${profileName}/${cmdId}`);
}

return { success: true };
Expand Down Expand Up @@ -608,13 +617,11 @@ export async function buildApi(deps: ApiDependencies): Promise<FastifyInstance>
.send({ error: body.error.issues[0]?.message ?? "Invalid request" });
}
try {
// Remove from source profile and add to target profile
deps.store.removeCommandFromProfile(request.params.profile, request.params.id);
deps.store.addCommandToProfile(body.data.targetProfile, request.params.id);
const command = deps.store.getCommand(request.params.id);
if (!command) {
return reply.status(404).send({ error: `Command "${request.params.id}" not found` });
}
deps.store.moveCommand(request.params.profile, body.data.targetProfile, command.id);
deps.queries.insertAuditEntry(
"move-command",
`${request.params.profile}/${request.params.id} -> ${body.data.targetProfile}`,
Expand Down Expand Up @@ -1058,7 +1065,8 @@ export async function buildApi(deps: ApiDependencies): Promise<FastifyInstance>
});

// MCP endpoint for AI agents. Not under /api/, so the workspace guard does
// not apply to the endpoint itself (tools dispatch back through /api/*, where it does).
// not apply to the endpoint itself (tools dispatch back through /api/*, where
// it does); the loopback guard above covers it like every other route.
// Registered before the static/SPA block so its not-found handler can't swallow it.
await registerMcp(app);

Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/config/schema.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { constants } from "node:os";
import { z } from "zod";

/**
Expand Down Expand Up @@ -55,7 +56,15 @@ export const CommandSchema = z.object({
watch: z.array(z.string()).default([]),
config_files: z.array(ConfigFileSchema).default([]),
readonly: z.boolean().default(false),
stop_signal: z.string().default("SIGTERM"),
// Checked here: process.kill throws on an unknown name, and the stop path
// can't tell that apart from "already gone", so a typo silently skipped
// the graceful stop and went straight to SIGKILL after stop_timeout_ms.
stop_signal: z
.string()
.refine((name) => Object.hasOwn(constants.signals, name), {
message: "Unknown signal; use a name like SIGTERM or SIGINT",
})
.default("SIGTERM"),
stop_timeout_ms: z.number().int().positive().default(5_000),
stop_command: z.string().min(1).optional(),
restart: z.enum(["manual", "on_failure", "always"]).default("manual"),
Expand Down
Loading
Loading