c8ctl-plugin-nano 1.56.2 → 1.56.3

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.
package/README.md CHANGED
@@ -1033,6 +1033,42 @@ it cannot (e.g. `launchctl` is unavailable, or you set `C8CTL_NANO_NO_LAUNCHD=1`
1033
1033
  it prints a prominent **warning** pointing at `supervisor install` instead of
1034
1034
  silently leaving a fleet that will wedge on logout.
1035
1035
 
1036
+ > **macOS Local Network Privacy + a LAN engine (macOS 15 Sequoia / 26 Tahoe).**
1037
+ > The `gui/$UID` LaunchAgent that keeps the fleet alive across SSH logout is a
1038
+ > **distinct TCC identity** — and on macOS 15+ it is **not** granted **Local
1039
+ > Network** access, which an interactive SSH/Terminal session inherits for free.
1040
+ > So a service-owned fleet whose engine is on the **LAN** (`merlin.local`,
1041
+ > `192.168.x.x`, `10.x`, …) hits the **same wedge** as the logout case —
1042
+ > `activateJobs failed: fetch failed`, `reconcile skipped — fetch failed`,
1043
+ > `listening on 0 job type(s)`, agentic `disconnected` — **even while you are
1044
+ > still logged in**. The tell is `EHOSTUNREACH` (or a silent connect failure) to
1045
+ > the engine's **LAN IP** from the service, while the *same* fetch works from an
1046
+ > interactive SSH shell and **internet-reachable** hosts still work from the
1047
+ > service. This is *not* the IPv6/mDNS case above — it fails even against the raw
1048
+ > IPv4 literal, because the OS is blocking LAN egress for the launchd identity.
1049
+ >
1050
+ > There is **no `tccutil` / CLI grant** for a headless launchd tool. Pick one:
1051
+ > - **Grant the launch program Local Network access (simplest).** On the Mac's
1052
+ > GUI, **System Settings → Privacy & Security → Local Network**, enable the
1053
+ > entry for the service's program — the **Node.js** runtime (it may surface as
1054
+ > *“Node.js Foundation”* / *“App Background Activity”*). Then
1055
+ > `c8ctl nano supervisor stop && c8ctl nano supervisor start`. The grant is
1056
+ > keyed to the node binary, so it persists across restarts.
1057
+ > - **Route over Tailscale (keeps logout survival, no LAN grant needed).** Traffic
1058
+ > over the Tailscale `utun` interface is **not** classified as “local network”,
1059
+ > so the launchd service reaches it fine. Point the engine at the tailnet
1060
+ > address (e.g. `NANO_REST_URL=http://<host>.<tailnet>.ts.net:8080`, or bake it
1061
+ > into the active profile so the service inherits it).
1062
+ > - **Run in the SSH session instead** (`supervisor uninstall`) — inherits the
1063
+ > Terminal grant, but reverts to dying on logout unless you pin a `tmux`/SSH
1064
+ > session. Note that on macOS `supervisor start` over SSH **auto-reparents**
1065
+ > back into the `gui/$UID` launchd identity (reinstalling the LaunchAgent)
1066
+ > unless you set `C8CTL_NANO_NO_LAUNCHD=1` — without that opt-out you land in
1067
+ > the same blocked identity. Start from a **local Terminal** session (or export
1068
+ > `C8CTL_NANO_NO_LAUNCHD=1`) to keep the interactive grant.
1069
+ > - **Run the daemon as root** — root retains LAN access, but running agent
1070
+ > harnesses as root is a poor trade; prefer the options above.
1071
+
1036
1072
  ## Composing a workforce: `workforce`
1037
1073
 
1038
1074
  `supervisor` is imperative — you compose a fleet with a `start --worker …` plus a
package/c8ctl-plugin.js CHANGED
@@ -6896,6 +6896,101 @@ function withIpv4FirstNodeOptions(env) {
6896
6896
  return { ...base, NODE_OPTIONS: ipv4FirstNodeOptions(base.NODE_OPTIONS) };
6897
6897
  }
6898
6898
 
