Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
33 changes: 32 additions & 1 deletion apps/cli/src/daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,38 @@ import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import * as Effect from "effect/Effect";

import { canAutoStartLocalDaemonForHost, isExecutorServerReachable } from "./daemon";
import {
canAutoStartLocalDaemonForHost,
isDevCliEntrypoint,
isExecutorServerReachable,
} from "./daemon";

describe("isDevCliEntrypoint", () => {
it("treats source entrypoints as dev", () => {
expect(isDevCliEntrypoint("/Users/x/src/executor/apps/cli/src/main.ts")).toBe(true);
expect(isDevCliEntrypoint("/Users/x/dist/main.js")).toBe(true);
});

it("treats compiled single-file binaries as NOT dev (both Unix and Windows)", () => {
// Bun's embedded filesystem: `/$bunfs/...` on Unix, `B:\~BUN\...` on Windows.
// Missing the Windows form made a real `executor.exe` look like a dev
// checkout, so `service install` wrongly refused on Windows.
expect(isDevCliEntrypoint("/$bunfs/root/main.js")).toBe(false);
expect(isDevCliEntrypoint("B:/~BUN/root/main.js")).toBe(false);
expect(isDevCliEntrypoint("B:\\~BUN\\root\\main.js")).toBe(false);
});

it("only treats a DRIVE-ROOTED ~BUN as compiled (a ~BUN dir mid-tree stays dev)", () => {
// The Windows bunfs root is `<drive>:\~BUN\...`; a dev checkout that merely
// contains a `~BUN` directory must not be misread as a compiled binary.
expect(isDevCliEntrypoint("/home/user/~BUN/project/src/main.ts")).toBe(true);
expect(isDevCliEntrypoint("C:/Users/dev/~BUN/src/main.ts")).toBe(true);
});

it("is false when no entrypoint is known", () => {
expect(isDevCliEntrypoint(undefined)).toBe(false);
});
});

