@lumi.ai/runner 0.5.12 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +30 -0
  2. package/dist/cli.js +455 -51
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -117,6 +117,36 @@ Each job is a separate `claude` process with its own empty working directory, so
117
117
  state — but they do share your CPU, your RAM and your Claude usage window. Start at 2 and watch a
118
118
  real job before going higher. Restart the daemon (`lumi-runner service restart`) to pick up a change.
119
119
 
120
+ ## MCP servers that run on this machine
121
+
122
+ A Ship can register MCP connections its agents reach. Most are remote — an `https://` address the
123
+ session talks to. Two kinds are **local**: a program this machine starts (`npx -y some-mcp-server`,
124
+ `node ./my-server.mjs`), and a server already listening on this machine's own loopback address
125
+ (`http://127.0.0.1:3000/mcp`).
126
+
127
+ **Those are off until you allow them, one Ship at a time.**
128
+
129
+ ```bash
130
+ lumi-runner config set local-mcp on --ship shp_abc123 # this Ship may run programs here
131
+ lumi-runner config set local-mcp off --ship shp_abc123 # stop allowing it
132
+ lumi-runner doctor # which Ships have local connections, and whether they may run
133
+ ```
134
+
135
+ There is no machine-wide form on purpose. A captain registers these in a browser and may not be the
136
+ person who owns this computer — and if this daemon serves several Ships, a single switch would enrol
137
+ every one of them, including any added later. So it is granted per Ship, by you, here.
138
+
139
+ What a Ship gets when you allow it: the connections a captain registered **on that Ship**, activated
140
+ on the agent running the job, and nothing else. The daemon writes them into the session's config
141
+ file — your own `~/.claude.json` and any `.mcp.json` lying around are still invisible to every agent
142
+ session, exactly as before.
143
+
144
+ Credentials for these live on the Ship, not here: a captain marks a variable secret and it is stored
145
+ where nothing reads it back, then handed to the program at start and scrubbed from the transcript.
146
+ Press **Test** on the connection in Ship Settings and this machine runs the test and reports what it
147
+ found — the Crew backend cannot reach a program on your laptop, so it asks you instead. Restart the
148
+ daemon (`lumi-runner service restart`) after changing this.
149
+
120
150
  ## Staying current
121
151
 
122
152
  **This machine updates itself.** Every half hour the daemon asks npm whether there is a newer
package/dist/cli.js CHANGED
@@ -442,8 +442,8 @@ ${ranked.length} document${ranked.length === 1 ? "" : "s"}. These are SUMMARIES
442
442
  room -= slug.length + 2;
443
443
  }
444
444
  const rest = dropped.length - named.length;
445
- const tail = OVERFLOW_PREFIX + named.join(", ") + (rest > 0 ? ` \u2026 and ${rest} more.` : ".");
446
- return head + lines.join("\n") + tail;
445
+ const tail2 = OVERFLOW_PREFIX + named.join(", ") + (rest > 0 ? ` \u2026 and ${rest} more.` : ".");
446
+ return head + lines.join("\n") + tail2;
447
447
  }
448
448
 
449
449
  // ../shared/dist/docMedia.js
@@ -466,6 +466,25 @@ var DOC_MEDIA_TYPES = [
466
466
  ];
467
467
 
468
468
  // ../shared/dist/mcpServer.js
469
+ function isLocalMcpServer(server) {
470
+ if (server.transport === "stdio")
471
+ return true;
472
+ return isLoopbackUrl(server.url);
473
+ }
474
+ function isLoopbackUrl(value) {
475
+ if (typeof value !== "string" || !value.trim())
476
+ return false;
477
+ let url;
478
+ try {
479
+ url = new URL(value);
480
+ } catch {
481
+ return false;
482
+ }
483
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
484
+ if (host === "localhost" || host === "::1")
485
+ return true;
486
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
487
+ }
469
488
  function mcpToolGrants(server) {
470
489
  const tools = server.tools ?? [];
471
490
  if (tools.length === 0)
@@ -732,14 +751,20 @@ function forgetShip(config2, shipId) {
732
751
  delete overrides[shipId];
733
752
  next.shipParallelJobs = overrides;
734
753
  }
754
+ if (next.allowLocalMcp) {
755
+ next.allowLocalMcp = next.allowLocalMcp.filter((id) => id !== shipId);
756
+ }
735
757
  return next;
736
758
  }
759
+ function allowsLocalMcp(config2, shipId) {
760
+ return Array.isArray(config2.allowLocalMcp) && config2.allowLocalMcp.includes(shipId);
761
+ }
737
762
  function mcpUrl(config2) {
738
763
  return process.env.CREW_MCP_URL || config2.mcpUrl || `https://us-central1-${config2.projectId}.cloudfunctions.net/workspaceMcp`;
739
764
  }
740
765
 
741
766
  // src/version.ts
742
- var RUNNER_VERSION = true ? "0.5.12" : "0.0.0-dev";
767
+ var RUNNER_VERSION = true ? "0.6.0" : "0.0.0-dev";
743
768
 
744
769
  // src/auth.ts
745
770
  import { signInWithCustomToken } from "firebase/auth";
@@ -752,8 +777,8 @@ function functionsBaseUrlFor(projectId) {
752
777
  return process.env.CREW_FUNCTIONS_URL?.replace(/\/$/, "") || `https://us-central1-${projectId}.cloudfunctions.net`;
753
778
  }