6899
+ // The reverse-DNS label prefix of the session-independent supervisor LaunchAgent
6900
+ // (macOS). A daemon/worker spawned by that service inherits `XPC_SERVICE_NAME`
6901
+ // set to `<prefix><stateHomeHash>`, so its presence is a reliable "am I running
6902
+ // under the launchd `gui/$UID` service (not an interactive SSH/Terminal
6903
+ // session)?" signal — the axis that decides whether macOS Local Network Privacy
6904
+ // applies. `supervisorServiceLabel()` composes the full label from this prefix.
6905
+ const SUPERVISOR_SERVICE_LABEL_PREFIX = 'io.nanobpm.c8ctl-nano.supervisor.';
6906
+
6907
+ /**
6908
+ * True when THIS process is running under the supervisor's launchd LaunchAgent
6909
+ * (the `gui/$UID` service, macOS). Keyed off the inherited `XPC_SERVICE_NAME`.
6910
+ * @param {Record<string,string|undefined>} [env]
6911
+ */
6912
+ function isUnderLaunchdSupervisorService(env = process.env) {
6913
+ const xpc = env?.XPC_SERVICE_NAME;
6914
+ return typeof xpc === 'string' && xpc.startsWith(SUPERVISOR_SERVICE_LABEL_PREFIX);
6915
+ }
6916
+
6917
+ /**
6918
+ * True when `address` is a private (RFC1918) or link-local IPv4 — i.e. a *local
6919
+ * network* peer, the class macOS 15+ Local Network Privacy gates. Tailscale's
6920
+ * CGNAT range (100.64.0.0/10) is deliberately EXCLUDED: it rides the `utun`
6921
+ * tunnel, which the OS does not treat as "local network", so it is not blocked
6922
+ * and must not trip the heuristic. Loopback and public addresses are excluded.
6923
+ * @param {unknown} address
6924
+ */
6925
+ function isPrivateLanIPv4(address) {
6926
+ if (typeof address !== 'string') return false;
6927
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(address);
6928
+ if (!m) return false;
6929
+ const a = Number(m[1]);
6930
+ const b = Number(m[2]);
6931
+ if (a > 255 || b > 255 || Number(m[3]) > 255 || Number(m[4]) > 255) return false;
6932
+ if (a === 10) return true; // 10.0.0.0/8
6933
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
6934
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16
6935
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
6936
+ return false;
6937
+ }
6938
+
6939
+ // Walk an error and its `.cause` chain (undici wraps the OS-level `code`/`address`
6940
+ // under `.cause`), returning the first defined value of `field`. Bounded depth so
6941
+ // a self-referential cause can't loop.
6942
+ function errorChainField(error, field, maxDepth = 6) {
6943
+ let e = error;
6944
+ for (let i = 0; i < maxDepth && e && typeof e === 'object'; i++) {
6945
+ if (e[field] !== undefined && e[field] !== null) return e[field];
6946
+ e = e.cause;
6947
+ }
6948
+ return undefined;
6949
+ }
6950
+
6951
+ /**
6952
+ * Heuristic: does this engine-read / activation failure look like a macOS Local
6953
+ * Network Privacy (TCC) block rather than an ordinary connectivity error?
6954
+ *
6955
+ * On macOS 15 Sequoia / 26 Tahoe the supervisor's `gui/$UID` LaunchAgent is a
6956
+ * distinct TCC identity that is NOT granted Local Network access, so every
6957
+ * connection to a LAN peer fails `EHOSTUNREACH` (even the raw IPv4 literal) while
6958
+ * internet hosts still work — the fleet wedges with `fetch failed` / `0` job
6959
+ * types even though an interactive SSH shell reaches the same engine fine. The
6960
+ * signature that distinguishes it from the IPv6/mDNS case (#139/#151 — which
6961
+ * fails against a dead `fe80::…` but succeeds on IPv4) is: running under the
6962
+ * launchd service, `EHOSTUNREACH`, to a *private IPv4* address.
6963
+ *
6964
+ * Injection seams (`platform`, `env`) keep it unit-testable without a Mac.
6965
+ * @param {{ error?: unknown, platform?: string, env?: Record<string,string|undefined> }} [opts]
6966
+ */
6967
+ function isLikelyLocalNetworkTccBlock({ error, platform = osPlatform(), env = process.env } = {}) {
6968
+ if (platform !== 'darwin') return false;
6969
+ if (!isUnderLaunchdSupervisorService(env)) return false;
6970
+ if (errorChainField(error, 'code') !== 'EHOSTUNREACH') return false;
6971
+ return isPrivateLanIPv4(errorChainField(error, 'address'));
6972
+ }
6973
+
6974
+ /** Actionable multi-line hint shown when {@link isLikelyLocalNetworkTccBlock}. */
6975
+ function localNetworkTccHint() {
6976
+ return [
6977
+ '↳ This looks like macOS Local Network Privacy blocking the supervisor service.',
6978
+ ' The gui/$UID LaunchAgent has no Local Network grant, so it cannot reach a LAN',
6979
+ ' engine (an interactive SSH/Terminal session can). Common fixes (see the',
6980
+ ' README "macOS Local Network Privacy" section for the full list, including',
6981
+ ' running the daemon as root):',
6982
+ ' • System Settings → Privacy & Security → Local Network → enable Node.js',
6983
+ ' (may show as "Node.js Foundation" / "App Background Activity"), then',
6984
+ ' `c8ctl nano supervisor stop && c8ctl nano supervisor start`.',
6985
+ ' • Route over Tailscale (utun is exempt): point the engine at the tailnet',
6986
+ ' address, e.g. NANO_REST_URL=http://<host>.<tailnet>.ts.net:8080',
6987
+ ' • Or run in the SSH session (`c8ctl nano supervisor uninstall`) and pin a',
6988
+ ' tmux/SSH session so the fleet does not die on logout. Note: over SSH,',
6989
+ ' `supervisor start` auto-reparents back under launchd (same block) unless',
6990
+ ' you set C8CTL_NANO_NO_LAUNCHD=1 or start from a local Terminal.',
6991
+ ].join('\n');
6992
+ }
6993
+
6899
6994
  /**
6900
6995
  * work — turn a hire profile into live Nano job workers (one per job-type in
6901
6996
  * the rank×capability matrix) and poll for work in the foreground until Ctrl-C.
@@ -7169,6 +7264,7 @@ async function workAgent(req, flags) {
7169
7264
  jobTypes = [...new Set([...autoTypes, ...extraJobTypes])];
7170
7265
  } catch (err) {
7171
7266
  logger.warn(`--auto: initial engine read failed (${err?.message || err}); starting with no auto pollers — will retry on the next poll.`);
7267
+ if (isLikelyLocalNetworkTccBlock({ error: err })) logger.warn(localNetworkTccHint());
7172
7268
  jobTypes = [...new Set(extraJobTypes)];
7173
7269
  }
7174
7270
  } else {
@@ -9773,6 +9869,16 @@ async function attachSupervisorConsole(state) {
9773
9869
  // into `gui/$UID` (macOS) or a `systemd --user` unit with lingering (Linux),
9774
9870
  // and `supervisor start` over SSH on macOS auto-reparents into that domain (or,
9775
9871
  // when it cannot, WARNS that the fleet will die on logout).
9872
+ //
9873
+ // CAVEAT (macOS 15 Sequoia / 26 Tahoe — Local Network Privacy): the `gui/$UID`
9874
+ // LaunchAgent is a distinct TCC identity that is NOT granted Local Network
9875
+ // access (an interactive SSH/Terminal session inherits it for free). So a
9876
+ // service-owned fleet whose engine is on the LAN (`merlin.local`, `192.168.x.x`)
9877
+ // hits the SAME wedge — `fetch failed` / `EHOSTUNREACH` to the LAN IP, `0` job
9878
+ // types — even while logged in, while internet hosts still work. There is no
9879
+ // `tccutil`/CLI grant for a headless tool: grant the node runtime Local Network
9880
+ // access in System Settings, route over Tailscale (utun is exempt), or run in
9881
+ // the SSH session / as root. See README "Surviving SSH logout" for the full note.
9776
9882
  // ---------------------------------------------------------------------------
9777
9883
 
9778
9884
  /** True when the current process is running inside an SSH login session. */
