History

Teach CLI and local server shared connections (#865)

* Teach CLI and local server shared connections * Use schemas for CLI server profile parsing

Rhys Sullivan committed 5 months ago

Commit 10b7eda1 parent 1b5618611 files · +1685 −146Browse files

Unified · Split

11 files
  1. apps/cli/package.json +5 −1
  2. apps/cli/src/local-server-manifest.test.ts +100 −0
  3. apps/cli/src/local-server-manifest.ts +130 −0
  4. apps/cli/src/main.ts +705 −131
  5. apps/cli/src/server-connection.test.ts +152 −0
  6. apps/cli/src/server-connection.ts +111 −0
  7. apps/cli/src/server-profile.test.ts +131 −0
  8. apps/cli/src/server-profile.ts +217 −0
  9. apps/local/src/serve.test.ts +74 −0
  10. apps/local/src/serve.ts +55 −14
  11. bun.lock +5 −0
apps/cli/package.json+5−1
@@ -18,6 +18,7 @@ · 17 unmodified lines
1818 "build:publish": "bun run src/build.ts publish",
1919 "release:publish:dry-run": "bun run src/release.ts --dry-run",
2020 "release:publish": "bun run src/release.ts",
21+ "test": "bun --bun vitest run",
2122 "typecheck": "tsgo --noEmit",
2223 "typecheck:slow": "tsc --noEmit"
2324 },
@@ -27,12 +28,15 @@ · 3 unmodified lines
2728 "@executor-js/integrations-registry": "workspace:*",
2829 "@executor-js/local": "workspace:*",
2930 "@executor-js/runtime-quickjs": "workspace:*",
31+ "@executor-js/sdk": "workspace:*",
3032 "@jitl/quickjs-wasmfile-release-sync": "catalog:",
3133 "effect": "catalog:",
3234 "quickjs-emscripten": "catalog:"
3335 },
3436 "devDependencies": {
37+ "@effect/vitest": "catalog:",
3538 "bun-types": "catalog:",
36− "typescript": "catalog:"
39+ "typescript": "catalog:",
40+ "vitest": "catalog:"
3741 }
3842 }
apps/cli/src/local-server-manifest.test.ts+100−0
@@ -0,0 +1,100 @@
1+import { afterEach, describe, expect, it } from "@effect/vitest";
2+import { BunServices } from "@effect/platform-bun";
3+import { mkdtempSync, rmSync } from "node:fs";
4+import { tmpdir } from "node:os";
5+import { join } from "node:path";
6+import { Path } from "effect";
7+import * as Effect from "effect/Effect";
8+import * as Exit from "effect/Exit";
9+
10+import { normalizeExecutorServerConnection } from "@executor-js/sdk/shared";
11+import {
12+ acquireLocalServerStartLock,
13+ readLocalServerManifest,
14+ releaseLocalServerStartLock,
15+ removeLocalServerManifestIfOwnedBy,
16+ resolveExecutorDataDir,
17+ writeLocalServerManifest,
18+} from "./local-server-manifest";
19+
20+const previousDataDir = process.env.EXECUTOR_DATA_DIR;
21+
22+afterEach(() => {
23+ if (previousDataDir === undefined) {
24+ delete process.env.EXECUTOR_DATA_DIR;
25+ } else {
26+ process.env.EXECUTOR_DATA_DIR = previousDataDir;
27+ }
28+});
29+
30+describe("local server manifest", () => {
31+ it.effect("round-trips the active local server owner", () =>
32+ Effect.gen(function* () {
33+ const dataDir = mkdtempSync(join(tmpdir(), "executor-local-server-"));
34+ process.env.EXECUTOR_DATA_DIR = dataDir;
35+
36+ try {
37+ const manifest = {
38+ version: 1 as const,
39+ kind: "cli-daemon" as const,
40+ pid: process.pid,
41+ startedAt: "2026-05-28T00:00:00.000Z",
42+ dataDir,
43+ scopeDir: dataDir,
44+ connection: normalizeExecutorServerConnection({
45+ origin: "http://localhost:4788",
46+ }),
47+ owner: {
48+ client: "cli" as const,
49+ version: "1.2.3",
50+ executablePath: "/usr/local/bin/executor",
51+ },
52+ };
53+
54+ yield* writeLocalServerManifest(manifest);
55+ expect((yield* readLocalServerManifest())?.connection.origin).toBe("http://localhost:4788");
56+
57+ yield* removeLocalServerManifestIfOwnedBy({ pid: process.pid + 1 });
58+ expect(yield* readLocalServerManifest()).not.toBeNull();
59+
60+ yield* removeLocalServerManifestIfOwnedBy({ pid: process.pid });
61+ expect(yield* readLocalServerManifest()).toBeNull();
62+ } finally {
63+ rmSync(dataDir, { recursive: true, force: true });
64+ }
65+ }).pipe(Effect.provide(BunServices.layer)),
66+ );
67+
68+ it.effect("serializes local startup with a stale-aware lock", () =>
69+ Effect.gen(function* () {
70+ const dataDir = mkdtempSync(join(tmpdir(), "executor-local-server-lock-"));
71+ process.env.EXECUTOR_DATA_DIR = dataDir;
72+
73+ try {
74+ const first = yield* acquireLocalServerStartLock();
75+ const second = yield* Effect.exit(acquireLocalServerStartLock());
76+
77+ expect(Exit.isFailure(second)).toBe(true);
78+ yield* releaseLocalServerStartLock(first);
79+ const third = yield* acquireLocalServerStartLock();
80+ yield* releaseLocalServerStartLock(third);
81+ } finally {
82+ rmSync(dataDir, { recursive: true, force: true });
83+ }
84+ }).pipe(Effect.provide(BunServices.layer)),
85+ );
86+
87+ it.effect("resolves the data dir from EXECUTOR_DATA_DIR", () =>
88+ Effect.gen(function* () {
89+ const dataDir = mkdtempSync(join(tmpdir(), "executor-local-server-dir-"));
90+ process.env.EXECUTOR_DATA_DIR = dataDir;
91+
92+ try {
93+ const path = yield* Path.Path;
94+ expect(resolveExecutorDataDir(path)).toBe(dataDir);
95+ } finally {
96+ rmSync(dataDir, { recursive: true, force: true });
97+ }
98+ }).pipe(Effect.provide(BunServices.layer)),
99+ );
100+});
apps/cli/src/local-server-manifest.ts+130−0
@@ -0,0 +1,130 @@
1+import { homedir } from "node:os";
2+import { resolve } from "node:path";
3+import { FileSystem, Option, Path, Schema } from "effect";
4+import type { PlatformError } from "effect/PlatformError";
5+import * as Effect from "effect/Effect";
6+
7+import {
8+ parseExecutorLocalServerManifest,
9+ serializeExecutorLocalServerManifest,
10+ type ExecutorLocalServerManifest,
11+} from "@executor-js/sdk/shared";
12+import { isPidAlive } from "./daemon-state";
13+
14+export interface LocalServerStartLock {
15+ readonly path: string;
16+}
17+
18+export const resolveExecutorDataDir = (path: Path.Path): string =>
19+ resolve(process.env.EXECUTOR_DATA_DIR ?? path.join(homedir(), ".executor"));
20+
21+const serverControlDir = (path: Path.Path): string =>
22+ path.join(resolveExecutorDataDir(path), "server-control");
23+
24+const localServerManifestPath = (path: Path.Path): string =>
25+ path.join(serverControlDir(path), "server.json");
26+
27+const localServerStartLockPath = (path: Path.Path): string =>
28+ path.join(serverControlDir(path), "startup.lock");
29+
30+export const readLocalServerManifest = (): Effect.Effect<
31+ ExecutorLocalServerManifest | null,
32+ never,
33+ FileSystem.FileSystem | Path.Path
34+> =>
35+ Effect.gen(function* () {
36+ const fs = yield* FileSystem.FileSystem;
37+ const path = yield* Path.Path;
38+ const raw = yield* fs
39+ .readFileString(localServerManifestPath(path))
40+ .pipe(Effect.catchCause(() => Effect.succeed(null)));
41+ if (raw === null) return null;
42+ return parseExecutorLocalServerManifest(raw);
43+ });
44+
45+export const writeLocalServerManifest = (
46+ manifest: ExecutorLocalServerManifest,
47+): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
48+ Effect.gen(function* () {
49+ const fs = yield* FileSystem.FileSystem;
50+ const path = yield* Path.Path;
51+ yield* fs.makeDirectory(serverControlDir(path), { recursive: true });
52+ yield* fs.writeFileString(
53+ localServerManifestPath(path),
54+ serializeExecutorLocalServerManifest(manifest),
55+ );
56+ });
57+
58+export const removeLocalServerManifestIfOwnedBy = (input: {
59+ readonly pid: number;
60+}): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
61+ Effect.gen(function* () {
62+ const fs = yield* FileSystem.FileSystem;
63+ const path = yield* Path.Path;
64+ const manifestPath = localServerManifestPath(path);
65+ const raw = yield* fs
66+ .readFileString(manifestPath)
67+ .pipe(Effect.catchCause(() => Effect.succeed(null)));
68+ if (raw === null) return;
69+ const manifest = parseExecutorLocalServerManifest(raw);
70+ if (manifest?.pid !== input.pid) return;
71+ yield* fs.remove(manifestPath, { force: true });
72+ });
73+
74+const StartupLockPayload = Schema.Struct({
75+ pid: Schema.Number,
76+});
77+
78+const decodeStartupLockPayload = Schema.decodeUnknownOption(
79+ Schema.fromJsonString(StartupLockPayload),
80+);
81+
82+const parseLockPid = (raw: string): number | null => {
83+ const decoded = decodeStartupLockPayload(raw);
84+ return Option.isSome(decoded) ? decoded.value.pid : null;
85+};
86+
87+export const acquireLocalServerStartLock = (): Effect.Effect<
88+ LocalServerStartLock,
89+ Error,
90+ FileSystem.FileSystem | Path.Path
91+> =>
92+ Effect.gen(function* () {
93+ const fs = yield* FileSystem.FileSystem;
94+ const path = yield* Path.Path;
95+ yield* fs.makeDirectory(serverControlDir(path), { recursive: true });
96+
97+ const lockPath = localServerStartLockPath(path);
98+ const lockPayload = `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }, null, 2)}\n`;
99+
100+ const tryAcquire = () =>
101+ fs.writeFileString(lockPath, lockPayload, { flag: "wx" }).pipe(
102+ Effect.as(true),
103+ Effect.catchCause(() => Effect.succeed(false)),
104+ );
105+
106+ if (yield* tryAcquire()) return { path: lockPath };
107+
108+ const existingRaw = yield* fs
109+ .readFileString(lockPath)
110+ .pipe(Effect.catchCause(() => Effect.succeed(null)));
111+ if (existingRaw !== null) {
112+ const existingPid = parseLockPid(existingRaw);
113+ if (existingPid !== null && !isPidAlive(existingPid)) {
114+ yield* fs.remove(lockPath, { force: true });
115+ if (yield* tryAcquire()) return { path: lockPath };
116+ }
117+ }
118+
119+ return yield* Effect.fail(
120+ new Error("Another local Executor server startup is already in progress."),
121+ );
122+ });
123+
124+export const releaseLocalServerStartLock = (
125+ lock: LocalServerStartLock,
126+): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
127+ Effect.gen(function* () {
128+ const fs = yield* FileSystem.FileSystem;
129+ yield* fs.remove(lock.path, { force: true });
130+ });
apps/cli/src/main.ts+705−131
@@ -52,13 +52,23 @@ if (typeof Bun !== "undefined" && (await Bun.file(wasmOnDisk).exists())) { · 51 unmodified lines
5252 import { Argument as Args, Command, Flag as Options } from "effect/unstable/cli";
5353 import { BunRuntime, BunServices } from "@effect/platform-bun";
5454 import { HttpApiClient } from "effect/unstable/httpapi";
55−import { FetchHttpClient } from "effect/unstable/http";
55+import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
5656 import { FileSystem, Path as PlatformPath } from "effect";
57+import type { PlatformError } from "effect/PlatformError";
5758 import * as Effect from "effect/Effect";
5859 import * as Option from "effect/Option";
5960 import * as Cause from "effect/Cause";
6061
6162 import { ExecutorApi } from "@executor-js/api";
63+import {
64+ DEFAULT_EXECUTOR_SERVER_USERNAME,
65+ getExecutorServerAuthorizationHeader,
66+ normalizeExecutorServerConnection,
67+ type ExecutorLocalServerKind,
68+ type ExecutorLocalServerManifest,
69+ type ExecutorServerConnection,
70+ type ExecutorServerConnectionInput,
71+} from "@executor-js/sdk/shared";
6272 import { startServer, runMcpStdioServer, getExecutor } from "@executor-js/local";
6373 import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs";
6474 import { fetchIntegrations } from "./integrations";
@@ -86,6 +96,30 @@ import { · 21 unmodified lines
8696 writeDaemonPointer,
8797 writeDaemonRecord,
8898 } from "./daemon-state";
99+import {
100+ canAutoStartCliServerConnection,
101+ chooseCliServerConnectionWithActiveLocal,
102+ parseCliExecutorServerConnection,
103+ type CliServerConnectionSource,
104+ withCliServerAuthFallback,
105+} from "./server-connection";
106+import {
107+ acquireLocalServerStartLock,
108+ readLocalServerManifest,
109+ releaseLocalServerStartLock,
110+ removeLocalServerManifestIfOwnedBy,
111+ resolveExecutorDataDir,
112+ writeLocalServerManifest,
113+} from "./local-server-manifest";
114+import {
115+ defaultCliServerConnectionProfile,
116+ findCliServerConnectionProfile,
117+ readCliServerConnectionStore,
118+ removeCliServerConnectionProfile,
119+ setDefaultCliServerConnectionProfile,
120+ upsertCliServerConnectionProfile,
121+ validateCliServerConnectionProfileName,
122+} from "./server-profile";
89123 import {
90124 buildResumeContentTemplate,
91125 buildToolPath,
@@ -146,9 +180,15 @@ interface DaemonScopeInfo { · 54 unmodified lines
146180 readonly dir: string;
147181 }
148182
149−const readDaemonScopeInfo = (baseUrl: string): Effect.Effect<DaemonScopeInfo | null> =>
183+const readDaemonScopeInfo = (
184+ baseUrl: string,
185+ authorization?: string,
186+): Effect.Effect<DaemonScopeInfo | null> =>
150187 Effect.tryPromise(() =>
151− fetch(`${baseUrl}/api/scope`, { signal: AbortSignal.timeout(2000) }),
188+ fetch(`${baseUrl}/api/scope`, {
189+ ...(authorization ? { headers: { authorization } } : {}),
190+ signal: AbortSignal.timeout(2000),
191+ }),
152192 ).pipe(
153193 Effect.flatMap((res) => {
154194 if (!res.ok) return Effect.succeed(null);
@@ -174,8 +214,38 @@ const readDaemonScopeInfo = (baseUrl: string): Effect.Effect<DaemonScopeInfo | n · 19 unmodified lines
174214 Effect.catchCause(() => Effect.succeed(null)),
175215 );
176216
177−const isServerReachable = (baseUrl: string): Effect.Effect<boolean> =>
178− readDaemonScopeInfo(baseUrl).pipe(Effect.map((scopeInfo) => scopeInfo !== null));
217+const isServerReachable = (baseUrl: string, authorization?: string): Effect.Effect<boolean> =>
218+ readDaemonScopeInfo(baseUrl, authorization).pipe(Effect.map((scopeInfo) => scopeInfo !== null));
219+
220+const readActiveLocalServerManifest = (): Effect.Effect<
221+ ExecutorLocalServerManifest | null,
222+ Error,
223+ FileSystem.FileSystem | PlatformPath.Path
224+> =>
225+ Effect.gen(function* () {
226+ const manifest = yield* readLocalServerManifest();
227+ if (!manifest) return null;
228+
229+ if (!isPidAlive(manifest.pid)) {
230+ yield* removeLocalServerManifestIfOwnedBy({ pid: manifest.pid }).pipe(Effect.ignore);
231+ return null;
232+ }
233+
234+ const authorization = getExecutorServerAuthorizationHeader(manifest.connection) ?? undefined;
235+ if (yield* isServerReachable(manifest.connection.origin, authorization)) {
236+ return manifest;
237+ }
238+
239+ return yield* Effect.fail(
240+ new Error(
241+ [
242+ `A local Executor ${manifest.kind} is registered at ${manifest.connection.origin} (pid ${manifest.pid}) but is not reachable.`,
243+ "Refusing to start another local server against the same data directory.",
244+ "Stop the existing process or remove the stale server-control manifest after verifying the process is not using the database.",
245+ ].join("\n"),
246+ ),
247+ );
248+ });
179249
180250 const normalizeDaemonScopeDir = (dir: string): string => {
181251 const resolved = resolve(dir);
@@ -185,6 +255,9 @@ const normalizeDaemonScopeDir = (dir: string): string => { · 3 unmodified lines
185255 const currentDaemonScopeDir = (): string =>
186256 normalizeDaemonScopeDir(process.env.EXECUTOR_SCOPE_DIR ?? process.cwd());
187257
258+const currentScopeDirForManifest = (): string | null =>
259+ process.env.EXECUTOR_SCOPE_DIR ? normalizeDaemonScopeDir(process.env.EXECUTOR_SCOPE_DIR) : null;
260+
188261 const script = process.argv[1];
189262 const isDevMode = isDevCliEntrypoint(script);
190263 const cliPrefix = isDevMode ? `bun run ${script}` : "executor";
@@ -192,6 +265,21 @@ const cliPrefix = isDevMode ? `bun run ${script}` : "executor"; · 1 unmodified line
192265 const toError = (cause: unknown): Error =>
193266 cause instanceof Error ? cause : new Error(String(cause));
194267
268+interface ServerTarget {
269+ readonly baseUrl?: string;
270+ readonly serverName?: string;
271+}
272+
273+interface RequestedExecutorServerConnection {
274+ readonly connection: ExecutorServerConnection;
275+ readonly source: CliServerConnectionSource;
276+}
277+
278+interface ExecuteCodeResult {
279+ readonly connection: ExecutorServerConnection;
280+ readonly outcome: ExecuteCodeOutcome;
281+}
282+
195283 const parseDaemonUrl = (baseUrl: string) =>
196284 Effect.try({
197285 try: () => parseDaemonBaseUrl(baseUrl, DEFAULT_PORT),
@@ -199,9 +287,81 @@ const parseDaemonUrl = (baseUrl: string) => · 1 unmodified line
199287 cause instanceof Error ? cause : new Error(`Invalid base URL: ${String(cause)}`),
200288 });
201289
290+const parseExecutorServerConnection = (baseUrl: string) =>
291+ Effect.try({
292+ try: () => parseCliExecutorServerConnection(baseUrl),
293+ catch: (cause) =>
294+ cause instanceof Error ? cause : new Error(`Invalid server URL: ${String(cause)}`),
295+ });
296+
202297 const daemonBaseUrl = (hostname: string, port: number): string =>
203298 `http://${canonicalDaemonHost(hostname)}:${port}`;
204299
300+const serverAuthFromInputs = (input: {
301+ readonly authToken: string | undefined;
302+ readonly authPassword: string | undefined;
303+}): ExecutorServerConnection["auth"] | undefined => {
304+ if (input.authPassword) {
305+ return {
306+ kind: "basic",
307+ username: DEFAULT_EXECUTOR_SERVER_USERNAME,
308+ password: input.authPassword,
309+ };
310+ }
311+ if (input.authToken) return { kind: "bearer", token: input.authToken };
312+ return undefined;
313+};
314+
315+const makeLocalServerManifest = (input: {
316+ readonly kind: ExecutorLocalServerKind;
317+ readonly connection: ExecutorServerConnection;
318+}): Effect.Effect<ExecutorLocalServerManifest, never, PlatformPath.Path> =>
319+ Effect.gen(function* () {
320+ const path = yield* PlatformPath.Path;
321+ return {
322+ version: 1,
323+ kind: input.kind,
324+ pid: process.pid,
325+ startedAt: new Date().toISOString(),
326+ dataDir: resolveExecutorDataDir(path),
327+ scopeDir: currentScopeDirForManifest(),
328+ connection: input.connection,
329+ owner: {
330+ client: "cli",
331+ version: CLI_VERSION,
332+ executablePath: isDevMode ? (script ?? null) : process.execPath,
333+ },
334+ };
335+ });
336+
337+const assertNoOtherActiveLocalServer = (): Effect.Effect<
338+ void,
339+ Error,
340+ FileSystem.FileSystem | PlatformPath.Path
341+> =>
342+ Effect.gen(function* () {
343+ const active = yield* readActiveLocalServerManifest();
344+ if (!active || active.pid === process.pid) return;
345+ return yield* Effect.fail(
346+ new Error(
347+ [
348+ `A local Executor ${active.kind} is already running at ${active.connection.origin} (pid ${active.pid}).`,
349+ `It owns the current data directory: ${active.dataDir}`,
350+ "Stop it before starting another local server.",
351+ ].join("\n"),
352+ ),
353+ );
354+ });
355+
356+const publishLocalServerManifest = (input: {
357+ readonly kind: ExecutorLocalServerKind;
358+ readonly connection: ExecutorServerConnection;
359+}): Effect.Effect<void, PlatformError, FileSystem.FileSystem | PlatformPath.Path> =>
360+ Effect.gen(function* () {
361+ const manifest = yield* makeLocalServerManifest(input);
362+ yield* writeLocalServerManifest(manifest);
363+ });
364+
205365 const installDefaultExecutorWebBaseUrl = (baseUrl: string): (() => void) => {
206366 if (process.env.EXECUTOR_WEB_BASE_URL !== undefined) {
207367 return () => {};
@@ -343,6 +503,23 @@ const ensureDaemon = ( · 135 unmodified lines
343503 return resolvedTarget.baseUrl;
344504 }
345505
506+ const active = yield* readActiveLocalServerManifest();
507+ if (
508+ active &&
509+ normalizeExecutorServerConnection({ origin: active.connection.origin }).origin !==
510+ normalizeExecutorServerConnection({ origin: resolvedTarget.baseUrl }).origin
511+ ) {
512+ return yield* Effect.fail(
513+ new Error(
514+ [
515+ `A local Executor ${active.kind} is already running at ${active.connection.origin} (pid ${active.pid}).`,
516+ `It owns the current data directory: ${active.dataDir}`,
517+ "Refusing to start another local daemon against the same database.",
518+ ].join("\n"),
519+ ),
520+ );
521+ }
522+
346523 const parsed = yield* parseDaemonUrl(baseUrl);
347524 const host = canonicalDaemonHost(parsed.hostname);
348525
@@ -366,6 +543,99 @@ const ensureDaemon = ( · 17 unmodified lines
366543 });
367544 }).pipe(Effect.mapError(toError));
368545
546+const resolveRequestedExecutorServerConnection = (
547+ target: ServerTarget,
548+): Effect.Effect<
549+ RequestedExecutorServerConnection,
550+ Error,
551+ FileSystem.FileSystem | PlatformPath.Path
552+> =>
553+ Effect.gen(function* () {
554+ if (target.baseUrl && target.serverName) {
555+ return yield* Effect.fail(new Error("Use either --server or --base-url, not both."));
556+ }
557+
558+ if (target.serverName) {
559+ const store = yield* readCliServerConnectionStore();
560+ const profile = findCliServerConnectionProfile(store, target.serverName);
561+ if (!profile) {
562+ return yield* Effect.fail(new Error(`No server profile named "${target.serverName}".`));
563+ }
564+ return { connection: withCliServerAuthFallback(profile.connection), source: "explicit" };
565+ }
566+
567+ if (!target.baseUrl) {
568+ const store = yield* readCliServerConnectionStore();
569+ const profile = defaultCliServerConnectionProfile(store);
570+ if (profile) {
571+ return {
572+ connection: withCliServerAuthFallback(profile.connection),
573+ source: "default-profile",
574+ };
575+ }
576+
577+ const active = yield* readActiveLocalServerManifest();
578+ if (active) return { connection: active.connection, source: "active-local" };
579+ }
580+
581+ return {
582+ connection: yield* parseExecutorServerConnection(target.baseUrl ?? DEFAULT_BASE_URL),
583+ source: target.baseUrl ? "explicit" : "implicit-default",
584+ };
585+ });
586+
587+const resolveExecutorServerConnection = (
588+ target: ServerTarget,
589+): Effect.Effect<ExecutorServerConnection, Error, FileSystem.FileSystem | PlatformPath.Path> =>
590+ Effect.gen(function* () {
591+ const requestedResult = yield* resolveRequestedExecutorServerConnection(target);
592+ const active = yield* readActiveLocalServerManifest();
593+ const decision = chooseCliServerConnectionWithActiveLocal({
594+ requested: requestedResult.connection,
595+ source: requestedResult.source,
596+ active,
597+ });
598+
599+ if (decision.kind === "conflict") {
600+ return yield* Effect.fail(
601+ new Error(
602+ [
603+ `A local Executor ${decision.active.kind} is already running at ${decision.active.connection.origin} (pid ${decision.active.pid}).`,
604+ `It owns the current data directory: ${decision.active.dataDir}`,
605+ "Refusing to auto-start another local server against the same database.",
606+ `Use the active server, or stop it before starting ${cliPrefix} daemon run.`,
607+ ].join("\n"),
608+ ),
609+ );
610+ }
611+
612+ const requested = decision.connection;
613+ if (decision.kind === "use-active") return requested;
614+
615+ if (!canAutoStartCliServerConnection(requested)) {
616+ const authorization = getExecutorServerAuthorizationHeader(requested) ?? undefined;
617+ if (yield* isServerReachable(requested.origin, authorization)) {
618+ return requested;
619+ }
620+ return yield* Effect.fail(
621+ new Error(
622+ [
623+ `Executor server is not reachable at ${requested.origin}.`,
624+ "For hosted Executor, set EXECUTOR_API_KEY to a bearer API key.",
625+ "For password-protected local or desktop servers, set EXECUTOR_AUTH_PASSWORD.",
626+ "For unauthenticated local Executor, use an http://localhost or http://127.0.0.1 server URL.",
627+ ].join("\n"),
628+ ),
629+ );
630+ }
631+
632+ const daemonUrl = yield* ensureDaemon(requested.origin);
633+ return normalizeExecutorServerConnection({
634+ ...requested,
635+ origin: daemonUrl,
636+ });
637+ }).pipe(Effect.mapError(toError));
638+
369639 const stopDaemon = (
370640 baseUrl: string,
371641 ): Effect.Effect<void, Error, FileSystem.FileSystem | PlatformPath.Path> =>
@@ -434,6 +704,7 @@ const stopDaemon = ( · 62 unmodified lines
434704
435705 yield* removeDaemonRecord({ hostname: host, port: target.port });
436706 yield* removeDaemonPointer({ hostname: host, scopeId }).pipe(Effect.ignore);
707+ yield* removeLocalServerManifestIfOwnedBy({ pid: record.pid }).pipe(Effect.ignore);
437708 console.log(`Daemon stopped at ${target.baseUrl}.`);
438709 }).pipe(Effect.mapError(toError));
439710
@@ -463,12 +734,12 @@ const buildResumeApprovalUrl = (baseUrl: string, executionId: string): string => · 23 unmodified lines
463734 };
464735
465736 const executeCode = (input: {
466− baseUrl: string;
737+ target: ServerTarget;
467738 code: string;
468−}): Effect.Effect<ExecuteCodeOutcome, Error, FileSystem.FileSystem | PlatformPath.Path> =>
739+}): Effect.Effect<ExecuteCodeResult, Error, FileSystem.FileSystem | PlatformPath.Path> =>
469740 Effect.gen(function* () {
470− const daemonUrl = yield* ensureDaemon(input.baseUrl);
471− const client = yield* makeApiClient(daemonUrl);
741+ const connection = yield* resolveExecutorServerConnection(input.target);
742+ const client = yield* makeApiClient(connection);
472743 const response = yield* client.executions.execute({
473744 payload: {
474745 code: input.code,
@@ -478,11 +749,16 @@ const executeCode = (input: { · 3 unmodified lines
478749 if (response.status === "paused") {
479750 const executionId = extractExecutionId(response.structured);
480751 return {
481− status: "paused" as const,
482− text: response.text,
483− executionId,
484− approvalUrl: executionId ? buildResumeApprovalUrl(daemonUrl, executionId) : undefined,
485− interaction: extractPausedInteraction(response.structured),
752+ connection,
753+ outcome: {
754+ status: "paused" as const,
755+ text: response.text,
756+ executionId,
757+ approvalUrl: executionId
758+ ? buildResumeApprovalUrl(connection.origin, executionId)
759+ : undefined,
760+ interaction: extractPausedInteraction(response.structured),
761+ },
486762 };
487763 }
488764
@@ -491,12 +767,27 @@ const executeCode = (input: { · 2 unmodified lines
491767 }
492768
493769 return {
494− status: "completed" as const,
495− result: extractExecutionResult(response.structured),
770+ connection,
771+ outcome: {
772+ status: "completed" as const,
773+ result: extractExecutionResult(response.structured),
774+ },
496775 };
497776 }).pipe(Effect.mapError(toError));
498777
499−const printExecutionOutcome = (input: { baseUrl: string; outcome: ExecuteCodeOutcome }) =>
778+const serverTargetResumeFlag = (
779+ target: ServerTarget,
780+ connection: ExecutorServerConnection,
781+): string =>
782+ target.serverName
783+ ? `--server ${shellQuoteArg(target.serverName)}`
784+ : `--base-url ${shellQuoteArg(target.baseUrl ?? connection.origin)}`;
785+
786+const printExecutionOutcome = (input: {
787+ target: ServerTarget;
788+ connection: ExecutorServerConnection;
789+ outcome: ExecuteCodeOutcome;
790+}) =>
500791 Effect.sync(() => {
501792 if (input.outcome.status === "paused") {
502793 console.log(input.outcome.text);
@@ -505,7 +796,7 @@ const printExecutionOutcome = (input: { baseUrl: string; outcome: ExecuteCodeOut · 2 unmodified lines
505796 console.log("\nApprove in browser:");
506797 console.log(` ${input.outcome.approvalUrl}`);
507798 }
508− const commandPrefix = `${cliPrefix} resume --execution-id ${input.outcome.executionId} --base-url ${input.baseUrl}`;
799+ const commandPrefix = `${cliPrefix} resume --execution-id ${input.outcome.executionId} ${serverTargetResumeFlag(input.target, input.connection)}`;
509800 if (input.outcome.interaction?.kind === "form") {
510801 const requestedSchema = input.outcome.interaction.requestedSchema;
511802 if (requestedSchema && Object.keys(requestedSchema).length > 0) {
@@ -537,10 +828,19 @@ const printExecutionOutcome = (input: { baseUrl: string; outcome: ExecuteCodeOut · 25 unmodified lines
537828 // Typed API client
538829 // ---------------------------------------------------------------------------
539830
540−const makeApiClient = (baseUrl: string) =>
541− HttpApiClient.make(ExecutorApi, { baseUrl: `${baseUrl}/api` }).pipe(
542− Effect.provide(FetchHttpClient.layer),
543− );
831+const makeApiClient = (connection: ExecutorServerConnection) => {
832+ const authorization = getExecutorServerAuthorizationHeader(connection);
833+ return HttpApiClient.make(ExecutorApi, {
834+ baseUrl: connection.apiBaseUrl,
835+ ...(authorization
836+ ? {
837+ transformClient: HttpClient.mapRequest((request) =>
838+ HttpClientRequest.setHeader(request, "authorization", authorization),
839+ ),
840+ }
841+ : {}),
842+ }).pipe(Effect.provide(FetchHttpClient.layer));
843+};
544844
545845 // ---------------------------------------------------------------------------
546846 // Foreground session
@@ -561,39 +861,65 @@ const runForegroundSession = (input: { · 14 unmodified lines
561861 );
562862
563863 try {
564− const server = yield* Effect.promise(() =>
565− startServer({
566− port: input.port,
567− hostname: input.hostname,
568− allowedHosts: input.allowedHosts,
569− authToken: input.authToken,
570− authPassword: input.authPassword,
571− embeddedWebUI,
572− }),
573− );
864+ const startupLock = yield* acquireLocalServerStartLock();
865+ let server: Awaited<ReturnType<typeof startServer>> | null = null;
866+ let baseUrl: string | null = null;
574867
575− const baseUrl = `http://${displayHost}:${server.port}`;
576− console.log(`Executor is ready.`);
577− console.log(`Web: ${baseUrl}`);
578− console.log(`MCP: ${baseUrl}/mcp`);
579− console.log(`OpenAPI: ${baseUrl}/api/docs`);
580− if (input.hostname !== "127.0.0.1" && input.hostname !== "localhost") {
581− console.log(
582− `\n⚠ Listening on ${input.hostname}. Executor runs arbitrary commands — only expose on trusted networks.`,
868+ try {
869+ yield* assertNoOtherActiveLocalServer();
870+ server = yield* Effect.promise(() =>
871+ startServer({
872+ port: input.port,
873+ hostname: input.hostname,
874+ allowedHosts: input.allowedHosts,
875+ authToken: input.authToken,
876+ authPassword: input.authPassword,
877+ embeddedWebUI,
878+ }),
583879 );
584− if (input.allowedHosts.length > 0) {
585− console.log(` Extra allowed Host headers: ${input.allowedHosts.join(", ")}`);
586− }
587− if (input.authPassword) {
588− console.log(" Basic authentication is enabled.");
589− } else if (input.authToken) {
590− console.log(" Token authentication is enabled.");
591− }
880+ baseUrl = `http://${displayHost}:${server.port}`;
881+ yield* publishLocalServerManifest({
882+ kind: "foreground",
883+ connection: normalizeExecutorServerConnection({
884+ kind: "http",
885+ origin: baseUrl,
886+ displayName: "CLI web",
887+ auth: serverAuthFromInputs(input),
888+ }),
889+ });
890+ } finally {
891+ yield* releaseLocalServerStartLock(startupLock).pipe(Effect.ignore);
892+ }
893+
894+ if (!server || !baseUrl) {
895+ return yield* Effect.fail(new Error("Failed to start local Executor server."));
592896 }
593− console.log(`\nPress Ctrl+C to stop.`);
594897
595− yield* waitForShutdownSignal();
596− yield* Effect.promise(() => server.stop());
898+ try {
899+ console.log(`Executor is ready.`);
900+ console.log(`Web: ${baseUrl}`);
901+ console.log(`MCP: ${baseUrl}/mcp`);
902+ console.log(`OpenAPI: ${baseUrl}/api/docs`);
903+ if (input.hostname !== "127.0.0.1" && input.hostname !== "localhost") {
904+ console.log(
905+ `\n⚠ Listening on ${input.hostname}. Executor runs arbitrary commands — only expose on trusted networks.`,
906+ );
907+ if (input.allowedHosts.length > 0) {
908+ console.log(` Extra allowed Host headers: ${input.allowedHosts.join(", ")}`);
909+ }
910+ if (input.authPassword) {
911+ console.log(" Basic authentication is enabled.");
912+ } else if (input.authToken) {
913+ console.log(" Token authentication is enabled.");
914+ }
915+ }
916+ console.log(`\nPress Ctrl+C to stop.`);
917+
918+ yield* waitForShutdownSignal();
919+ } finally {
920+ yield* Effect.promise(() => server.stop());
921+ yield* removeLocalServerManifestIfOwnedBy({ pid: process.pid }).pipe(Effect.ignore);
922+ }
597923 } finally {
598924 restoreWebBaseUrl();
599925 }
@@ -614,37 +940,62 @@ const runDaemonSession = (input: { · 14 unmodified lines
614940 const scopeId = currentDaemonScopeId();
615941
616942 try {
617− const existing = yield* readDaemonPointer({ hostname: daemonHost, scopeId });
943+ const startupLock = yield* acquireLocalServerStartLock();
944+ let server: Awaited<ReturnType<typeof startServer>> | null = null;
945+ let daemonPort: number | null = null;
946+ let token: string | null = null;
618947
619− if (existing) {
620− const existingUrl = daemonBaseUrl(existing.hostname, existing.port);
621− if (isPidAlive(existing.pid) && (yield* isServerReachable(existingUrl))) {
622− return yield* Effect.fail(
623− new Error(
624− [
625− `A daemon is already running for scope ${scopeId} on ${daemonHost}.`,
626− `Existing daemon: ${existingUrl} (pid ${existing.pid}).`,
627− `Stop it first: ${cliPrefix} daemon stop`,
628− ].join("\n"),
629− ),
630− );
948+ try {
949+ yield* assertNoOtherActiveLocalServer();
950+
951+ const existing = yield* readDaemonPointer({ hostname: daemonHost, scopeId });
952+
953+ if (existing) {
954+ const existingUrl = daemonBaseUrl(existing.hostname, existing.port);
955+ if (isPidAlive(existing.pid) && (yield* isServerReachable(existingUrl))) {
956+ return yield* Effect.fail(
957+ new Error(
958+ [
959+ `A daemon is already running for scope ${scopeId} on ${daemonHost}.`,
960+ `Existing daemon: ${existingUrl} (pid ${existing.pid}).`,
961+ `Stop it first: ${cliPrefix} daemon stop`,
962+ ].join("\n"),
963+ ),
964+ );
965+ }
966+ yield* cleanupPointer({ hostname: existing.hostname, scopeId, port: existing.port });
631967 }
632− yield* cleanupPointer({ hostname: existing.hostname, scopeId, port: existing.port });
633− }
634968
635− const server = yield* Effect.promise(() =>
636− startServer({
637− port: input.port,
638− hostname: input.hostname,
639− allowedHosts: input.allowedHosts,
640− authToken: input.authToken,
641− authPassword: input.authPassword,
642− embeddedWebUI,
643− }),
644− );
969+ server = yield* Effect.promise(() =>
970+ startServer({
971+ port: input.port,
972+ hostname: input.hostname,
973+ allowedHosts: input.allowedHosts,
974+ authToken: input.authToken,
975+ authPassword: input.authPassword,
976+ embeddedWebUI,
977+ }),
978+ );
979+
980+ daemonPort = server.port;
981+ token = randomUUID();
982+ const daemonUrl = daemonBaseUrl(daemonHost, daemonPort);
983+ yield* publishLocalServerManifest({
984+ kind: "cli-daemon",
985+ connection: normalizeExecutorServerConnection({
986+ kind: "http",
987+ origin: daemonUrl,
988+ displayName: "CLI daemon",
989+ auth: serverAuthFromInputs(input),
990+ }),
991+ });
992+ } finally {
993+ yield* releaseLocalServerStartLock(startupLock).pipe(Effect.ignore);
994+ }
645995
646− const daemonPort = server.port;
647− const token = randomUUID();
996+ if (!server || daemonPort === null || token === null) {
997+ return yield* Effect.fail(new Error("Failed to start local Executor daemon."));
998+ }
648999
6491000 try {
6501001 yield* writeDaemonRecord({
@@ -674,6 +1025,7 @@ const runDaemonSession = (input: { · 23 unmodified lines
6741025 yield* Effect.promise(() => server.stop());
6751026 yield* removeDaemonRecord({ hostname: daemonHost, port: daemonPort });
6761027 yield* removeDaemonPointer({ hostname: daemonHost, scopeId }).pipe(Effect.ignore);
1028+ yield* removeLocalServerManifestIfOwnedBy({ pid: process.pid }).pipe(Effect.ignore);
6771029 }
6781030 } finally {
6791031 restoreWebBaseUrl();
@@ -748,30 +1100,57 @@ const withStdoutReroutedToStderr = async <A>(body: () => Promise<A>): Promise<A> · 68 unmodified lines
7481100
7491101 const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "model" }) =>
7501102 Effect.gen(function* () {
751− const web = yield* Effect.promise(() =>
752− withStdoutReroutedToStderr(async () => {
753− const host = "127.0.0.1";
754− const port = await Effect.runPromise(
755− chooseDaemonPort({ preferredPort: DEFAULT_PORT, hostname: host }),
756− );
757− const baseUrl = `http://localhost:${port}`;
758− const restoreWebBaseUrl = installDefaultExecutorWebBaseUrl(baseUrl);
759−
760− try {
761− const executor = await getExecutor();
762− const server = await startServer({
763− port,
764− hostname: host,
765− embeddedWebUI,
766− });
767− const serverBaseUrl = `http://localhost:${server.port}`;
768− return { executor, server, baseUrl: serverBaseUrl, restoreWebBaseUrl };
769− } catch (cause) {
770− restoreWebBaseUrl();
771− throw cause;
772− }
773− }),
774− );
1103+ const startupLock = yield* acquireLocalServerStartLock();
1104+ let web: Awaited<
1105+ ReturnType<
1106+ typeof withStdoutReroutedToStderr<{
1107+ readonly executor: Awaited<ReturnType<typeof getExecutor>>;
1108+ readonly server: Awaited<ReturnType<typeof startServer>>;
1109+ readonly baseUrl: string;
1110+ readonly restoreWebBaseUrl: () => void;
1111+ }>
1112+ >
1113+ > | null = null;
1114+
1115+ try {
1116+ yield* assertNoOtherActiveLocalServer();
1117+ web = yield* Effect.promise(() =>
1118+ withStdoutReroutedToStderr(async () => {
1119+ const host = "127.0.0.1";
1120+ const port = await Effect.runPromise(
1121+ chooseDaemonPort({ preferredPort: DEFAULT_PORT, hostname: host }),
1122+ );
1123+ const baseUrl = `http://localhost:${port}`;
1124+ const restoreWebBaseUrl = installDefaultExecutorWebBaseUrl(baseUrl);
1125+
1126+ try {
1127+ const executor = await getExecutor();
1128+ const server = await startServer({
1129+ port,
1130+ hostname: host,
1131+ embeddedWebUI,
1132+ });
1133+ const serverBaseUrl = `http://localhost:${server.port}`;
1134+ return { executor, server, baseUrl: serverBaseUrl, restoreWebBaseUrl };
1135+ } catch (cause) {
1136+ restoreWebBaseUrl();
1137+ throw cause;
1138+ }
1139+ }),
1140+ );
1141+ yield* publishLocalServerManifest({
1142+ kind: "foreground",
1143+ connection: normalizeExecutorServerConnection({
1144+ kind: "http",
1145+ origin: web.baseUrl,
1146+ displayName: "CLI MCP",
1147+ }),
1148+ });
1149+ } finally {
1150+ yield* releaseLocalServerStartLock(startupLock).pipe(Effect.ignore);
1151+ }
1152+
1153+ if (!web) return yield* Effect.fail(new Error("Failed to start local Executor MCP server."));
7751154
7761155 try {
7771156 yield* Effect.promise(() =>
@@ -791,6 +1170,7 @@ const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "mode · 13 unmodified lines
7911170 } finally {
7921171 web.restoreWebBaseUrl();
7931172 yield* Effect.promise(() => web.server.stop());
1173+ yield* removeLocalServerManifestIfOwnedBy({ pid: process.pid }).pipe(Effect.ignore);
7941174 }
7951175 });
7961176
@@ -799,6 +1179,31 @@ const scope = Options.string("scope").pipe( · 2 unmodified lines
7991179 Options.withDescription("Path to workspace directory containing executor.jsonc"),
8001180 );
8011181
1182+const serverBaseUrl = Options.string("base-url").pipe(
1183+ Options.optional,
1184+ Options.withDescription(
1185+ "Executor server origin. Overrides the default profile; local URLs auto-start the daemon.",
1186+ ),
1187+);
1188+
1189+const serverProfile = Options.string("server").pipe(
1190+ Options.optional,
1191+ Options.withDescription("Named Executor server profile."),
1192+);
1193+
1194+const daemonBaseUrlOption = Options.string("base-url").pipe(
1195+ Options.withDefault(DEFAULT_BASE_URL),
1196+ Options.withDescription("Local daemon origin."),
1197+);
1198+
1199+const serverTargetFromOptions = (input: {
1200+ readonly baseUrl: Option.Option<string>;
1201+ readonly server: Option.Option<string>;
1202+}): ServerTarget => ({
1203+ baseUrl: Option.getOrUndefined(input.baseUrl),
1204+ serverName: Option.getOrUndefined(input.server),
1205+});
1206+
8021207 const applyScope = (s: Option.Option<string>) => {
8031208 const dir = Option.getOrUndefined(s);
8041209 if (dir) process.env.EXECUTOR_SCOPE_DIR = resolve(dir);
@@ -866,7 +1271,8 @@ const parsePositiveIntegerOption = (name: string, raw: string): number => { · 61 unmodified lines
8661271
8671272 interface ParsedCallHelpArgs {
8681273 readonly pathParts: ReadonlyArray<string>;
869− readonly baseUrl: string;
1274+ readonly baseUrl: string | undefined;
1275+ readonly serverName: string | undefined;
8701276 readonly scopeDir: string | undefined;
8711277 readonly match: string | undefined;
8721278 readonly limit: number | undefined;
@@ -877,7 +1283,8 @@ const HELP_FLAGS = new Set(["--help", "-h"]); · 4 unmodified lines
8771283 const isHelpFlag = (value: string): boolean => HELP_FLAGS.has(value);
8781284
8791285 const parseCallHelpArgs = (args: ReadonlyArray<string>): ParsedCallHelpArgs => {
880− let baseUrl = DEFAULT_BASE_URL;
1286+ let baseUrl: string | undefined = undefined;
1287+ let serverName: string | undefined = undefined;
8811288 let scopeDir: string | undefined = undefined;
8821289 let match: string | undefined = undefined;
8831290 let limit: number | undefined = undefined;
@@ -899,6 +1306,18 @@ const parseCallHelpArgs = (args: ReadonlyArray<string>): ParsedCallHelpArgs => { · 15 unmodified lines
8991306 continue;
9001307 }
9011308
1309+ if (token === "--server") {
1310+ const value = args[index + 1];
1311+ if (!value) throw new Error("Missing value for --server");
1312+ serverName = value;
1313+ index += 1;
1314+ continue;
1315+ }
1316+ if (token.startsWith("--server=")) {
1317+ serverName = token.slice("--server=".length);
1318+ continue;
1319+ }
1320+
9021321 if (token === "--scope") {
9031322 const value = args[index + 1];
9041323 if (!value) throw new Error("Missing value for --scope");
@@ -958,7 +1377,7 @@ const parseCallHelpArgs = (args: ReadonlyArray<string>): ParsedCallHelpArgs => { · 53 unmodified lines
9581377 pathParts.pop();
9591378 }
9601379
961− return { pathParts, baseUrl, scopeDir, match, limit };
1380+ return { pathParts, baseUrl, serverName, scopeDir, match, limit };
9621381 };
9631382
9641383 const printCallBrowseHelp = (input: {
@@ -1097,8 +1516,11 @@ const runCallHelp = ( · 132 unmodified lines
10971516 Effect.gen(function* () {
10981517 if (args.scopeDir) process.env.EXECUTOR_SCOPE_DIR = resolve(args.scopeDir);
10991518
1100− const daemonUrl = yield* ensureDaemon(args.baseUrl);
1101− const client = yield* makeApiClient(daemonUrl);
1519+ const connection = yield* resolveExecutorServerConnection({
1520+ baseUrl: args.baseUrl,
1521+ serverName: args.serverName,
1522+ });
1523+ const client = yield* makeApiClient(connection);
11021524 const scopeInfo = yield* client.scope.info();
11031525 const tools = yield* client.tools.list({ params: { scopeId: scopeInfo.id } });
11041526 const toolPaths = tools.map((tool) => tool.id);
@@ -1257,12 +1679,14 @@ const callCommand = Command.make( · 152 unmodified lines
12571679 "call",
12581680 {
12591681 pathParts: Args.string("tool-path-segment").pipe(Args.variadic({})),
1260− baseUrl: Options.string("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)),
1682+ baseUrl: serverBaseUrl,
1683+ server: serverProfile,
12611684 scope,
12621685 },
1263− ({ pathParts, baseUrl, scope }) =>
1686+ ({ pathParts, baseUrl, server, scope }) =>
12641687 Effect.gen(function* () {
12651688 applyScope(scope);
1689+ const target = serverTargetFromOptions({ baseUrl, server });
12661690 const { path, args } = yield* resolveToolInvocation({
12671691 rawPathParts: pathParts,
12681692 });
@@ -1272,8 +1696,12 @@ const callCommand = Command.make( · 3 unmodified lines
12721696 cause instanceof Error ? cause : new Error(`Invalid tool path: ${String(cause)}`),
12731697 });
12741698
1275− const outcome = yield* executeCode({ baseUrl, code });
1276− yield* printExecutionOutcome({ baseUrl, outcome });
1699+ const result = yield* executeCode({ target, code });
1700+ yield* printExecutionOutcome({
1701+ target,
1702+ connection: result.connection,
1703+ outcome: result.outcome,
1704+ });
12771705 }),
12781706 ).pipe(
12791707 Command.withDescription(
@@ -1295,17 +1723,19 @@ const resumeCommand = Command.make( · 15 unmodified lines
12951723 Options.optional,
12961724 Options.withDescription("JSON object to send when action=accept"),
12971725 ),
1298− baseUrl: Options.string("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)),
1726+ baseUrl: serverBaseUrl,
1727+ server: serverProfile,
12991728 scope,
13001729 },
1301− ({ executionId, action, content, baseUrl, scope }) =>
1730+ ({ executionId, action, content, baseUrl, server, scope }) =>
13021731 Effect.gen(function* () {
13031732 applyScope(scope);
1304− const daemonUrl = yield* ensureDaemon(baseUrl);
1733+ const target = serverTargetFromOptions({ baseUrl, server });
1734+ const connection = yield* resolveExecutorServerConnection(target);
13051735
13061736 const contentObj = yield* parseOptionalJsonObject(Option.getOrUndefined(content));
13071737
1308− const client = yield* makeApiClient(daemonUrl);
1738+ const client = yield* makeApiClient(connection);
13091739 const result = yield* client.executions.resume({
13101740 params: { executionId },
13111741 payload: { action, content: contentObj },
@@ -1317,7 +1747,7 @@ const resumeCommand = Command.make( · 5 unmodified lines
13171747 if (nextExecutionId) {
13181748 console.log("");
13191749 console.log("Approval required:");
1320− console.log(buildResumeApprovalUrl(daemonUrl, nextExecutionId));
1750+ console.log(buildResumeApprovalUrl(connection.origin, nextExecutionId));
13211751 }
13221752 process.exit(0);
13231753 }
@@ -1347,20 +1777,26 @@ const toolsSearchCommand = Command.make( · 23 unmodified lines
13471777 query: Args.string("query"),
13481778 namespace: Options.string("namespace").pipe(Options.optional),
13491779 limit: Options.integer("limit").pipe(Options.withDefault(12)),
1350− baseUrl: Options.string("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)),
1780+ baseUrl: serverBaseUrl,
1781+ server: serverProfile,
13511782 scope,
13521783 },
1353− ({ query, namespace, limit, baseUrl, scope }) =>
1784+ ({ query, namespace, limit, baseUrl, server, scope }) =>
13541785 Effect.gen(function* () {
13551786 applyScope(scope);
1787+ const target = serverTargetFromOptions({ baseUrl, server });
13561788 const code = buildSearchToolsCode({
13571789 query,
13581790 namespace: Option.getOrUndefined(namespace),
13591791 limit,
13601792 });
13611793
1362− const outcome = yield* executeCode({ baseUrl, code });
1363− yield* printExecutionOutcome({ baseUrl, outcome });
1794+ const result = yield* executeCode({ target, code });
1795+ yield* printExecutionOutcome({
1796+ target,
1797+ connection: result.connection,
1798+ outcome: result.outcome,
1799+ });
13641800 }),
13651801 ).pipe(Command.withDescription("Search tools by natural-language query"));
13661802
@@ -1369,19 +1805,25 @@ const toolsSourcesCommand = Command.make( · 2 unmodified lines
13691805 {
13701806 query: Options.string("query").pipe(Options.optional),
13711807 limit: Options.integer("limit").pipe(Options.withDefault(50)),
1372− baseUrl: Options.string("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)),
1808+ baseUrl: serverBaseUrl,
1809+ server: serverProfile,
13731810 scope,
13741811 },
1375− ({ query, limit, baseUrl, scope }) =>
1812+ ({ query, limit, baseUrl, server, scope }) =>
13761813 Effect.gen(function* () {
13771814 applyScope(scope);
1815+ const target = serverTargetFromOptions({ baseUrl, server });
13781816 const code = buildListSourcesCode({
13791817 query: Option.getOrUndefined(query),
13801818 limit,
13811819 });
13821820
1383− const outcome = yield* executeCode({ baseUrl, code });
1384− yield* printExecutionOutcome({ baseUrl, outcome });
1821+ const result = yield* executeCode({ target, code });
1822+ yield* printExecutionOutcome({
1823+ target,
1824+ connection: result.connection,
1825+ outcome: result.outcome,
1826+ });
13851827 }),
13861828 ).pipe(Command.withDescription("List configured sources and tool counts"));
13871829
@@ -1389,15 +1831,21 @@ const toolsDescribeCommand = Command.make( · 1 unmodified line
13891831 "describe",
13901832 {
13911833 path: Args.string("path"),
1392− baseUrl: Options.string("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)),
1834+ baseUrl: serverBaseUrl,
1835+ server: serverProfile,
13931836 scope,
13941837 },
1395− ({ path, baseUrl, scope }) =>
1838+ ({ path, baseUrl, server, scope }) =>
13961839 Effect.gen(function* () {
13971840 applyScope(scope);
1841+ const target = serverTargetFromOptions({ baseUrl, server });
13981842 const code = buildDescribeToolCode(path);
1399− const outcome = yield* executeCode({ baseUrl, code });
1400− yield* printExecutionOutcome({ baseUrl, outcome });
1843+ const result = yield* executeCode({ target, code });
1844+ yield* printExecutionOutcome({
1845+ target,
1846+ connection: result.connection,
1847+ outcome: result.outcome,
1848+ });
14011849 }),
14021850 ).pipe(Command.withDescription("Describe a tool's TypeScript and JSON schema"));
14031851
@@ -1406,6 +1854,131 @@ const toolsCommand = Command.make("tools").pipe( · 2 unmodified lines
14061854 Command.withDescription("Discover available tools and sources"),
14071855 );
14081856
1857+const profileConnectionInput = (input: {
1858+ readonly origin: string;
1859+ readonly displayName: Option.Option<string>;
1860+ readonly kind: Option.Option<"http" | "desktop-sidecar">;
1861+}): ExecutorServerConnectionInput => {
1862+ const selectedKind = Option.getOrUndefined(input.kind);
1863+ const displayName = Option.getOrUndefined(input.displayName);
1864+ return {
1865+ kind: selectedKind ?? "http",
1866+ origin: input.origin,
1867+ ...(displayName ? { displayName } : {}),
1868+ };
1869+};
1870+
1871+const printServerProfiles = () =>
1872+ Effect.gen(function* () {
1873+ const store = yield* readCliServerConnectionStore();
1874+ if (store.profiles.length === 0) {
1875+ console.log("No server profiles configured.");
1876+ console.log(`Add one: ${cliPrefix} server add local ${DEFAULT_BASE_URL} --default`);
1877+ return;
1878+ }
1879+
1880+ const rows = store.profiles.map((profile) => ({
1881+ marker: profile.name === store.defaultProfile ? "*" : " ",
1882+ name: profile.name,
1883+ kind: profile.connection.kind,
1884+ origin: profile.connection.origin,
1885+ displayName: profile.connection.displayName,
1886+ auth: profile.connection.auth ? "stored-auth" : "env-auth",
1887+ }));
1888+ const nameWidth = rows.reduce((max, row) => Math.max(max, row.name.length), 4);
1889+ const kindWidth = rows.reduce((max, row) => Math.max(max, row.kind.length), 4);
1890+
1891+ for (const row of rows) {
1892+ console.log(
1893+ `${row.marker} ${row.name.padEnd(nameWidth)} ${row.kind.padEnd(kindWidth)} ${row.origin} ${row.displayName} ${row.auth}`,
1894+ );
1895+ }
1896+ });
1897+
1898+const serverAddCommand = Command.make(
1899+ "add",
1900+ {
1901+ name: Args.string("name"),
1902+ origin: Args.string("origin"),
1903+ displayName: Options.string("display-name").pipe(
1904+ Options.optional,
1905+ Options.withDescription("Display label for this server profile."),
1906+ ),
1907+ kind: Options.choice("kind", ["http", "desktop-sidecar"] as const).pipe(
1908+ Options.optional,
1909+ Options.withDescription("Server kind. Defaults to http."),
1910+ ),
1911+ makeDefault: Options.boolean("default").pipe(
1912+ Options.withDefault(false),
1913+ Options.withDescription("Make this profile the default server."),
1914+ ),
1915+ },
1916+ ({ name, origin, displayName, kind, makeDefault }) =>
1917+ Effect.gen(function* () {
1918+ const profileName = validateCliServerConnectionProfileName(name);
1919+ const store = yield* upsertCliServerConnectionProfile({
1920+ name: profileName,
1921+ connection: profileConnectionInput({ origin, displayName, kind }),
1922+ makeDefault,
1923+ });
1924+ const profile = findCliServerConnectionProfile(store, profileName);
1925+ if (!profile) return yield* Effect.fail(new Error(`Failed to save "${profileName}".`));
1926+ console.log(`Saved server profile "${profile.name}" (${profile.connection.origin}).`);
1927+ if (store.defaultProfile === profile.name) {
1928+ console.log(`Default server profile: ${profile.name}`);
1929+ }
1930+ }),
1931+).pipe(Command.withDescription("Add or update a named Executor server profile"));
1932+
1933+const serverListCommand = Command.make("list", {}, () => printServerProfiles()).pipe(
1934+ Command.withDescription("List configured Executor server profiles"),
1935+);
1936+
1937+const serverUseCommand = Command.make(
1938+ "use",
1939+ {
1940+ name: Args.string("name"),
1941+ },
1942+ ({ name }) =>
1943+ Effect.gen(function* () {
1944+ const store = yield* setDefaultCliServerConnectionProfile(name);
1945+ const profile = defaultCliServerConnectionProfile(store);
1946+ if (!profile) return yield* Effect.fail(new Error(`No server profile named "${name}".`));
1947+ console.log(`Default server profile: ${profile.name} (${profile.connection.origin}).`);
1948+ }),
1949+).pipe(Command.withDescription("Set the default Executor server profile"));
1950+
1951+const serverRemoveCommand = Command.make(
1952+ "remove",
1953+ {
1954+ name: Args.string("name"),
1955+ },
1956+ ({ name }) =>
1957+ Effect.gen(function* () {
1958+ const profileName = validateCliServerConnectionProfileName(name);
1959+ const store = yield* readCliServerConnectionStore();
1960+ const profile = findCliServerConnectionProfile(store, profileName);
1961+ if (!profile) {
1962+ return yield* Effect.fail(new Error(`No server profile named "${profileName}".`));
1963+ }
1964+ const nextStore = yield* removeCliServerConnectionProfile(profileName);
1965+ console.log(`Removed server profile "${profileName}".`);
1966+ if (nextStore.defaultProfile === null) {
1967+ console.log("No default server profile is configured.");
1968+ }
1969+ }),
1970+).pipe(Command.withDescription("Remove an Executor server profile"));
1971+
1972+const serverCommand = Command.make("server").pipe(
1973+ Command.withSubcommands([
1974+ serverAddCommand,
1975+ serverListCommand,
1976+ serverUseCommand,
1977+ serverRemoveCommand,
1978+ ] as const),
1979+ Command.withDescription("Manage named Executor server profiles"),
1980+);
1981+
14091982 const webCommand = Command.make(
14101983 "web",
14111984 {
@@ -1490,7 +2063,7 @@ const daemonRunCommand = Command.make( · 78 unmodified lines
14902063 const daemonStatusCommand = Command.make(
14912064 "status",
14922065 {
1493− baseUrl: Options.string("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)),
2066+ baseUrl: daemonBaseUrlOption,
14942067 },
14952068 ({ baseUrl }) =>
14962069 Effect.gen(function* () {
@@ -1542,7 +2115,7 @@ const daemonStatusCommand = Command.make( · 45 unmodified lines
15422115 const daemonStopCommand = Command.make(
15432116 "stop",
15442117 {
1545− baseUrl: Options.string("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)),
2118+ baseUrl: daemonBaseUrlOption,
15462119 },
15472120 ({ baseUrl }) => stopDaemon(baseUrl),
15482121 ).pipe(Command.withDescription("Stop the local daemon"));
@@ -1550,7 +2123,7 @@ const daemonStopCommand = Command.make( · 1 unmodified line
15502123 const daemonRestartCommand = Command.make(
15512124 "restart",
15522125 {
1553− baseUrl: Options.string("base-url").pipe(Options.withDefault(DEFAULT_BASE_URL)),
2126+ baseUrl: daemonBaseUrlOption,
15542127 scope,
15552128 },
15562129 ({ baseUrl, scope }) =>
@@ -1600,6 +2173,7 @@ const root = Command.make("executor").pipe( · 43 unmodified lines
16002173 callCommand,
16012174 resumeCommand,
16022175 toolsCommand,
2176+ serverCommand,
16032177 webCommand,
16042178 daemonCommand,
16052179 mcpCommand,
apps/cli/src/server-connection.test.ts+152−0
@@ -0,0 +1,152 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import {
4+ canAutoStartCliServerConnection,
5+ chooseCliServerConnectionWithActiveLocal,
6+ parseCliExecutorServerConnection,
7+ withCliServerAuthFallback,
8+} from "./server-connection";
9+
10+describe("CLI server connection", () => {
11+ it("treats localhost HTTP servers as auto-startable daemon targets", () => {
12+ const connection = parseCliExecutorServerConnection("localhost:4788", {});
13+
14+ expect(connection.origin).toBe("http://localhost:4788");
15+ expect(connection.apiBaseUrl).toBe("http://localhost:4788/api");
16+ expect(canAutoStartCliServerConnection(connection)).toBe(true);
17+ });
18+
19+ it("treats hosted HTTPS servers as explicit server connections", () => {
20+ const connection = parseCliExecutorServerConnection("https://executor.example/api", {
21+ EXECUTOR_API_KEY: "key_123",
22+ });
23+
24+ expect(connection.origin).toBe("https://executor.example");
25+ expect(connection.apiBaseUrl).toBe("https://executor.example/api");
26+ expect(connection.auth).toEqual({ kind: "bearer", token: "key_123" });
27+ expect(canAutoStartCliServerConnection(connection)).toBe(false);
28+ });
29+
30+ it("adds environment auth only when a profile did not carry auth", () => {
31+ const fromProfile = parseCliExecutorServerConnection("https://executor.example", {});
32+ expect(withCliServerAuthFallback(fromProfile, { EXECUTOR_API_KEY: "key_123" }).auth).toEqual({
33+ kind: "bearer",
34+ token: "key_123",
35+ });
36+
37+ const storedAuth = parseCliExecutorServerConnection("https://executor.example", {
38+ EXECUTOR_API_KEY: "stored",
39+ });
40+ expect(withCliServerAuthFallback(storedAuth, { EXECUTOR_API_KEY: "env" }).auth).toEqual({
41+ kind: "bearer",
42+ token: "stored",
43+ });
44+ });
45+
46+ it("supports desktop-style basic auth for local server connections", () => {
47+ const connection = parseCliExecutorServerConnection("http://127.0.0.1:4789", {
48+ EXECUTOR_AUTH_PASSWORD: "desktop-password",
49+ });
50+
51+ expect(connection.auth).toEqual({
52+ kind: "basic",
53+ username: "executor",
54+ password: "desktop-password",
55+ });
56+ expect(canAutoStartCliServerConnection(connection)).toBe(false);
57+ });
58+
59+ it("never auto-starts desktop sidecar profiles", () => {
60+ const connection = {
61+ ...parseCliExecutorServerConnection("http://127.0.0.1:4789", {}),
62+ kind: "desktop-sidecar" as const,
63+ };
64+
65+ expect(canAutoStartCliServerConnection(connection)).toBe(false);
66+ });
67+
68+ it("prefers bearer auth for hosted connections and basic auth for local connections", () => {
69+ const hosted = parseCliExecutorServerConnection("https://executor.example", {
70+ EXECUTOR_API_KEY: "key_123",
71+ EXECUTOR_AUTH_PASSWORD: "desktop-password",
72+ });
73+ expect(hosted.auth).toEqual({ kind: "bearer", token: "key_123" });
74+
75+ const local = parseCliExecutorServerConnection("http://127.0.0.1:4789", {
76+ EXECUTOR_API_KEY: "key_123",
77+ EXECUTOR_AUTH_PASSWORD: "desktop-password",
78+ EXECUTOR_AUTH_USERNAME: "rhys",
79+ });
80+ expect(local.auth).toEqual({
81+ kind: "basic",
82+ username: "rhys",
83+ password: "desktop-password",
84+ });
85+ });
86+
87+ it("attaches implicit local requests to the active local owner", () => {
88+ const requested = parseCliExecutorServerConnection("http://localhost:4788", {});
89+ const active = {
90+ version: 1 as const,
91+ kind: "desktop-sidecar" as const,
92+ pid: process.pid,
93+ startedAt: "2026-05-28T00:00:00.000Z",
94+ dataDir: "/tmp/executor",
95+ scopeDir: "/tmp/executor",
96+ connection: parseCliExecutorServerConnection("http://127.0.0.1:4789", {
97+ EXECUTOR_AUTH_PASSWORD: "desktop-password",
98+ }),
99+ owner: {
100+ client: "desktop" as const,
101+ version: "1.2.3",
102+ executablePath: "/Applications/Executor.app/Contents/MacOS/Executor",
103+ },
104+ };
105+
106+ const decision = chooseCliServerConnectionWithActiveLocal({
107+ requested,
108+ source: "active-local",
109+ active,
110+ });
111+
112+ expect(decision).toMatchObject({
113+ kind: "use-active",
114+ connection: {
115+ origin: "http://127.0.0.1:4789",
116+ auth: {
117+ kind: "basic",
118+ username: "executor",
119+ password: "desktop-password",
120+ },
121+ },
122+ });
123+ });
124+
125+ it("blocks local auto-start when another local owner is active", () => {
126+ const requested = parseCliExecutorServerConnection("http://localhost:4788", {});
127+ const active = {
128+ version: 1 as const,
129+ kind: "desktop-sidecar" as const,
130+ pid: process.pid,
131+ startedAt: "2026-05-28T00:00:00.000Z",
132+ dataDir: "/tmp/executor",
133+ scopeDir: "/tmp/executor",
134+ connection: parseCliExecutorServerConnection("http://127.0.0.1:4789", {
135+ EXECUTOR_AUTH_PASSWORD: "desktop-password",
136+ }),
137+ owner: {
138+ client: "desktop" as const,
139+ version: "1.2.3",
140+ executablePath: "/Applications/Executor.app/Contents/MacOS/Executor",
141+ },
142+ };
143+
144+ expect(
145+ chooseCliServerConnectionWithActiveLocal({
146+ requested,
147+ source: "explicit",
148+ active,
149+ }).kind,
150+ ).toBe("conflict");
151+ });
152+});
apps/cli/src/server-connection.ts+111−0
@@ -0,0 +1,111 @@
1+import {
2+ DEFAULT_EXECUTOR_SERVER_USERNAME,
3+ normalizeExecutorServerConnection,
4+ type ExecutorLocalServerManifest,
5+ type ExecutorServerAuth,
6+ type ExecutorServerConnection,
7+} from "@executor-js/sdk/shared";
8+import { canAutoStartLocalDaemonForHost } from "./daemon";
9+
10+const readCliBasicServerAuth = (
11+ env: Record<string, string | undefined> = process.env,
12+): ExecutorServerAuth | undefined => {
13+ const password = env.EXECUTOR_AUTH_PASSWORD;
14+ if (!password) return undefined;
15+ return {
16+ kind: "basic",
17+ username: env.EXECUTOR_AUTH_USERNAME ?? DEFAULT_EXECUTOR_SERVER_USERNAME,
18+ password,
19+ };
20+};
21+
22+const readCliBearerServerAuth = (
23+ env: Record<string, string | undefined> = process.env,
24+): ExecutorServerAuth | undefined => {
25+ const token = env.EXECUTOR_API_KEY ?? env.EXECUTOR_AUTH_TOKEN;
26+ if (!token) return undefined;
27+ return { kind: "bearer", token };
28+};
29+
30+export const readCliServerAuth = (
31+ env: Record<string, string | undefined> = process.env,
32+): ExecutorServerAuth | undefined => readCliBearerServerAuth(env) ?? readCliBasicServerAuth(env);
33+
34+const readCliServerAuthForConnection = (
35+ connection: ExecutorServerConnection,
36+ env: Record<string, string | undefined> = process.env,
37+): ExecutorServerAuth | undefined => {
38+ const bearer = readCliBearerServerAuth(env);
39+ const basic = readCliBasicServerAuth(env);
40+ const protocol = new URL(connection.origin).protocol;
41+
42+ if (protocol === "https:") {
43+ return bearer ?? basic;
44+ }
45+
46+ return basic ?? bearer;
47+};
48+
49+export const parseCliExecutorServerConnection = (
50+ baseUrl: string,
51+ env: Record<string, string | undefined> = process.env,
52+): ExecutorServerConnection => {
53+ const connection = normalizeExecutorServerConnection({
54+ origin: baseUrl,
55+ });
56+ return normalizeExecutorServerConnection({
57+ ...connection,
58+ auth: readCliServerAuthForConnection(connection, env),
59+ });
60+};
61+
62+export const withCliServerAuthFallback = (
63+ connection: ExecutorServerConnection,
64+ env: Record<string, string | undefined> = process.env,
65+): ExecutorServerConnection =>
66+ connection.auth
67+ ? connection
68+ : normalizeExecutorServerConnection({
69+ ...connection,
70+ auth: readCliServerAuthForConnection(connection, env),
71+ });
72+
73+export const canAutoStartCliServerConnection = (connection: ExecutorServerConnection): boolean => {
74+ if (connection.kind !== "http") return false;
75+ if (connection.auth?.kind === "basic") return false;
76+ const url = new URL(connection.origin);
77+ return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname);
78+};
79+
80+export type CliServerConnectionSource =
81+ | "explicit"
82+ | "default-profile"
83+ | "implicit-default"
84+ | "active-local";
85+
86+export type ActiveLocalServerDecision =
87+ | { readonly kind: "use-requested"; readonly connection: ExecutorServerConnection }
88+ | { readonly kind: "use-active"; readonly connection: ExecutorServerConnection }
89+ | { readonly kind: "conflict"; readonly active: ExecutorLocalServerManifest };
90+
91+const sameOrigin = (left: string, right: string): boolean =>
92+ normalizeExecutorServerConnection({ origin: left }).origin ===
93+ normalizeExecutorServerConnection({ origin: right }).origin;
94+
95+export const chooseCliServerConnectionWithActiveLocal = (input: {
96+ readonly requested: ExecutorServerConnection;
97+ readonly source: CliServerConnectionSource;
98+ readonly active: ExecutorLocalServerManifest | null;
99+}): ActiveLocalServerDecision => {
100+ if (!input.active) return { kind: "use-requested", connection: input.requested };
101+ if (input.source === "active-local") {
102+ return { kind: "use-active", connection: input.active.connection };
103+ }
104+ if (sameOrigin(input.requested.origin, input.active.connection.origin)) {
105+ return { kind: "use-active", connection: input.active.connection };
106+ }
107+ if (canAutoStartCliServerConnection(input.requested)) {
108+ return { kind: "conflict", active: input.active };
109+ }
110+ return { kind: "use-requested", connection: input.requested };
111+};
apps/cli/src/server-profile.test.ts+131−0
@@ -0,0 +1,131 @@
1+import { afterEach, describe, expect, it } from "@effect/vitest";
2+import { BunServices } from "@effect/platform-bun";
3+import { mkdtempSync, rmSync } from "node:fs";
4+import { tmpdir } from "node:os";
5+import { join } from "node:path";
6+import * as Effect from "effect/Effect";
7+
8+import {
9+ defaultCliServerConnectionProfile,
10+ parseCliServerConnectionStore,
11+ readCliServerConnectionStore,
12+ removeCliServerConnectionProfile,
13+ setDefaultCliServerConnectionProfile,
14+ upsertCliServerConnectionProfile,
15+} from "./server-profile";
16+
17+const previousDataDir = process.env.EXECUTOR_DATA_DIR;
18+
19+afterEach(() => {
20+ if (previousDataDir === undefined) {
21+ delete process.env.EXECUTOR_DATA_DIR;
22+ } else {
23+ process.env.EXECUTOR_DATA_DIR = previousDataDir;
24+ }
25+});
26+
27+describe("CLI server connection profiles", () => {
28+ it("round-trips named server connections and default selection", () =>
29+ Effect.gen(function* () {
30+ const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-"));
31+ process.env.EXECUTOR_DATA_DIR = dataDir;
32+
33+ try {
34+ yield* upsertCliServerConnectionProfile({
35+ name: "remote",
36+ connection: {
37+ origin: "https://executor.example/api",
38+ auth: { kind: "bearer", token: "key_123" },
39+ },
40+ makeDefault: true,
41+ });
42+
43+ const store = yield* readCliServerConnectionStore();
44+ expect(store.defaultProfile).toBe("remote");
45+ expect(store.profiles).toHaveLength(1);
46+ expect(store.profiles[0]?.connection.kind).toBe("http");
47+ expect(store.profiles[0]?.connection.origin).toBe("https://executor.example");
48+ expect(store.profiles[0]?.connection.apiBaseUrl).toBe("https://executor.example/api");
49+ expect(store.profiles[0]?.connection.auth).toEqual({
50+ kind: "bearer",
51+ token: "key_123",
52+ });
53+ expect(defaultCliServerConnectionProfile(store)?.name).toBe("remote");
54+ } finally {
55+ rmSync(dataDir, { recursive: true, force: true });
56+ }
57+ }).pipe(Effect.provide(BunServices.layer)));
58+
59+ it("switches and removes the default profile", () =>
60+ Effect.gen(function* () {
61+ const dataDir = mkdtempSync(join(tmpdir(), "executor-server-profiles-"));
62+ process.env.EXECUTOR_DATA_DIR = dataDir;
63+
64+ try {
65+ yield* upsertCliServerConnectionProfile({
66+ name: "local",
67+ connection: { origin: "localhost:4788" },
68+ makeDefault: true,
69+ });
70+ yield* upsertCliServerConnectionProfile({
71+ name: "remote",
72+ connection: { origin: "https://executor.example" },
73+ makeDefault: false,
74+ });
75+
76+ const switched = yield* setDefaultCliServerConnectionProfile("remote");
77+ expect(switched.defaultProfile).toBe("remote");
78+
79+ const removed = yield* removeCliServerConnectionProfile("remote");
80+ expect(removed.defaultProfile).toBeNull();
81+ expect(removed.profiles.map((profile) => profile.name)).toEqual(["local"]);
82+ } finally {
83+ rmSync(dataDir, { recursive: true, force: true });
84+ }
85+ }).pipe(Effect.provide(BunServices.layer)));
86+
87+ it("drops malformed profiles when parsing", () => {
88+ const store = parseCliServerConnectionStore(
89+ JSON.stringify({
90+ version: 1,
91+ defaultProfile: "missing",
92+ profiles: [
93+ { name: "valid", connection: { origin: "https://executor.example" } },
94+ { name: "bad space", connection: { origin: "https://ignored.example" } },
95+ { name: "no-origin", connection: {} },
96+ ],
97+ }),
98+ );
99+
100+ expect(store.defaultProfile).toBeNull();
101+ expect(store.profiles.map((profile) => profile.name)).toEqual(["valid"]);
102+ });
103+
104+ it("preserves desktop sidecar profile kind", () => {
105+ const store = parseCliServerConnectionStore(
106+ JSON.stringify({
107+ version: 1,
108+ defaultProfile: "desktop",
109+ profiles: [
110+ {
111+ name: "desktop",
112+ connection: {
113+ kind: "desktop-sidecar",
114+ key: "desktop-sidecar",
115+ origin: "http://127.0.0.1:4789",
116+ auth: { kind: "basic", username: "executor", password: "secret" },
117+ },
118+ },
119+ ],
120+ }),
121+ );
122+
123+ expect(store.defaultProfile).toBe("desktop");
124+ expect(store.profiles[0]?.connection.kind).toBe("desktop-sidecar");
125+ expect(store.profiles[0]?.connection.auth).toEqual({
126+ kind: "basic",
127+ username: "executor",
128+ password: "secret",
129+ });
130+ });
131+});
apps/cli/src/server-profile.ts+217−0
@@ -0,0 +1,217 @@
1+import { homedir } from "node:os";
2+import { FileSystem, Option, Path, Schema } from "effect";
3+import type { PlatformError } from "effect/PlatformError";
4+import * as Effect from "effect/Effect";
5+
6+import {
7+ normalizeExecutorServerConnection,
8+ type ExecutorServerConnection,
9+ type ExecutorServerConnectionInput,
10+} from "@executor-js/sdk/shared";
11+
12+export interface CliServerConnectionProfile {
13+ readonly name: string;
14+ readonly connection: ExecutorServerConnection;
15+}
16+
17+export interface CliServerConnectionStore {
18+ readonly version: 1;
19+ readonly defaultProfile: string | null;
20+ readonly profiles: readonly CliServerConnectionProfile[];
21+}
22+
23+export const emptyCliServerConnectionStore: CliServerConnectionStore = {
24+ version: 1,
25+ defaultProfile: null,
26+ profiles: [],
27+};
28+
29+export const validateCliServerConnectionProfileName = (name: string): string => {
30+ const trimmed = name.trim();
31+ if (!/^[A-Za-z0-9_.-]+$/.test(trimmed)) {
32+ throw new Error(
33+ "Server profile names may contain only letters, numbers, dots, underscores, and dashes.",
34+ );
35+ }
36+ return trimmed;
37+};
38+
39+const resolveDataDir = (path: Path.Path): string =>
40+ process.env.EXECUTOR_DATA_DIR ?? path.join(homedir(), ".executor");
41+
42+const serverConnectionStorePath = (path: Path.Path): string =>
43+ path.join(resolveDataDir(path), "server-connections.json");
44+
45+const PersistedAuth = Schema.Union([
46+ Schema.Struct({
47+ kind: Schema.Literal("basic"),
48+ username: Schema.optional(Schema.String),
49+ password: Schema.String,
50+ }),
51+ Schema.Struct({
52+ kind: Schema.Literal("bearer"),
53+ token: Schema.String,
54+ }),
55+]);
56+
57+const PersistedConnection = Schema.Struct({
58+ kind: Schema.optional(Schema.Literals(["http", "desktop-sidecar"])),
59+ key: Schema.optional(Schema.String),
60+ origin: Schema.optional(Schema.String),
61+ apiBaseUrl: Schema.optional(Schema.String),
62+ displayName: Schema.optional(Schema.String),
63+ auth: Schema.optional(PersistedAuth),
64+});
65+
66+const PersistedProfile = Schema.Struct({
67+ name: Schema.String,
68+ connection: PersistedConnection,
69+});
70+
71+const PersistedStore = Schema.Struct({
72+ version: Schema.Literal(1),
73+ defaultProfile: Schema.optional(Schema.NullOr(Schema.String)),
74+ profiles: Schema.Array(PersistedProfile),
75+});
76+
77+const decodeStoreJson = Schema.decodeUnknownOption(Schema.fromJsonString(PersistedStore));
78+
79+const decodeConnection = (
80+ input: ExecutorServerConnectionInput,
81+): ExecutorServerConnection | null => {
82+ if (!input.origin && !input.apiBaseUrl) return null;
83+ return normalizeExecutorServerConnection(input);
84+};
85+
86+export const parseCliServerConnectionStore = (raw: string): CliServerConnectionStore => {
87+ const decoded = decodeStoreJson(raw);
88+ if (Option.isNone(decoded)) return emptyCliServerConnectionStore;
89+ const record = decoded.value;
90+
91+ const profiles = record.profiles.flatMap((value): readonly CliServerConnectionProfile[] => {
92+ const connection = decodeConnection(value.connection);
93+ if (!connection) return [];
94+ try {
95+ return [{ name: validateCliServerConnectionProfileName(value.name), connection }];
96+ } catch {
97+ return [];
98+ }
99+ });
100+
101+ const defaultProfile =
102+ record.defaultProfile && profiles.some((profile) => profile.name === record.defaultProfile)
103+ ? record.defaultProfile
104+ : null;
105+
106+ return {
107+ version: 1,
108+ defaultProfile,
109+ profiles,
110+ };
111+};
112+
113+const serializeCliServerConnectionStore = (store: CliServerConnectionStore): string =>
114+ `${JSON.stringify(store, null, 2)}\n`;
115+
116+export const readCliServerConnectionStore = (): Effect.Effect<
117+ CliServerConnectionStore,
118+ never,
119+ FileSystem.FileSystem | Path.Path
120+> =>
121+ Effect.gen(function* () {
122+ const fs = yield* FileSystem.FileSystem;
123+ const path = yield* Path.Path;
124+ const raw = yield* fs
125+ .readFileString(serverConnectionStorePath(path))
126+ .pipe(Effect.catchCause(() => Effect.succeed(null)));
127+ if (raw === null) return emptyCliServerConnectionStore;
128+ return parseCliServerConnectionStore(raw);
129+ });
130+
131+export const writeCliServerConnectionStore = (
132+ store: CliServerConnectionStore,
133+): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
134+ Effect.gen(function* () {
135+ const fs = yield* FileSystem.FileSystem;
136+ const path = yield* Path.Path;
137+ const dataDir = resolveDataDir(path);
138+ yield* fs.makeDirectory(dataDir, { recursive: true });
139+ yield* fs.writeFileString(
140+ serverConnectionStorePath(path),
141+ serializeCliServerConnectionStore(store),
142+ );
143+ });
144+
145+export const upsertCliServerConnectionProfile = (input: {
146+ readonly name: string;
147+ readonly connection: ExecutorServerConnectionInput;
148+ readonly makeDefault: boolean;
149+}): Effect.Effect<CliServerConnectionStore, PlatformError, FileSystem.FileSystem | Path.Path> =>
150+ Effect.gen(function* () {
151+ const name = validateCliServerConnectionProfileName(input.name);
152+ const store = yield* readCliServerConnectionStore();
153+ const connection = normalizeExecutorServerConnection({
154+ ...input.connection,
155+ key: input.connection.key ?? `profile:${name}`,
156+ displayName: input.connection.displayName ?? name,
157+ });
158+ const nextProfiles = [
159+ ...store.profiles.filter((profile) => profile.name !== name),
160+ { name, connection },
161+ ].sort((a, b) => a.name.localeCompare(b.name));
162+ const nextStore: CliServerConnectionStore = {
163+ version: 1,
164+ defaultProfile:
165+ input.makeDefault || store.defaultProfile === null ? name : store.defaultProfile,
166+ profiles: nextProfiles,
167+ };
168+ yield* writeCliServerConnectionStore(nextStore);
169+ return nextStore;
170+ });
171+
172+export const setDefaultCliServerConnectionProfile = (
173+ name: string,
174+): Effect.Effect<
175+ CliServerConnectionStore,
176+ Error | PlatformError,
177+ FileSystem.FileSystem | Path.Path
178+> =>
179+ Effect.gen(function* () {
180+ const profileName = validateCliServerConnectionProfileName(name);
181+ const store = yield* readCliServerConnectionStore();
182+ if (!store.profiles.some((profile) => profile.name === profileName)) {
183+ return yield* Effect.fail(new Error(`No server profile named "${profileName}".`));
184+ }
185+ const nextStore: CliServerConnectionStore = { ...store, defaultProfile: profileName };
186+ yield* writeCliServerConnectionStore(nextStore);
187+ return nextStore;
188+ });
189+
190+export const removeCliServerConnectionProfile = (
191+ name: string,
192+): Effect.Effect<CliServerConnectionStore, PlatformError, FileSystem.FileSystem | Path.Path> =>
193+ Effect.gen(function* () {
194+ const profileName = validateCliServerConnectionProfileName(name);
195+ const store = yield* readCliServerConnectionStore();
196+ const nextProfiles = store.profiles.filter((profile) => profile.name !== profileName);
197+ const nextStore: CliServerConnectionStore = {
198+ version: 1,
199+ defaultProfile: store.defaultProfile === profileName ? null : store.defaultProfile,
200+ profiles: nextProfiles,
201+ };
202+ yield* writeCliServerConnectionStore(nextStore);
203+ return nextStore;
204+ });
205+
206+export const findCliServerConnectionProfile = (
207+ store: CliServerConnectionStore,
208+ name: string,
209+): CliServerConnectionProfile | null => {
210+ const profileName = validateCliServerConnectionProfileName(name);
211+ return store.profiles.find((profile) => profile.name === profileName) ?? null;
212+};
213+
214+export const defaultCliServerConnectionProfile = (
215+ store: CliServerConnectionStore,
216+): CliServerConnectionProfile | null =>
217+ store.defaultProfile ? findCliServerConnectionProfile(store, store.defaultProfile) : null;
apps/local/src/serve.test.ts+74−0
@@ -116,4 +116,78 @@ describe("startServer network bind auth", () => { · 115 unmodified lines
116116 expect(authorized.status).toBe(200);
117117 expect(await authorized.text()).toBe("ok");
118118 });
119+
120+ it("answers browser CORS preflights before auth", async () => {
121+ server = await startServer({
122+ port: 0,
123+ hostname: "127.0.0.1",
124+ clientDir,
125+ authToken: "test-token",
126+ handlers: {
127+ api: {
128+ handler: async () => new Response("ok"),
129+ dispose: async () => {},
130+ },
131+ mcp: {
132+ handleRequest: async () => new Response("ok"),
133+ handleApprovalRequest: async () => new Response("ok"),
134+ close: async () => {},
135+ },
136+ },
137+ });
138+
139+ const response = await fetch(`http://127.0.0.1:${server.port}/api/scope`, {
140+ method: "OPTIONS",
141+ headers: {
142+ origin: "http://127.0.0.1:4789",
143+ "access-control-request-method": "GET",
144+ "access-control-request-headers": "authorization,b3,traceparent",
145+ },
146+ });
147+
148+ expect(response.status).toBe(204);
149+ expect(response.headers.get("access-control-allow-origin")).toBe("http://127.0.0.1:4789");
150+ expect(response.headers.get("access-control-allow-credentials")).toBe("true");
151+ expect(response.headers.get("access-control-allow-headers")).toContain("traceparent");
152+ expect(response.headers.get("access-control-allow-headers")).toContain("b3");
153+ });
154+
155+ it("adds CORS headers to authenticated API failures", async () => {
156+ server = await startServer({
157+ port: 0,
158+ hostname: "127.0.0.1",
159+ clientDir,
160+ authToken: "test-token",
161+ handlers: {
162+ api: {
163+ handler: async () => new Response("ok"),
164+ dispose: async () => {},
165+ },
166+ mcp: {
167+ handleRequest: async () => new Response("ok"),
168+ handleApprovalRequest: async () => new Response("ok"),
169+ close: async () => {},
170+ },
171+ },
172+ });
173+
174+ const response = await fetch(`http://127.0.0.1:${server.port}/api/scope`, {
175+ headers: { origin: "http://127.0.0.1:4789" },
176+ });
177+
178+ expect(response.status).toBe(401);
179+ expect(response.headers.get("access-control-allow-origin")).toBe("http://127.0.0.1:4789");
180+ expect(response.headers.get("access-control-allow-credentials")).toBe("true");
181+ expect(response.headers.get("access-control-allow-headers")).toContain("traceparent");
182+ });
183+
184+ it("does not expose unauthenticated loopback servers cross-origin", async () => {
185+ const baseUrl = await startTestServer();
186+ const response = await fetch(`${baseUrl}/api/scope`, {
187+ headers: { origin: "https://example.com" },
188+ });
189+
190+ expect(response.status).toBe(200);
191+ expect(response.headers.get("access-control-allow-origin")).toBeNull();
192+ });
119193 });
apps/local/src/serve.ts+55−14
@@ -200,6 +200,36 @@ export interface ServerInstance { · 199 unmodified lines
200200
201201 type ServerHandlers = Awaited<ReturnType<typeof getServerHandlers>>;
202202
203+const corsHeaders = {
204+ "access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
205+ "access-control-allow-headers":
206+ "authorization, content-type, x-executor-token, x-requested-with, traceparent, tracestate, baggage, b3",
207+ "access-control-allow-credentials": "true",
208+ "access-control-expose-headers": "*",
209+} as const;
210+
211+const withCorsHeaders = (req: Request, response: Response): Response => {
212+ const origin = req.headers.get("origin");
213+ if (!origin) return response;
214+ const headers = new Headers(response.headers);
215+ headers.set("access-control-allow-origin", origin);
216+ for (const [key, value] of Object.entries(corsHeaders)) headers.set(key, value);
217+ headers.set(
218+ "access-control-allow-headers",
219+ req.headers.get("access-control-request-headers") ??
220+ corsHeaders["access-control-allow-headers"],
221+ );
222+ headers.append("vary", "Origin");
223+ return new Response(response.body, {
224+ status: response.status,
225+ statusText: response.statusText,
226+ headers,
227+ });
228+};
229+
230+const corsPreflightResponse = (req: Request): Response =>
231+ withCorsHeaders(req, new Response(null, { status: 204 }));
232+
203233 export async function startServer(opts: StartServerOptions = {}): Promise<ServerInstance> {
204234 const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10);
205235 const hostname = opts.hostname ?? "127.0.0.1";
@@ -264,8 +294,15 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 58 unmodified lines
264294 idleTimeout: 0,
265295 routes: { ...staticRoutes },
266296 async fetch(req) {
297+ const maybeWithCorsHeaders = (response: Response): Response =>
298+ requiresAuth ? withCorsHeaders(req, response) : response;
299+
267300 if (!isAllowedHost(req)) {
268− return new Response("Forbidden", { status: 403 });
301+ return maybeWithCorsHeaders(new Response("Forbidden", { status: 403 }));
302+ }
303+
304+ if (requiresAuth && req.method === "OPTIONS" && req.headers.has("origin")) {
305+ return corsPreflightResponse(req);
269306 }
270307
271308 const url = new URL(req.url);
@@ -276,18 +313,20 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 4 unmodified lines
276313 const skipAuth = isUnauthenticatedOAuthCallbackPath(url.pathname);
277314
278315 if (requiresAuth && !skipAuth && !isAuthorized(req)) {
279− return new Response("Unauthorized", {
280− status: 401,
281− headers: { "www-authenticate": 'Bearer realm="executor", Basic realm="executor"' },
282− });
316+ return maybeWithCorsHeaders(
317+ new Response("Unauthorized", {
318+ status: 401,
319+ headers: { "www-authenticate": 'Bearer realm="executor", Basic realm="executor"' },
320+ }),
321+ );
283322 }
284323
285324 if (url.pathname.startsWith("/mcp")) {
286− return handlers.mcp.handleRequest(req);
325+ return maybeWithCorsHeaders(await handlers.mcp.handleRequest(req));
287326 }
288327
289328 if (url.pathname.startsWith("/api/mcp-sessions/")) {
290− return handlers.mcp.handleApprovalRequest(req);
329+ return maybeWithCorsHeaders(await handlers.mcp.handleApprovalRequest(req));
291330 }
292331
293332 // OAuth result polling — local-only, served outside the typed API
@@ -296,31 +335,33 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 2 unmodified lines
296335 const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(url.pathname);
297336 if (awaitMatch && req.method === "GET") {
298337 const result = consumeOAuthResult(awaitMatch[1]);
299− return new Response(JSON.stringify(result), {
300− headers: { "content-type": "application/json" },
301− });
338+ return maybeWithCorsHeaders(
339+ new Response(JSON.stringify(result), {
340+ headers: { "content-type": "application/json" },
341+ }),
342+ );
302343 }
303344
304345 if (url.pathname.startsWith("/api/") || url.pathname === "/api") {
305346 url.pathname = url.pathname.slice("/api".length) || "/";
306− return handlers.api.handler(new Request(url, req));
347+ return maybeWithCorsHeaders(await handlers.api.handler(new Request(url, req)));
307348 }
308349
309350 // Dev mode: forward everything else (SPA + hashed assets) to the
310351 // vite child so source edits show up without a rebuild.
311352 if (viteChild) {
312− return proxyToVite(req, viteChild.url);
353+ return maybeWithCorsHeaders(await proxyToVite(req, viteChild.url));
313354 }
314355
315356 // If a path looks like a static asset (has a file extension), do not
316357 // fall back to SPA HTML. Returning index.html here causes browser module
317358 // MIME errors when hashed chunks are stale/missing.
318359 if (hasFileExtension(url.pathname)) {
319− return new Response("Not Found", { status: 404 });
360+ return maybeWithCorsHeaders(new Response("Not Found", { status: 404 }));
320361 }
321362
322363 // SPA fallback
323− return serveIndex();
364+ return maybeWithCorsHeaders(await serveIndex());
324365 },
325366 error(error) {
326367 console.error("Server error:", error);
bun.lock+5−0
@@ -39,13 +39,16 @@ · 38 unmodified lines
3939 "@executor-js/integrations-registry": "workspace:*",
4040 "@executor-js/local": "workspace:*",
4141 "@executor-js/runtime-quickjs": "workspace:*",
42+ "@executor-js/sdk": "workspace:*",
4243 "@jitl/quickjs-wasmfile-release-sync": "catalog:",
4344 "effect": "catalog:",
4445 "quickjs-emscripten": "catalog:",
4546 },
4647 "devDependencies": {
48+ "@effect/vitest": "catalog:",
4749 "bun-types": "catalog:",
4850 "typescript": "catalog:",
51+ "vitest": "catalog:",
4952 },
5053 },
5154 "apps/cloud": {
@@ -135,6 +138,7 @@ · 83 unmodified lines
135138 "@executor-js/plugin-onepassword": "workspace:*",
136139 "@executor-js/plugin-openapi": "workspace:*",
137140 "@executor-js/runtime-quickjs": "workspace:*",
141+ "@executor-js/sdk": "workspace:*",
138142 "@jitl/quickjs-wasmfile-release-sync": "catalog:",
139143 "@modelcontextprotocol/sdk": "^1.29.0",
140144 "@types/node": "catalog:",
@@ -277,6 +281,7 @@ · 136 unmodified lines
277281 "@executor-js/vite-plugin": "workspace:*",
278282 "@tanstack/react-router": "catalog:",
279283 "effect": "catalog:",
284+ "lucide-react": "^1.7.0",
280285 "react": "catalog:",
281286 "react-dom": "catalog:",
282287 },