describe("canAutoStartLocalDaemonForHost", () => {
it("allows loopback hosts", () => {
Expand Down
23 changes: 20 additions & 3 deletions apps/cli/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,32 @@ export const parseDaemonBaseUrl = (baseUrl: string, defaultPort: number): Parsed
// ---------------------------------------------------------------------------

const LOCAL_DAEMON_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
const BUN_EMBEDDED_ENTRYPOINT_PREFIX = "/$bunfs/";

export const canAutoStartLocalDaemonForHost = (hostname: string): boolean =>
LOCAL_DAEMON_HOSTNAMES.has(hostname.toLowerCase());

/**
* Bun's compiled-binary embedded filesystem root, drive-rooted on Windows
* (`B:\~BUN\root\...`, argv normalized to `B:/~BUN/root/...`). Anchored to a
* drive prefix so a dev checkout that merely *contains* a `~BUN` directory
* isn't misread as a compiled binary.
*/
const WINDOWS_BUNFS_ENTRYPOINT = /^[a-z]:\/~BUN\//i;

/**
* Whether the process is running from the dev source (`bun run src/main.ts`)
* rather than a compiled single-file binary. A compiled binary runs from Bun's
* embedded filesystem, whose entrypoint is `/$bunfs/root/main.js` on Unix but
* `B:\~BUN\root\main.js` (argv like `B:/~BUN/root/main.js`) on Windows — match
* BOTH. Missing the Windows form made a real `executor.exe` look like a dev
* checkout, so `service install` refused on Windows. (Found by a real EC2
* Windows test.)
*/
export const isDevCliEntrypoint = (scriptPath: string | undefined): boolean => {
if (!scriptPath) return false;
if (scriptPath.startsWith(BUN_EMBEDDED_ENTRYPOINT_PREFIX)) return false;
return scriptPath.endsWith(".ts") || scriptPath.endsWith(".js");
const normalized = scriptPath.replaceAll("\\", "/");
if (normalized.startsWith("/$bunfs/") || WINDOWS_BUNFS_ENTRYPOINT.test(normalized)) return false;
return normalized.endsWith(".ts") || normalized.endsWith(".js");
};

export const isExecutorServerReachable = (
Expand Down
19 changes: 19 additions & 0 deletions apps/cli/src/local-server-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,25 @@ export const removeLocalServerManifestIfOwnedBy = (input: {
yield* fs.remove(manifestPath, { force: true });
});

/**
* Remove the server manifest unconditionally. Used by an OS-supervised daemon
* to reclaim a stale `server.json` left by a previous boot: across a reboot the
* recorded pid is meaningless (pids recycle, so it may now belong to an
* unrelated process), and launchd/systemd already guarantee a single supervised
* instance — so any pre-existing manifest is stale and the supervised daemon
* owns it.
*/
export const removeLocalServerManifest = (): Effect.Effect<
void,
PlatformError,
FileSystem.FileSystem | Path.Path
> =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
yield* fs.remove(localServerManifestPath(path), { force: true });
});

const StartupLockPayload = Schema.Struct({
pid: Schema.Number,
});
Expand Down
193 changes: 159 additions & 34 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,12 @@ import {
acquireLocalServerStartLock,
readLocalServerManifest,
releaseLocalServerStartLock,
removeLocalServerManifest,
removeLocalServerManifestIfOwnedBy,
resolveExecutorDataDir,
writeLocalServerManifest,
} from "./local-server-manifest";
import { DEFAULT_SERVICE_PORT, getServiceBackend, SERVICE_LABEL } from "./service";
import {
defaultCliServerConnectionProfile,
findCliServerConnectionProfile,
Expand All @@ -115,7 +117,6 @@ import {
} from "./server-profile";
import {
buildResumeContentTemplate,
buildToolPath,
buildDescribeToolCode,
filterToolPathChildren,
buildInvokeToolCode,
Expand All @@ -127,6 +128,7 @@ import {
inspectToolPath,
normalizeCliErrorText,
parseJsonObjectInput,
resolveToolInvocation,
sanitizeCliOutputText,
shellQuoteArg,
} from "./tooling";
Expand Down Expand Up @@ -886,7 +888,19 @@ const runDaemonSession = (input: {
let token: string | null = null;

try {
yield* assertNoOtherActiveLocalServer();
// A supervised daemon (launchd/systemd) is the OS-guaranteed singleton
// — kickstart -k kills the old instance before starting the new — so any
// server.json from a previous boot is stale. Reclaim it rather than
// refusing: across a reboot the recorded pid may have been recycled by
// an unrelated process, which would otherwise make the "is one already
// running?" check treat it as alive-but-unreachable, refuse to start,
// and crash-loop under KeepAlive. (Found by a real reboot test with
// integration data in the DB.)
if (process.env.EXECUTOR_SUPERVISED) {
yield* removeLocalServerManifest().pipe(Effect.ignore);
} else {
yield* assertNoOtherActiveLocalServer();
}

const existing = yield* readDaemonPointer({ hostname: daemonHost, scopeId });

Expand Down Expand Up @@ -1572,38 +1586,6 @@ const runCallHelp = (
});
}).pipe(Effect.mapError(toError));

const resolveToolInvocation = (input: {
rawPathParts: ReadonlyArray<string>;
}): Effect.Effect<{ path: string; args: Record<string, unknown> }, Error> =>
Effect.gen(function* () {
if (!Array.isArray(input.rawPathParts)) {
return yield* Effect.fail(
new Error("Invalid tool invocation: path parts were not parsed as an array"),
);
}

const maybeJsonArg = input.rawPathParts.at(-1)?.trim();
const hasInlineJsonArg = maybeJsonArg !== undefined && maybeJsonArg.startsWith("{");
const pathParts = hasInlineJsonArg ? input.rawPathParts.slice(0, -1) : input.rawPathParts;
const args = hasInlineJsonArg ? yield* parseJsonObjectInput(maybeJsonArg) : {};

if (pathParts.some((part) => part.trim().startsWith("-"))) {
return yield* Effect.fail(
new Error(
"Tool invocation no longer accepts flags. Use: executor call <path...> '{...json...}'",
),
);
}

const path = yield* Effect.try({
try: () => buildToolPath(pathParts),
catch: (cause) =>
cause instanceof Error ? cause : new Error(`Invalid tool path: ${String(cause)}`),
});

return { path, args };
});

// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -2000,6 +1982,10 @@ const daemonRunCommand = Command.make(
Effect.gen(function* () {
applyScope(scope);
if (foreground) {
// The foreground daemon is the form OS service managers run. Its bearer
// comes from --auth-token, else the stable token in auth.json (loaded by
// startServer from EXECUTOR_DATA_DIR) — the supervised unit carries no
// secret, so the daemon and its clients share the one auth.json token.
yield* runDaemonSession({
port,
hostname,
Expand Down Expand Up @@ -2116,6 +2102,144 @@ const mcpCommand = Command.make(
}),
).pipe(Command.withDescription("Start an MCP server over stdio"));

// ---------------------------------------------------------------------------
// Service — register the daemon with the OS so it survives app-quit + restart
// ---------------------------------------------------------------------------

const supervisedServiceOrigin = (port: number): string => `http://127.0.0.1:${port}`;

const serviceInstallCommand = Command.make(
"install",
{
port: Options.integer("port")
.pipe(Options.withDefault(DEFAULT_SERVICE_PORT))
.pipe(Options.withDescription("Port the supervised daemon binds (loopback only).")),
},
({ port }) =>
Effect.gen(function* () {
if (isDevMode) {
return yield* Effect.fail(
new Error(
[
"`service install` requires the compiled `executor` binary so the OS can run it directly.",
`In a dev checkout, run \`${cliPrefix} daemon run --foreground\` instead.`,
].join("\n"),
),
);
}

const backend = getServiceBackend();
if (!backend.automated) {
// Unsupported platforms surface their manual steps via the install error.
yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });
return;
}

// Don't fight an already-running local server against the same data dir
// (a desktop sidecar, a foreground `executor web`, or an existing daemon).
const active = yield* readActiveLocalServerManifest();
if (active) {
const status = yield* backend.status();
if (status.registered && status.running && active.kind === "cli-daemon") {
console.log(
`Executor service already running at ${active.connection.origin} (pid ${active.pid}).`,
);
return;
}
return yield* Effect.fail(
new Error(
[
`A local Executor ${active.kind} is already running at ${active.connection.origin} (pid ${active.pid}).`,
`Stop it first (quit the desktop app, or \`${cliPrefix} daemon stop\`), then re-run install.`,
].join("\n"),
),
);
}

// The unit carries no secret: the supervised daemon mints/loads its bearer
// from auth.json (under EXECUTOR_DATA_DIR) on first boot, and clients read
// the same file — so reachability is the credential-free /api/health probe.
yield* backend.install({ executablePath: process.execPath, port, version: CLI_VERSION });

const origin = supervisedServiceOrigin(port);
const reachable = yield* waitForReachable({
check: isServerReachable(origin),
timeoutMs: DAEMON_BOOT_TIMEOUT_MS,
intervalMs: DAEMON_BOOT_POLL_MS,
});
if (!reachable) {
return yield* Effect.fail(
new Error(
[
`Installed ${SERVICE_LABEL} but it did not become reachable at ${origin} within ${DAEMON_BOOT_TIMEOUT_MS / 1000}s.`,
`Check ~/.executor/logs/daemon.error.log and \`${cliPrefix} service status\`.`,
].join("\n"),
),
);
}

console.log(`Executor is now running as a background service at ${origin}.`);
console.log("It keeps serving after you quit the app and restarts on login.");
console.log(`Open it in your browser, already signed in, with: ${cliPrefix} open`);
}),
).pipe(
Command.withDescription("Install and start Executor as an OS-supervised background service"),
);

const serviceUninstallCommand = Command.make("uninstall", {}, () =>
Effect.gen(function* () {
const backend = getServiceBackend();
yield* backend.uninstall();
console.log("Executor background service uninstalled.");
}),
).pipe(Command.withDescription("Stop and remove the OS-supervised background service"));

const serviceStatusCommand = Command.make("status", {}, () =>
Effect.gen(function* () {
const backend = getServiceBackend();
const status = yield* backend.status();
// Tolerate a registered-but-unreachable manifest here — status shouldn't throw.
const active = yield* readActiveLocalServerManifest().pipe(
Effect.catchCause(() => Effect.succeed(null)),
);
console.log(`Platform: ${status.platform}`);
console.log(`Registered: ${status.registered ? "yes" : "no"}`);
console.log(
`Running: ${status.running ? "yes" : "no"}${status.pid ? ` (pid ${status.pid})` : ""}`,
);
if (active) {
console.log(`Serving: ${active.connection.origin} (${active.kind}, pid ${active.pid})`);
// Version drift: the running daemon was launched by the binary the unit
// points at. If that differs from this CLI, an upgrade left the unit
// pointing at an older binary — reinstall to repoint + restart.
if (active.owner.version && active.owner.version !== CLI_VERSION) {
console.log(
`Drift: running ${active.owner.version}, current ${CLI_VERSION} — run \`${cliPrefix} service install\` to upgrade.`,
);
}
}
for (const line of status.detail) console.log(line);
}),
).pipe(Command.withDescription("Show the OS-supervised service status"));

const serviceRestartCommand = Command.make("restart", {}, () =>
Effect.gen(function* () {
const backend = getServiceBackend();
yield* backend.restart();
console.log("Executor background service restarted.");
}),
).pipe(Command.withDescription("Restart the OS-supervised background service"));

const serviceCommand = Command.make("service").pipe(
Command.withSubcommands([
serviceInstallCommand,
serviceUninstallCommand,
serviceStatusCommand,
serviceRestartCommand,
] as const),
Command.withDescription("Manage the OS-supervised background service"),
);

// ---------------------------------------------------------------------------
// Root command
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -2170,6 +2294,7 @@ const root = Command.make("executor").pipe(
serverCommand,
webCommand,
daemonCommand,
serviceCommand,
mcpCommand,
openCommand,
] as const),
Expand Down
Loading
Loading