@@ -9799,7 +9905,7 @@ function supervisorServiceHash() {
9799
9905
 
9800
9906
  /** Reverse-DNS LaunchAgent label for this state home. */
9801
9907
  function supervisorServiceLabel() {
9802
- return `io.nanobpm.c8ctl-nano.supervisor.${supervisorServiceHash()}`;
9908
+ return `${SUPERVISOR_SERVICE_LABEL_PREFIX}${supervisorServiceHash()}`;
9803
9909
  }
9804
9910
 
9805
9911
  /** Per-user LaunchAgent plist path (macOS). */
@@ -12971,6 +13077,11 @@ export {
12971
13077
  createSupervisorDeps,
12972
13078
  enableEngineHappyEyeballs,
12973
13079
  preferIpv4Resolution,
13080
+ isLikelyLocalNetworkTccBlock,
13081
+ isUnderLaunchdSupervisorService,
13082
+ isPrivateLanIPv4,
13083
+ localNetworkTccHint,
13084
+ SUPERVISOR_SERVICE_LABEL_PREFIX,
12974
13085
  ipv4FirstNodeOptions,
12975
13086
  withIpv4FirstNodeOptions,
12976
13087
  DNS_RESULT_ORDER_IPV4_FIRST,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.56.2",
3
+ "version": "1.56.3",
4
4
  "type": "module",
5
5
  "description": "c8ctl plugin to start, inspect, and stop a local Nano BPM (nanobpmn) cluster",
6
6
  "main": "c8ctl-plugin.js",
@@ -57,7 +57,7 @@
57
57
  "devDependencies": {
58
58
  "@commitlint/cli": "^20.4.1",
59
59
  "@commitlint/config-conventional": "^20.4.1",
60
- "@nanobpm/engine-wasm": "^0.8.6",
60
+ "@nanobpm/engine-wasm": "^0.9.0",
61
61
  "@semantic-release/exec": "^7.1.0",
62
62
  "@semantic-release/github": "^12.0.6",
63
63
  "@types/node": "^22.20.1",
@@ -72,12 +72,12 @@
72
72
  },
73
73
  "optionalDependencies": {
74
74
  "node-pty": "^1.0.0",
75
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.56.2",
76
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.56.2",
77
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.56.2",
78
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.56.2",
79
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.56.2",
80
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.56.2",
81
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.56.2"
75
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.56.3",
76
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.56.3",
77
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.56.3",
78
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.56.3",
79
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.56.3",
80
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.56.3",
81
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.56.3"
82
82
  }
83
83
  }