754
779
  var CallableError = class extends Error {
755
- constructor(status, message) {
756
- super(message);
780
+ constructor(status, message2) {
781
+ super(message2);
757
782
  this.status = status;
758
783
  }
759
784
  status;
@@ -786,8 +811,8 @@ async function request(baseUrl, name, data, headers) {
786
811
 
787
812
  // src/auth.ts
788
813
  var AuthError = class extends Error {
789
- constructor(message, gone = false) {
790
- super(message);
814
+ constructor(message2, gone = false) {
815
+ super(message2);
791
816
  this.gone = gone;
792
817
  }
793
818
  gone;
@@ -1105,8 +1130,8 @@ function isTransientFirestoreError(error) {
1105
1130
  if (code === "unavailable" || code === "deadline-exceeded" || code === "internal" || code === "cancelled" || code === "aborted" || code === "resource-exhausted") {
1106
1131
  return true;
1107
1132
  }
1108
- const message = error?.message;
1109
- return typeof message === "string" && message.toLowerCase().includes("client is offline");
1133
+ const message2 = error?.message;
1134
+ return typeof message2 === "string" && message2.toLowerCase().includes("client is offline");
1110
1135
  }
1111
1136
  async function withFirestoreRetry(read, sleep2 = (ms) => new Promise((r) => setTimeout(r, ms)), delays = FIRESTORE_RETRY_DELAYS_MS) {
1112
1137
  for (let i = 0; ; i++) {
@@ -1594,8 +1619,8 @@ import path3 from "node:path";
1594
1619
  function stepsFromClaudeEvent(event, opts) {
1595
1620
  const type = typeof event?.type === "string" ? event.type : "";
1596
1621
  if (type === "assistant") {
1597
- const message = event.message;
1598
- const content = Array.isArray(message?.content) ? message.content : [];
1622
+ const message2 = event.message;
1623
+ const content = Array.isArray(message2?.content) ? message2.content : [];
1599
1624
  const steps = [];
1600
1625
  for (const block of content) {
1601
1626
  if (!block || typeof block !== "object") continue;
@@ -1694,6 +1719,15 @@ function buildMcpConfig(input) {
1694
1719
  };
1695
1720
  for (const s of input.extraServers ?? []) {
1696
1721
  if (s.key === "workspace") continue;
1722
+ if (s.transport === "stdio") {
1723
+ mcpServers[s.key] = {
1724
+ type: "stdio",
1725
+ command: s.command,
1726
+ ...s.args?.length ? { args: s.args } : {},
1727
+ ...s.env && Object.keys(s.env).length > 0 ? { env: s.env } : {}
1728
+ };
1729
+ continue;
1730
+ }
1697
1731
  mcpServers[s.key] = {
1698
1732
  type: s.transport === "sse" ? "sse" : "http",
1699
1733
  url: s.url,
@@ -1951,7 +1985,15 @@ async function resolveMcpServers(input) {
1951
1985
  functionsBaseUrl(input.config),
1952
1986
  input.idToken,
1953
1987
  "mintMcpJobConnections",
1954
- { shipId: input.shipId, jobId: input.jobId }
1988
+ {
1989
+ shipId: input.shipId,
1990
+ jobId: input.jobId,
1991
+ // §15.40: the backend needs this to decide whether this daemon may be handed a LOCAL
1992
+ // connection. Sent rather than read from `runners/{id}`, which the heartbeat writes and
1993
+ // which is therefore at its most stale immediately after an auto-update — exactly when a
1994
+ // captain is waiting for their local connection to start working.
1995
+ runnerVersion: RUNNER_VERSION
1996
+ }
1955
1997
  );
1956
1998
  } catch (e) {
1957
1999
  input.log(
@@ -1962,17 +2004,265 @@ async function resolveMcpServers(input) {
1962
2004
  for (const s of res.skipped ?? []) {
1963
2005
  input.log(`MCP connection "${s.key}" skipped: ${s.reason}.`);
1964
2006
  }
1965
- return res.servers ?? [];
2007
+ return filterLocal(res.servers ?? [], input);
2008
+ }
2009
+ function filterLocal(servers, input) {
2010
+ if (servers.every((s) => !isLocalMcpServer(s))) return [...servers];
2011
+ if (allowsLocalMcp(input.config, input.shipId)) return [...servers];
2012
+ const kept = [];
2013
+ for (const server of servers) {
2014
+ if (!isLocalMcpServer(server)) {
2015
+ kept.push(server);
2016
+ continue;
2017
+ }
2018
+ input.log(
2019
+ `MCP connection "${server.key}" skipped: local MCP is off for this Ship on this machine \u2014 run \`lumi-runner config set local-mcp on --ship ${input.shipId}\` here to allow it.`
2020
+ );
2021
+ }
2022
+ return kept;
1966
2023
  }
1967
2024
  function mcpSecretsToRedact(servers) {
1968
2025
  const out = [];
1969
2026
  for (const s of servers) {
1970
2027
  out.push(...Object.values(s.headers ?? {}));
1971
2028
  if (s.secretValue) out.push(s.secretValue);
2029
+ out.push(...s.secretEnvValues ?? []);
1972
2030
  }
1973
2031
  return out;
1974
2032
  }
1975
2033
 
2034
+ // src/jobs/localProbe.ts
2035
+ import { spawn as spawn4 } from "node:child_process";
2036
+ var PROBE_TIMEOUT_MS = 15e3;
2037
+ var PROTOCOL_VERSION = "2024-11-05";
2038
+ async function probeAndReport(input) {
2039
+ const { shipId, server } = input;
2040
+ if (!allowsLocalMcp(input.config, shipId)) {
2041
+ await report(input, {
2042
+ ok: false,
2043
+ toolNames: [],
2044
+ error: `This machine has not allowed this Ship to run local MCP connections. On the machine, run: lumi-runner config set local-mcp on --ship ${shipId}`
2045
+ });
2046
+ return;
2047
+ }
2048
+ let resolved;
2049
+ try {
2050
+ const res = await callFunction(
2051
+ functionsBaseUrl(input.config),
2052
+ input.idToken,
2053
+ "mintShipMcpProbe",
2054
+ { shipId, serverKey: server.id, runnerVersion: RUNNER_VERSION }
2055
+ );
2056
+ resolved = res.server;
2057
+ } catch (e) {
2058
+ await report(input, { ok: false, toolNames: [], error: message(e) });
2059
+ return;
2060
+ }
2061
+ const result = await probe(resolved);
2062
+ await report(input, result);
2063
+ input.log(
2064
+ result.ok ? `MCP connection "${server.id}" tested: ${result.toolNames.length} tool(s).` : `MCP connection "${server.id}" could not be started: ${result.error}`
2065
+ );
2066
+ }
2067
+ async function probe(server) {
2068
+ try {
2069
+ const toolNames = server.transport === "stdio" ? await probeStdio(server) : await probeHttp(server);
2070
+ return { ok: true, toolNames };
2071
+ } catch (e) {
2072
+ return { ok: false, toolNames: [], error: message(e) };
2073
+ }
2074
+ }
2075
+ async function probeStdio(server) {
2076
+ const child = spawn4(server.command ?? "", server.args ?? [], {
2077
+ // The parent's environment PLUS the Ship's, not the Ship's alone: a program started with no
2078
+ // PATH cannot find node, and `npx` cannot find anything at all. The Ship's values win, which is
2079
+ // what lets a connection override a variable the operator happens to have set.
2080
+ env: { ...process.env, ...server.env ?? {} },
2081
+ stdio: ["pipe", "pipe", "pipe"]
2082
+ });
2083
+ let stderr = "";
2084
+ child.stderr.on("data", (chunk) => {
2085
+ if (stderr.length < 4e3) stderr += chunk.toString();
2086
+ });
2087
+ const replies = new RpcReader(child.stdout);
2088
+ const spawnFailed = new Promise((_, reject) => {
2089
+ child.on("error", (e) => reject(new Error(`the program could not be started: ${e.message}`)));
2090
+ });
2091
+ child.on("close", () => replies.close());
2092
+ try {
2093
+ return await withTimeout(
2094
+ Promise.race([
2095
+ spawnFailed,
2096
+ (async () => {
2097
+ send(child, 1, "initialize", {
2098
+ protocolVersion: PROTOCOL_VERSION,
2099
+ capabilities: {},
2100
+ clientInfo: { name: "lumi-runner", version: RUNNER_VERSION }
2101
+ });
2102
+ await replies.waitFor(1, () => stderr);
2103
+ child.stdin.write(
2104
+ `${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}
2105
+ `
2106
+ );
2107
+ send(child, 2, "tools/list", {});
2108
+ return toolNamesFrom(await replies.waitFor(2, () => stderr));
2109
+ })()
2110
+ ]),
2111
+ // The stderr tail is the useful half of a startup failure — "MODULE_NOT_FOUND", "missing
2112
+ // CLICKUP_API_KEY" — and without it the captain gets "timed out" and nothing to act on.
2113
+ () => `it did not respond within ${PROBE_TIMEOUT_MS / 1e3}s${stderr.trim() ? `: ${tail(stderr)}` : ""}`
2114
+ );
2115
+ } finally {
2116
+ child.kill("SIGKILL");
2117
+ }
2118
+ }
2119
+ async function probeHttp(server) {
2120
+ const post = async (id, method, params) => {
2121
+ const res = await fetch(server.url ?? "", {
2122
+ method: "POST",
2123
+ headers: {
2124
+ "content-type": "application/json",
2125
+ // Streamable HTTP servers may answer either way, and a server that only speaks SSE will
2126
+ // refuse a request that does not say it can read one.
2127
+ accept: "application/json, text/event-stream",
2128
+ ...server.headers ?? {}
2129
+ },
2130
+ body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
2131
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
2132
+ });
2133
+ if (!res.ok) throw new Error(`the server answered ${res.status} ${res.statusText}`);
2134
+ const body = await res.text();
2135
+ return parseRpc(body, id);
2136
+ };
2137
+ await post(1, "initialize", {
2138
+ protocolVersion: PROTOCOL_VERSION,
2139
+ capabilities: {},
2140
+ clientInfo: { name: "lumi-runner", version: RUNNER_VERSION }
2141
+ });
2142
+ return toolNamesFrom(await post(2, "tools/list", {}));
2143
+ }
2144
+ function parseRpc(body, id) {
2145
+ const candidates = body.includes("data:") ? body.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()) : [body.trim()];
2146
+ for (const candidate of candidates) {
2147
+ if (!candidate) continue;
2148
+ let parsed;
2149
+ try {
2150
+ parsed = JSON.parse(candidate);
2151
+ } catch {
2152
+ continue;
2153
+ }
2154
+ if (parsed.id !== id) continue;
2155
+ if (parsed.error) throw new Error(parsed.error.message ?? "the server returned an error");
2156
+ return parsed.result;
2157
+ }
2158
+ throw new Error("the server did not answer in a shape this understands");
2159
+ }
2160
+ function send(child, id, method, params) {
2161
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}
2162
+ `);
2163
+ }
2164
+ var RpcReader = class {
2165
+ buffer = "";
2166
+ seen = [];
2167
+ wake = [];
2168
+ closed = false;
2169
+ constructor(stream) {
2170
+ stream.on("data", (chunk) => {
2171
+ this.buffer += chunk.toString();
2172
+ let index = this.buffer.indexOf("\n");
2173
+ while (index >= 0) {
2174
+ const line = this.buffer.slice(0, index).trim();
2175
+ this.buffer = this.buffer.slice(index + 1);
2176
+ if (line) this.accept(line);
2177
+ index = this.buffer.indexOf("\n");
2178
+ }
2179
+ });
2180
+ }
2181
+ accept(line) {
2182
+ let parsed;
2183
+ try {
2184
+ parsed = JSON.parse(line);
2185
+ } catch {
2186
+ return;
2187
+ }
2188
+ if (typeof parsed.id !== "number") return;
2189
+ this.seen.push({
2190
+ id: parsed.id,
2191
+ ...parsed.error ? { error: parsed.error.message ?? "the server returned an error" } : {},
2192
+ result: parsed.result
2193
+ });
2194
+ this.notify();
2195
+ }
2196
+ close() {
2197
+ this.closed = true;
2198
+ this.notify();
2199
+ }
2200
+ notify() {
2201
+ const waiters = this.wake;
2202
+ this.wake = [];
2203
+ for (const w of waiters) w();
2204
+ }
2205
+ /** The response with this id, or a throw naming what the program said instead. */
2206
+ async waitFor(id, stderr) {
2207
+ for (; ; ) {
2208
+ const hit = this.seen.find((r) => r.id === id);
2209
+ if (hit) {
2210
+ if (hit.error) throw new Error(hit.error);
2211
+ return hit.result;
2212
+ }
2213
+ if (this.closed) {
2214
+ const said = stderr().trim();
2215
+ throw new Error(
2216
+ `the program stopped without answering${said ? `: ${tail(said)}` : ""}`
2217
+ );
2218
+ }
2219
+ await new Promise((resolve) => this.wake.push(resolve));
2220
+ }
2221
+ }
2222
+ };
2223
+ function toolNamesFrom(result) {
2224
+ const tools = result?.tools ?? [];
2225
+ return tools.map((t) => String(t?.name ?? "")).filter(Boolean);
2226
+ }
2227
+ async function withTimeout(work, onTimeout) {
2228
+ let timer;
2229
+ try {
2230
+ return await Promise.race([
2231
+ work,
2232
+ new Promise((_, reject) => {
2233
+ timer = setTimeout(() => reject(new Error(onTimeout())), PROBE_TIMEOUT_MS);
2234
+ })
2235
+ ]);
2236
+ } finally {
2237
+ if (timer) clearTimeout(timer);
2238
+ }
2239
+ }
2240
+ async function report(input, result) {
2241
+ try {
2242
+ await callFunction(
2243
+ functionsBaseUrl(input.config),
2244
+ input.idToken,
2245
+ "reportShipMcpProbe",
2246
+ {
2247
+ shipId: input.shipId,
2248
+ serverKey: input.server.id,
2249
+ ok: result.ok,
2250
+ toolNames: result.toolNames,
2251
+ ...result.error ? { error: result.error } : {}
2252
+ }
2253
+ );
2254
+ } catch (e) {
2255
+ input.log(`Could not report the MCP test for "${input.server.id}": ${message(e)}`);
2256
+ }
2257
+ }
2258
+ var message = (e) => e instanceof Error ? e.message : String(e);
2259
+ var tail = (s) => s.trim().slice(-300);
2260
+ function serversNeedingProbe(servers) {
2261
+ return servers.filter(
2262
+ (s) => s.enabled !== false && isLocalMcpServer(s) && typeof s.probeRequestedAt === "number" && s.probeRequestedAt > (s.probe?.at ?? 0)
2263
+ );
2264
+ }
2265
+
1976
2266
  // src/jobs/secrets.ts
1977
2267
  import { doc as doc4, getDoc as getDoc4 } from "firebase/firestore";
1978
2268
  async function loadRunnerSecrets(db, shipId) {
@@ -2940,15 +3230,15 @@ function clearUpdateState() {
2940
3230
  }
2941
3231
 
2942
3232
  // src/update/install.ts
2943
- import { spawn as spawn4, spawnSync as spawnSync2 } from "node:child_process";
3233
+ import { spawn as spawn5, spawnSync as spawnSync2 } from "node:child_process";
2944
3234
  import fs6 from "node:fs";
2945
3235
  import path7 from "node:path";
2946
3236
  var INSTALL_TIMEOUT_MS = 5 * 6e4;
2947
3237
  var STDERR_KEEP = 400;
2948
3238
  function npmPresent() {
2949
- const probe = process.platform === "win32" ? "where" : "which";
3239
+ const probe2 = process.platform === "win32" ? "where" : "which";
2950
3240
  try {
2951
- return spawnSync2(probe, ["npm"], { encoding: "utf8" }).status === 0;
3241
+ return spawnSync2(probe2, ["npm"], { encoding: "utf8" }).status === 0;
2952
3242
  } catch {
2953
3243
  return false;
2954
3244
  }
@@ -2964,7 +3254,7 @@ async function installGlobal(target, cliPath2, options = {}) {
2964
3254
  return new Promise((resolve) => {
2965
3255
  let child;
2966
3256
  try {
2967
- child = spawn4("npm", args, { stdio: ["ignore", "ignore", "pipe"] });
3257
+ child = spawn5("npm", args, { stdio: ["ignore", "ignore", "pipe"] });
2968
3258
  } catch (e) {
2969
3259
  resolve({ ok: false, detail: e instanceof Error ? e.message : String(e) });
2970
3260
  return;
@@ -3198,6 +3488,55 @@ async function startDaemon() {
3198
3488
  },
3199
3489
  listenerError(shipId, "agents")
3200
3490
  ),
3491
+ /**
3492
+ * MCP connections, for the ONE thing only this machine can answer (§15.40): a captain
3493
+ * pressing Test on a connection that runs here.
3494
+ *
3495
+ * A third listener on a small collection — at most 20 documents per Ship — and it earns its
3496
+ * place by being the only route the question has. The cloud cannot reach a program on this
3497
+ * laptop, so without this the Test button on a local connection is a button that does nothing.
3498
+ *
3499
+ * `serversNeedingProbe` deliberately answers "nothing" for a connection nobody asked about,
3500
+ * INCLUDING at startup: a daemon coming up must not spawn every program on the Ship to see
3501
+ * what happens. Testing is somebody's decision, and `doctor` is where it is made on purpose.
3502
+ */
3503
+ onSnapshot2(
3504
+ collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers),
3505
+ (snap) => {
3506
+ const servers = snap.docs.map(
3507
+ (d) => ({ id: d.id, ...d.data() })
3508
+ );
3509
+ for (const server of serversNeedingProbe(servers)) {
3510
+ void (async () => {
3511
+ try {
3512
+ const idToken = await sess(shipId).user.getIdToken();
3513
+ await probeAndReport({ config: config2, idToken, shipId, server, log: log2 });
3514
+ } catch (e) {
3515
+ log2(
3516
+ `MCP test for "${server.id}" (${shipId}) failed to start: ${e instanceof Error ? e.message : String(e)}`
3517
+ );
3518
+ }
3519
+ })();
3520
+ }
3521
+ },
3522
+ /**
3523
+ * ITS OWN ERROR HANDLER, and NOT `listenerError` — this is the important line.
3524
+ *
3525
+ * `listenerError` treats any `permission-denied` as "a captain removed this machine" and
3526
+ * calls `forgetShipLocally`, which stops serving the Ship until the daemon restarts. That
3527
+ * inference is sound for the listeners it was written for; it is badly wrong here.
3528
+ *
3529
+ * `mcp_servers` is gated on `isShipReader`, which a REVOKED machine fails — and revocation
3530
+ * is a state the daemon is expected to sit through and recover from, without a restart and
3531
+ * without re-minting (e2e-runner asserts exactly that: approve → revoke → re-approve, same
3532
+ * process). Reading a denial here as removal would turn a temporary revocation into a
3533
+ * permanent one.
3534
+ *
3535
+ * The blast radius is the whole argument. This listener exists to make a captain's Test
3536
+ * button answer promptly. Losing it costs that button; it must never cost the Ship.
3537
+ */
3538
+ (e) => console.error(`mcp connections listener error (${shipId}):`, e.message)
3539
+ ),
3201
3540
  // Exhausted usage windows. The FIRST snapshot is the startup seed: a restarted daemon —
3202
3541
  // and a second daemon on this Ship — inherits the pause instead of running a doomed
3203
3542
  // session to rediscover it.
@@ -3906,8 +4245,8 @@ async function startDaemon() {
3906
4245
  import * as clack from "@clack/prompts";
3907
4246
  import { createColors } from "picocolors";
3908
4247
  var CliError = class extends Error {
3909
- constructor(message, exitCode = 1) {
3910
- super(message);
4248
+ constructor(message2, exitCode = 1) {
4249
+ super(message2);
3911
4250
  this.exitCode = exitCode;
3912
4251
  this.name = "CliError";
3913
4252
  }
@@ -3952,30 +4291,30 @@ var say = {
3952
4291
  intro(title) {
3953
4292
  if (!jsonMode) clack.intro(pc.bgCyan(pc.black(` ${title} `)));
3954
4293
  },
3955
- outro(message) {
3956
- if (!jsonMode) clack.outro(message);
4294
+ outro(message2) {
4295
+ if (!jsonMode) clack.outro(message2);
3957
4296
  },
3958
- info(message) {
3959
- if (!jsonMode) clack.log.info(message);
4297
+ info(message2) {
4298
+ if (!jsonMode) clack.log.info(message2);
3960
4299
  },
3961
- success(message) {
3962
- if (!jsonMode) clack.log.success(message);
4300
+ success(message2) {
4301
+ if (!jsonMode) clack.log.success(message2);
3963
4302
  },
3964
- warn(message) {
3965
- if (!jsonMode) clack.log.warn(message);
4303
+ warn(message2) {
4304
+ if (!jsonMode) clack.log.warn(message2);
3966
4305
  },
3967
- error(message) {
3968
- if (!jsonMode) clack.log.error(message);
4306
+ error(message2) {
4307
+ if (!jsonMode) clack.log.error(message2);
3969
4308
  },
3970
- step(message) {
3971
- if (!jsonMode) clack.log.step(message);
4309
+ step(message2) {
4310
+ if (!jsonMode) clack.log.step(message2);
3972
4311
  },
3973
4312
  note(body, title) {
3974
4313
  if (!jsonMode) clack.note(body, title);
3975
4314
  },
3976
4315
  /** Raw line straight to stdout — for `logs`, where the content IS the output. */
3977
- line(message) {
3978
- process.stdout.write(`${message}
4316
+ line(message2) {
4317
+ process.stdout.write(`${message2}
3979
4318
  `);
3980
4319
  }
3981
4320
  };
@@ -4041,6 +4380,8 @@ var CHANNEL = "channel";
4041
4380
  var CHANNEL_HELP = "Which npm release channel to follow (default: derived from the version)";
4042
4381
  var CHECK_EVERY = "checkEvery";
4043
4382
  var CHECK_EVERY_HELP = `Minutes between update checks (${CHECK_MINUTES_MIN}-${CHECK_MINUTES_MAX})`;
4383
+ var LOCAL_MCP = "local-mcp";
4384
+ var LOCAL_MCP_HELP = "Allow a Ship to run MCP servers on this machine (needs --ship)";
4044
4385
  var DEFAULT_WORDS = ["default", "auto", "reset"];
4045
4386
  function isToggle(key) {
4046
4387
  return Object.hasOwn(TOGGLES, key);
@@ -4085,7 +4426,8 @@ async function runConfigList() {
4085
4426
  const ships = config2.ships.map((shipId) => ({
4086
4427
  shipId,
4087
4428
  parallel: shipCap(config2, shipId),
4088
- explicit: config2.shipParallelJobs?.[shipId] !== void 0
4429
+ explicit: config2.shipParallelJobs?.[shipId] !== void 0,
4430
+ localMcp: allowsLocalMcp(config2, shipId)
4089
4431
  }));
4090
4432
  const channel = resolveChannel(RUNNER_VERSION, config2, process.env);
4091
4433
  const checkEvery = checkIntervalMs(config2) / 6e4;
@@ -4101,22 +4443,29 @@ async function runConfigList() {
4101
4443
  say.line(` ${PARALLEL.padEnd(16)} ${String(machine).padEnd(2)} ${PARALLEL_HELP}`);
4102
4444
  say.line(` ${CHANNEL.padEnd(16)} ${channel.padEnd(2)} ${CHANNEL_HELP}`);
4103
4445
  say.line(` ${CHECK_EVERY.padEnd(16)} ${String(checkEvery).padEnd(2)} ${CHECK_EVERY_HELP}`);
4446
+ say.line(` ${LOCAL_MCP.padEnd(16)} ${"--".padEnd(2)} ${LOCAL_MCP_HELP}`);
4104
4447
  if (ships.length > 0) {
4105
4448
  say.line("");
4106
4449
  for (const ship2 of ships) {
4107
4450
  const note2 = ship2.explicit ? "" : " (machine default)";
4108
4451
  say.line(` ${`${PARALLEL} --ship`.padEnd(16)} ${ship2.shipId} = ${ship2.parallel}${note2}`);
4109
4452
  }
4453
+ for (const ship2 of ships) {
4454
+ say.line(
4455
+ ` ${`${LOCAL_MCP} --ship`.padEnd(16)} ${ship2.shipId} = ${ship2.localMcp ? "on" : "off"}`
4456
+ );
4457
+ }
4110
4458
  }
4111
4459
  return 0;
4112
4460
  }
4113
4461
  async function runConfigSet(key, value, options = {}) {
4114
4462
  const config2 = requireConfig();
4115
4463
  if (key === PARALLEL) return setParallel(config2, value, options.ship);
4464
+ if (key === LOCAL_MCP) return setLocalMcp(config2, value, options.ship);
4116
4465
  if (key === CHANNEL || key === CHECK_EVERY) return setUpdateSetting(config2, key, value, options.ship);
4117
4466
  if (!isToggle(key)) {
4118
4467
  throw new CliError(
4119
- `Unknown setting "${key}". Known: ${[...Object.keys(TOGGLES), PARALLEL, CHANNEL, CHECK_EVERY].join(", ")}.`
4468
+ `Unknown setting "${key}". Known: ${[...Object.keys(TOGGLES), PARALLEL, LOCAL_MCP, CHANNEL, CHECK_EVERY].join(", ")}.`
4120
4469
  );
4121
4470
  }
4122
4471
  if (options.ship) {
@@ -4161,6 +4510,37 @@ function setUpdateSetting(config2, key, value, ship2) {
4161
4510
  say.line(" Restart the daemon for this to take effect: `lumi-runner service restart`.");
4162
4511
  return 0;
4163
4512
  }
4513
+ function setLocalMcp(config2, value, ship2) {
4514
+ if (!ship2) {
4515
+ throw new CliError(
4516
+ `"${LOCAL_MCP}" is granted one Ship at a time: \`lumi-runner config set ${LOCAL_MCP} ${value} --ship <shipId>\`. There is no machine-wide form \u2014 allowing a Ship to run programs here should be a decision about that Ship. \`lumi-runner ship list\` shows the ids.`
4517
+ );
4518
+ }
4519
+ if (!config2.ships.includes(ship2)) {
4520
+ throw new CliError(
4521
+ `This machine does not serve Ship "${ship2}". Run \`lumi-runner ship list\` to see which Ships it serves, or \`lumi-runner ship add ${ship2}\` to add it.`
4522
+ );
4523
+ }
4524
+ const allowed = parseBool(value);
4525
+ const current = new Set(config2.allowLocalMcp ?? []);
4526
+ if (allowed) current.add(ship2);
4527
+ else current.delete(ship2);
4528
+ if (current.size > 0) config2.allowLocalMcp = [...current].sort();
4529
+ else delete config2.allowLocalMcp;
4530
+ saveConfig(config2);
4531
+ if (isJson()) {
4532
+ emitJson({ localMcp: allowed, shipId: ship2 });
4533
+ return 0;
4534
+ }
4535
+ say.success(`${LOCAL_MCP} = ${allowed ? "on" : "off"} for Ship ${ship2}`);
4536
+ if (allowed) {
4537
+ say.line(
4538
+ " This Ship may now start MCP servers as programs on this machine, and reach servers on its loopback address. Only the connections a captain registered on that Ship, and only the ones activated on the agent running the job."
4539
+ );
4540
+ }
4541
+ say.line(" Restart the daemon for this to take effect: `lumi-runner service restart`.");
4542
+ return 0;
4543
+ }
4164
4544
  function setParallel(config2, value, ship2) {
4165
4545
  const parsed = parseParallel(value);
4166
4546
  if (ship2) {
@@ -4226,8 +4606,8 @@ var fail = (id, label, detail, fix) => ({
4226
4606
  fix
4227
4607
  });
4228
4608
  function onPath(binary) {
4229
- const probe = process.platform === "win32" ? "where" : "which";
4230
- return spawnSync3(probe, [binary], { stdio: "ignore" }).status === 0;
4609
+ const probe2 = process.platform === "win32" ? "where" : "which";
4610
+ return spawnSync3(probe2, [binary], { stdio: "ignore" }).status === 0;
4231
4611
  }
4232
4612
  function checkNode() {
4233
4613
  const major = Number(process.versions.node.split(".")[0]);
@@ -4290,6 +4670,19 @@ function versionCheckFrom(input) {
4290
4670
  autoUpdate ? "Nothing to do \u2014 this machine installs it by itself. `lumi-runner update` does it now." : "Auto-update is off. Run `lumi-runner update`."
4291
4671
  );
4292
4672
  }
4673
+ function localMcpCheckFrom(shipId, servers, allowed) {
4674
+ const local = servers.filter((s) => s.enabled !== false && isLocalMcpServer(s));
4675
+ if (local.length === 0) return null;
4676
+ const id = `localmcp:${shipId}`;
4677
+ const label = `Ship ${shipId} \u2014 local MCP`;
4678
+ const names = local.map((s) => s.id).join(", ");
4679
+ return allowed ? ok(id, label, `Allowed on this machine: ${names}.`) : warn(
4680
+ id,
4681
+ label,
4682
+ `${local.length} connection(s) run on this machine and are not allowed here: ${names}.`,
4683
+ `lumi-runner config set local-mcp on --ship ${shipId}`
4684
+ );
4685
+ }
4293
4686
  async function checkVersion(config2) {
4294
4687
  const channel = resolveChannel(RUNNER_VERSION, config2, process.env);
4295
4688
  const tags = await fetchDistTags();
@@ -4392,6 +4785,17 @@ async function checkShips(config2) {
4392
4785
  )
4393
4786
  );
4394
4787
  }
4788
+ try {
4789
+ const snap = await getDocs5(
4790
+ collection6(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers)
4791
+ );
4792
+ const servers = snap.docs.map(
4793
+ (d) => ({ id: d.id, ...d.data() })
4794
+ );
4795
+ const check = localMcpCheckFrom(shipId, servers, allowsLocalMcp(config2, shipId));
4796
+ if (check) checks.push(check);
4797
+ } catch {
4798
+ }
4395
4799
  }
4396
4800
  return { checks, engines, needsGithub };
4397
4801
  }
@@ -4402,7 +4806,7 @@ async function runDoctor() {
4402
4806
  checks.push(
4403
4807
  fail("config", "Configuration", "This machine is not connected.", "Run `lumi-runner setup`.")
4404
4808
  );
4405
- return report(checks);
4809
+ return report2(checks);
4406
4810
  }
4407
4811
  checks.push(ok("config", "Configuration", `Runner ${config2.runnerId} on project ${config2.projectId}`));
4408
4812
  const progress = spinner2();
@@ -4437,9 +4841,9 @@ async function runDoctor() {
4437
4841
  checks.push(checkService());
4438
4842
  checks.push(await checkVersion(config2));
4439
4843
  progress.stop("Checks complete.");
4440
- return report(checks);
4844
+ return report2(checks);
4441
4845
  }
4442
- function report(checks) {
4846
+ function report2(checks) {
4443
4847
  const failed = checks.filter((c) => c.level === "fail");
4444
4848
  const warned = checks.filter((c) => c.level === "warn");
4445
4849
  if (isJson()) {
@@ -4468,7 +4872,7 @@ function report(checks) {
4468
4872
  }
4469
4873
 
4470
4874
  // src/cli/commands/login.ts
4471
- import { spawn as spawn5 } from "node:child_process";
4875
+ import { spawn as spawn6 } from "node:child_process";
4472
4876
  import os5 from "node:os";
4473
4877
  import { signInWithCustomToken as signInWithCustomToken2 } from "firebase/auth";
4474
4878
  function buildLoginResult(approved, existing, fallbackProjectId, mcpUrl2) {
@@ -4494,7 +4898,7 @@ function buildLoginResult(approved, existing, fallbackProjectId, mcpUrl2) {
4494
4898
  function openBrowser(url) {
4495
4899
  const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
4496
4900
  try {
4497
- const child = spawn5(command, args, { stdio: "ignore", detached: true });
4901
+ const child = spawn6(command, args, { stdio: "ignore", detached: true });
4498
4902
  child.on("error", () => {
4499
4903
  });
4500
4904
  child.unref();
@@ -4571,7 +4975,7 @@ Code: ${pc.bold(start.displayCode)}`,
4571
4975
  const first = approvedShips[0];
4572
4976
  const uid2 = first ? (await signInWithCustomToken2(fb.auth, (await exchange(config2, first)).customToken)).user.uid : "(no Ship approved yet)";
4573
4977
  saveConfig(config2);
4574
- return report2({ runnerId: config2.runnerId, uid: uid2, ships: config2.ships, approvedShips, pendingShips });
4978
+ return report3({ runnerId: config2.runnerId, uid: uid2, ships: config2.ships, approvedShips, pendingShips });
4575
4979
  }
4576
4980
  async function exchange(config2, shipId) {
4577
4981
  return callPublicFunction(
@@ -4607,7 +5011,7 @@ async function loginWithKey(options) {
4607
5011
  const fb = initFirebase(config2);
4608
5012
  const cred = await signInWithCustomToken2(fb.auth, session.customToken);
4609
5013
  saveConfig(config2);
4610
- return report2({
5014
+ return report3({
4611
5015
  runnerId: config2.runnerId,
4612
5016
  uid: cred.user.uid,
4613
5017
  ships: config2.ships,
@@ -4619,7 +5023,7 @@ function parseKeyShipId(key) {
4619
5023
  const parts = (key ?? "").split("_");
4620
5024
  return parts.length === 3 && parts[0] === "crewrunner" && parts[1] && parts[2] ? parts[1] : null;
4621
5025
  }
4622
- function report2(result) {
5026
+ function report3(result) {
4623
5027
  if (isJson()) {
4624
5028
  emitJson({ version: RUNNER_VERSION, ...result });
4625
5029
  return 0;
@@ -4639,12 +5043,12 @@ function report2(result) {
4639
5043
 
4640
5044
  // src/cli/commands/logs.ts
4641
5045
  async function runLogs(options) {
4642
- const tail = readTail(options.lines);
4643
- if (tail.length === 0 && !options.follow) {
5046
+ const tail2 = readTail(options.lines);
5047
+ if (tail2.length === 0 && !options.follow) {
4644
5048
  say.warn(`No log output yet at ${logFile()}.`);
4645
5049
  return 0;
4646
5050
  }
4647
- for (const line of tail) say.line(line);
5051
+ for (const line of tail2) say.line(line);
4648
5052
  if (!options.follow) return 0;
4649
5053
  await new Promise((resolve) => {
4650
5054
  const stop = followLog((line) => say.line(line));
@@ -5112,10 +5516,10 @@ function action(handler) {
5112
5516
  try {
5113
5517
  process.exitCode = await handler(...args);
5114
5518
  } catch (error) {
5115
- const message = error instanceof Error ? error.message : String(error);
5116
- if (isJson()) process.stderr.write(`${JSON.stringify({ error: message })}
5519
+ const message2 = error instanceof Error ? error.message : String(error);
5520
+ if (isJson()) process.stderr.write(`${JSON.stringify({ error: message2 })}
5117
5521
  `);
5118
- else say.error(message);
5522
+ else say.error(message2);
5119
5523
  process.exitCode = error instanceof CliError ? error.exitCode : 1;
5120
5524
  } finally {
5121
5525
  await closeFirebase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumi.ai/runner",
3
- "version": "0.5.12",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Lumi Crew runner daemon — claims jobs from your Ships and executes them as headless Claude sessions on your own machine.",
6
6
  "//name": "The ONLY package in this monorepo published to the public registry, so it is the one that does not follow the internal @lumi/crew-* convention: `@lumi` is not a scope we own, `@lumi.ai` is (the npm org). The workspace DIRECTORY stays packages/crew/runner — renaming the package is not renaming the folder.",