local-operator-ui 0.15.1 → 0.15.2

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 (56) hide show
  1. package/bin/linux-sandbox.js +471 -0
  2. package/bin/local-operator-ui.js +91 -3
  3. package/bin/postinstall.js +120 -0
  4. package/out/renderer/assets/{_basePickBy-CfllbLPT.js → _basePickBy-CefSvQ3Q.js} +1 -1
  5. package/out/renderer/assets/{_baseUniq-t3IzOokH.js → _baseUniq-DlDqTbuA.js} +1 -1
  6. package/out/renderer/assets/{agent-details-page-BU1kYKxK.js → agent-details-page-BCuUg55n.js} +1 -1
  7. package/out/renderer/assets/{agent-hub-page-DrFYnBSt.js → agent-hub-page-BqCCuxo-.js} +1 -1
  8. package/out/renderer/assets/{agents-page-cq-zLmOE.js → agents-page-DG4vCZu3.js} +1 -1
  9. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-DZdNjiiw.js → architectureDiagram-IEHRJDOE-CUlo4ACF.js} +1 -1
  10. package/out/renderer/assets/{blockDiagram-JOT3LUYC-DfSTYCE8.js → blockDiagram-JOT3LUYC-nU3IOM88.js} +1 -1
  11. package/out/renderer/assets/{c4Diagram-VJAJSXHY-kj8ONcdJ.js → c4Diagram-VJAJSXHY-MT0nxa4-.js} +1 -1
  12. package/out/renderer/assets/channel-CCadi6VR.js +1 -0
  13. package/out/renderer/assets/{chunk-4BMEZGHF-DTEIsOQ8.js → chunk-4BMEZGHF-CXkPsNEi.js} +1 -1
  14. package/out/renderer/assets/{chunk-A2AXSNBT-H5FcAjIP.js → chunk-A2AXSNBT-DiTT6jkA.js} +1 -1
  15. package/out/renderer/assets/{chunk-AEK57VVT-Ckh1RshY.js → chunk-AEK57VVT-CIZ7-t01.js} +1 -1
  16. package/out/renderer/assets/{chunk-D6G4REZN-BI15Ho6z.js → chunk-D6G4REZN-BIKuFzEX.js} +1 -1
  17. package/out/renderer/assets/{chunk-RZ5BOZE2-B4XztrFv.js → chunk-RZ5BOZE2-byZRPh67.js} +1 -1
  18. package/out/renderer/assets/{chunk-XZIHB7SX-RYtDnxT5.js → chunk-XZIHB7SX-BM-t-ERh.js} +1 -1
  19. package/out/renderer/assets/{classDiagram-GIVACNV2-BZfvXxWD.js → classDiagram-GIVACNV2-Bkvtrmw4.js} +1 -1
  20. package/out/renderer/assets/{classDiagram-v2-COTLJTTW-BZfvXxWD.js → classDiagram-v2-COTLJTTW-Bkvtrmw4.js} +1 -1
  21. package/out/renderer/assets/clone-DTDvO4Il.js +1 -0
  22. package/out/renderer/assets/{dagre-OKDRZEBW-CVqq_Qpq.js → dagre-OKDRZEBW-DU9huU8P.js} +1 -1
  23. package/out/renderer/assets/{diagram-SSKATNLV-7I1hPrEB.js → diagram-SSKATNLV-C6KmXMQc.js} +1 -1
  24. package/out/renderer/assets/{diagram-VNBRO52H-B4In8mRQ.js → diagram-VNBRO52H-B2c-8R3S.js} +1 -1
  25. package/out/renderer/assets/{erDiagram-Q7BY3M3F-D8m7xWyt.js → erDiagram-Q7BY3M3F-D6QA70K4.js} +1 -1
  26. package/out/renderer/assets/{flowDiagram-4HSFHLVR-HzyvxZJ_.js → flowDiagram-4HSFHLVR-Dk0lRNK2.js} +1 -1
  27. package/out/renderer/assets/{ganttDiagram-APWFNJXF-D9VGOeKz.js → ganttDiagram-APWFNJXF-BevHed3v.js} +1 -1
  28. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-bCjN7hkh.js → gitGraphDiagram-7IBYFJ6S-CmOfsj5i.js} +1 -1
  29. package/out/renderer/assets/{graph-Bm4dxGQt.js → graph-CUJVKfm_.js} +1 -1
  30. package/out/renderer/assets/{index-C_toWewb.js → index-8mt-yi7u.js} +1 -1
  31. package/out/renderer/assets/{index-fNnd2qrg.js → index-DZnGa8Vt.js} +265 -265
  32. package/out/renderer/assets/{index-B1ky8xLm.js → index-Eh3n7DJx.js} +1 -1
  33. package/out/renderer/assets/{infoDiagram-PH2N3AL5-B2JkP-Hd.js → infoDiagram-PH2N3AL5-DWdIO3pN.js} +1 -1
  34. package/out/renderer/assets/{journeyDiagram-U35MCT3I-NnbVNsik.js → journeyDiagram-U35MCT3I-BNOCDq91.js} +1 -1
  35. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-DBgR7W50.js → kanban-definition-NDS4AKOZ-CIVzZzmM.js} +1 -1
  36. package/out/renderer/assets/{layout-CE8ShubG.js → layout-zNSVRDKv.js} +1 -1
  37. package/out/renderer/assets/{mermaid.core-CExbFl-7.js → mermaid.core-CFY9BM3R.js} +5 -5
  38. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-ag_HQs7G.js → mindmap-definition-ALO5MXBD-yS5L1WQ2.js} +1 -1
  39. package/out/renderer/assets/{parseISO-LV1Yh_aZ.js → parseISO-DIyaQXAe.js} +1 -1
  40. package/out/renderer/assets/{pieDiagram-IB7DONF6-DJ-5Rt4W.js → pieDiagram-IB7DONF6-BPWYAs8_.js} +1 -1
  41. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-BQceCClO.js → quadrantDiagram-7GDLP6J5-B82PFOoq.js} +1 -1
  42. package/out/renderer/assets/{radar-MK3ICKWK-D2qI0wBa.js → radar-MK3ICKWK-Z4U7o29x.js} +1 -1
  43. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-Bd6T_ndu.js → requirementDiagram-KVF5MWMF-BwhazqTR.js} +1 -1
  44. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-CnMYNt__.js → sankeyDiagram-QLVOVGJD-Cg4M6UvE.js} +1 -1
  45. package/out/renderer/assets/{schedules-page-DqrcIxzB.js → schedules-page-ChiypWrp.js} +1 -1
  46. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-SiFK0EWD.js → sequenceDiagram-X6HHIX6F-hTniLZHQ.js} +1 -1
  47. package/out/renderer/assets/{settings-page-D_JWK3qq.js → settings-page-CKzoAptg.js} +1 -1
  48. package/out/renderer/assets/{stateDiagram-DGXRK772-BrX4XGdu.js → stateDiagram-DGXRK772-DI0pBdbL.js} +1 -1
  49. package/out/renderer/assets/{stateDiagram-v2-YXO3MK2T-CEzipxL2.js → stateDiagram-v2-YXO3MK2T-CG5hsBHO.js} +1 -1
  50. package/out/renderer/assets/{timeline-definition-BDJGKUSR-CtwXxKAC.js → timeline-definition-BDJGKUSR-t0KbQhGu.js} +1 -1
  51. package/out/renderer/assets/{use-agent-like-mutation-f9aYefiU.js → use-agent-like-mutation-C_nJtNuz.js} +1 -1
  52. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-BUx2vked.js → xychartDiagram-VJFVF3MP-BQjVqv5u.js} +1 -1
  53. package/out/renderer/index.html +1 -1
  54. package/package.json +6 -5
  55. package/out/renderer/assets/channel-Dy-Y0W96.js +0 -1
  56. package/out/renderer/assets/clone-Dd3fQGFQ.js +0 -1
@@ -0,0 +1,471 @@
1
+ /**
2
+ * Linux Chromium-sandbox diagnostics for the npm/global install path.
3
+ *
4
+ * WHY THIS FILE EXISTS (issue #91)
5
+ *
6
+ * Electron ships a setuid helper, `chrome-sandbox`, next to its binary. npm
7
+ * strips setuid bits from package contents by design and Electron's own
8
+ * postinstall does not restore them, so after `npm install -g` the helper lands
9
+ * `root:root 0755` and Chromium aborts before any window opens:
10
+ *
11
+ * [FATAL:setuid_sandbox_host.cc(163)] The SUID sandbox helper binary was
12
+ * found, but is not configured correctly.
13
+ *
14
+ * That message names the file but not the commands, it arrives after a crash
15
+ * rather than instead of one, and the launcher used to exit 0 on it (see
16
+ * exitCodeFor below), so callers were told the app had started cleanly.
17
+ *
18
+ * WHAT IS DELIBERATELY *NOT* HERE: `--no-sandbox` / ELECTRON_DISABLE_SANDBOX are
19
+ * never passed by us. Turning the Chromium sandbox off for every Linux user is a
20
+ * security-posture decision that belongs to the user, so it is offered in the
21
+ * guidance below as their explicit opt-out with the consequence stated, and
22
+ * never applied on their behalf.
23
+ *
24
+ * TWO CASES, AND WHY THEY ARE HANDLED DIFFERENTLY
25
+ *
26
+ * Both were measured in node:22-bookworm rather than reasoned about, and the
27
+ * measurements are what shaped the code:
28
+ *
29
+ * 1. Running as root ALWAYS aborts, whatever the helper's mode. Verified: with
30
+ * the helper corrected to `root:root 4755`, root still gets
31
+ * "[FATAL:electron_main_delegate.cc(288)] Running as root without
32
+ * --no-sandbox is not supported". Because the outcome depends only on the
33
+ * effective uid, this is decidable BEFORE spawning, so it is a true
34
+ * preflight: we stop and explain instead of letting Chromium abort.
35
+ *
36
+ * 2. A missing setuid bit does NOT reliably mean failure. Chromium falls back to
37
+ * the unprivileged user-namespace sandbox, and where the kernel permits that
38
+ * the app starts normally with the helper at 0755. Verified: the same
39
+ * container that aborts under Docker's default seccomp profile reaches
40
+ * LOCAL_OPERATOR_UI_READY with `--security-opt seccomp=unconfined`, helper
41
+ * unchanged at `root:root 755`. So a preflight keyed on "the setuid bit is
42
+ * missing" would refuse to start installs that work today.
43
+ *
44
+ * Detecting user-namespace availability up front is not dependable either:
45
+ * /proc/sys/user/max_user_namespaces read 31322 in BOTH containers, so the
46
+ * /proc indicators cannot see a seccomp filter that blocks the unshare(2)
47
+ * call itself. Probing by spawning would mean guessing about a syscall we
48
+ * cannot make from Node.
49
+ *
50
+ * So case 2 is handled by REACTING rather than predicting: if the app exits
51
+ * abnormally and the helper is in the state known to cause it, we translate
52
+ * that into the exact commands. This cannot produce a false positive on a
53
+ * working install, because it only runs after a launch has already failed.
54
+ */
55
+
56
+ const fs = require("node:fs");
57
+ const path = require("node:path");
58
+ const { spawnSync } = require("node:child_process");
59
+
60
+ // Chromium's own required mode for the helper. `chown root:root` + `chmod 4755`
61
+ // is what the FATAL message asks for, and what the issue verified as a fix.
62
+ const REQUIRED_MODE = 0o4755;
63
+ const SETUID_BIT = 0o4000;
64
+
65
+ /**
66
+ * Every stat and every mutation in this file goes through a descriptor opened
67
+ * with O_NOFOLLOW, and this is the single most security-sensitive decision here.
68
+ *
69
+ * WHY: the repair runs as root during `sudo npm install -g`, and `chownSync`,
70
+ * `chmodSync` and `statSync` all FOLLOW symlinks (there is no lchmod on Linux).
71
+ * If the helper path is a symlink, a path-based repair applies `root:root 4755`
72
+ * to the link's TARGET -- so a crafted dependency that drops a link to, say,
73
+ * /usr/bin/env turns this postinstall into a root setuid primitive it can aim.
74
+ * The path itself is safe (it is built with path.join from __dirname, never from
75
+ * package metadata), but the CONTENT of node_modules is not a trust boundary
76
+ * during an install: dependency postinstalls run alongside ours.
77
+ *
78
+ * Opening once and operating on the fd closes the TOCTOU window in the same
79
+ * move: without it we stat a path and then chown that path, and the file can be
80
+ * swapped in between. Holding the descriptor means the thing we inspected is
81
+ * provably the thing we modify.
82
+ *
83
+ * WHY O_NONBLOCK IS PART OF THIS, and it is not decoration: `open(2)` on a FIFO
84
+ * with O_RDONLY BLOCKS UNTIL A WRITER APPEARS. That is plain POSIX open
85
+ * semantics and has nothing to do with O_NOFOLLOW, which refuses only symlinks.
86
+ * So a dependency that drops a FIFO at the helper path -- a file it can create
87
+ * as easily as a symlink -- makes `sudo npm install -g` hang FOREVER, with no
88
+ * timeout and no failure, and the path is reachable: ensureElectronDist()
89
+ * fast-paths on existsSync(chrome-sandbox), which a FIFO satisfies. Measured:
90
+ * both entry points had to be SIGKILLed after 8s without this flag and return in
91
+ * ~1ms with it. O_NONBLOCK is a no-op on the regular file we actually expect, so
92
+ * it costs nothing on the healthy path; it only turns "wait indefinitely" into
93
+ * "open it and let the fstat below refuse it as not a regular file".
94
+ *
95
+ * KNOWN LIMIT, stated rather than implied: O_NOFOLLOW only refuses a symlink as
96
+ * the FINAL path component. An attacker who can replace an intermediate
97
+ * directory inside our own node_modules/electron/dist with a link is not
98
+ * defeated by this. That is accepted -- defending it needs a resolved-directory
99
+ * walk (openat/O_DIRECTORY per component), which Node does not expose -- and the
100
+ * final component is the one an npm-installed package can actually place.
101
+ */
102
+ const OPEN_NOFOLLOW =
103
+ fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK;
104
+
105
+ // A refused O_NOFOLLOW open reports ELOOP on Linux and macOS; BSDs use EMLINK.
106
+ // Both mean the same thing here: the final component is a symlink.
107
+ const isSymlinkRefusal = (err) => err.code === "ELOOP" || err.code === "EMLINK";
108
+
109
+ /**
110
+ * Chromium's acceptance rule for the helper, as a pure predicate over the two
111
+ * stat fields it depends on.
112
+ *
113
+ * Extracted rather than inlined because it is the whole correctness question in
114
+ * this file and both halves matter: root ownership WITHOUT the setuid bit is the
115
+ * exact state npm leaves behind, and the setuid bit on a file owned by anyone
116
+ * else confers nothing. Testing it through a real file cannot cover the
117
+ * root-owned cases without being root, so keeping it separately callable is what
118
+ * lets the shipped rule be asserted directly instead of restated in a test.
119
+ */
120
+ const isHelperHealthy = (uid, mode) => uid === 0 && (mode & SETUID_BIT) !== 0;
121
+
122
+ /**
123
+ * The helper sits beside the Electron binary in the same dist directory.
124
+ * Returns null when it cannot be located, which is the normal case on macOS and
125
+ * Windows and on any install where the optional Electron download was skipped.
126
+ */
127
+ const resolveSandboxHelper = (electronBinaryPath) => {
128
+ if (typeof electronBinaryPath !== "string" || electronBinaryPath === "") {
129
+ return null;
130
+ }
131
+ return path.join(path.dirname(electronBinaryPath), "chrome-sandbox");
132
+ };
133
+
134
+ /**
135
+ * Read the helper's ownership and permissions from a descriptor, never by path.
136
+ *
137
+ * `needsRepair` is true only when the file exists and is not already
138
+ * root-owned-setuid, so a helper that is absent (not a Linux install, optional
139
+ * dependency skipped) is never reported as broken. Any stat failure degrades to
140
+ * "nothing to say" rather than throwing: this is a diagnostic, and a diagnostic
141
+ * must never be the reason the app fails to start.
142
+ *
143
+ * `unsafe` is the third state, distinct from both: the path exists but is a
144
+ * symlink or is not a regular file. It reports `needsRepair: false` because the
145
+ * repair must refuse it (see OPEN_NOFOLLOW) and because the chown/chmod guidance
146
+ * would send the user to "fix" permissions on someone else's file.
147
+ */
148
+ const inspectSandboxHelper = (helperPath) => {
149
+ if (!helperPath) {
150
+ return { exists: false, needsRepair: false };
151
+ }
152
+ let fd;
153
+ try {
154
+ fd = fs.openSync(helperPath, OPEN_NOFOLLOW);
155
+ } catch (err) {
156
+ if (isSymlinkRefusal(err)) {
157
+ return {
158
+ exists: true,
159
+ path: helperPath,
160
+ unsafe: true,
161
+ needsRepair: false,
162
+ };
163
+ }
164
+ return { exists: false, needsRepair: false };
165
+ }
166
+ try {
167
+ const stats = fs.fstatSync(fd);
168
+ if (!stats.isFile()) {
169
+ // A fifo or device in the helper's place is not something to chmod 4755.
170
+ return {
171
+ exists: true,
172
+ path: helperPath,
173
+ unsafe: true,
174
+ needsRepair: false,
175
+ };
176
+ }
177
+ const mode = stats.mode & 0o7777;
178
+ const isSetuidRoot = isHelperHealthy(stats.uid, mode);
179
+ return {
180
+ exists: true,
181
+ path: helperPath,
182
+ uid: stats.uid,
183
+ gid: stats.gid,
184
+ mode,
185
+ isSetuidRoot,
186
+ needsRepair: !isSetuidRoot,
187
+ };
188
+ } catch {
189
+ return { exists: false, needsRepair: false };
190
+ } finally {
191
+ fs.closeSync(fd);
192
+ }
193
+ };
194
+
195
+ /**
196
+ * Restore `root:root 4755` on the helper. Used by the postinstall step, where we
197
+ * may already be root because the user ran `sudo npm install -g`.
198
+ *
199
+ * Returns a result object and NEVER throws or exits non-zero. An unprivileged
200
+ * install legitimately cannot do this, and a postinstall that fails the install
201
+ * is a worse defect than the bug it is fixing: the user would end up with no
202
+ * package at all instead of one that needs a documented chmod. When we cannot
203
+ * repair it, the launcher's post-mortem guidance covers the case.
204
+ */
205
+ const repairSandboxHelper = (helperPath) => {
206
+ if (!helperPath) {
207
+ return { outcome: "absent" };
208
+ }
209
+ let fd;
210
+ try {
211
+ fd = fs.openSync(helperPath, OPEN_NOFOLLOW);
212
+ } catch (err) {
213
+ // Refusing a symlink is a REFUSAL, not a failure to find the file: the
214
+ // caller must be able to tell "nothing to repair" from "something is in the
215
+ // way that we will not chmod as root".
216
+ if (isSymlinkRefusal(err)) {
217
+ return { outcome: "unsafe", path: helperPath };
218
+ }
219
+ return { outcome: "absent" };
220
+ }
221
+ try {
222
+ // Everything below reads and mutates THIS descriptor. Re-deriving state from
223
+ // the path here would reopen the TOCTOU window the fd exists to close.
224
+ const before = fs.fstatSync(fd);
225
+ if (!before.isFile()) {
226
+ return { outcome: "unsafe", path: helperPath };
227
+ }
228
+ if (isHelperHealthy(before.uid, before.mode & 0o7777)) {
229
+ return { outcome: "already-correct", path: helperPath };
230
+ }
231
+ try {
232
+ // chown first: chmod's setuid bit is cleared by a subsequent chown, so the
233
+ // reverse order silently produces a 0755 file and a "success" report.
234
+ fs.fchownSync(fd, 0, 0);
235
+ fs.fchmodSync(fd, REQUIRED_MODE);
236
+ } catch (err) {
237
+ return { outcome: "not-permitted", path: helperPath, error: err };
238
+ }
239
+ // Re-stat rather than trusting the syscalls returned without throwing. This
240
+ // is the check that catches the ordering inversion above in production: a
241
+ // chmod-then-chown pair succeeds and still leaves 0755.
242
+ const after = fs.fstatSync(fd);
243
+ return isHelperHealthy(after.uid, after.mode & 0o7777)
244
+ ? { outcome: "repaired", path: helperPath }
245
+ : { outcome: "not-permitted", path: helperPath };
246
+ } catch (err) {
247
+ return { outcome: "not-permitted", path: helperPath, error: err };
248
+ } finally {
249
+ fs.closeSync(fd);
250
+ }
251
+ };
252
+
253
+ const OPT_OUT_NOTE = (command) => [
254
+ "",
255
+ "If you would rather run without the Chromium sandbox, that is your call to",
256
+ "make: it starts the app but removes a significant security boundary around",
257
+ "the browser engine, so it is not something we enable for you.",
258
+ "",
259
+ ` ELECTRON_DISABLE_SANDBOX=1 ${command}`,
260
+ ];
261
+
262
+ /**
263
+ * Guidance for the root case, decided before Electron is spawned.
264
+ */
265
+ const rootGuidance = (command) => [
266
+ "Local Operator UI cannot start as root with the Chromium sandbox enabled.",
267
+ "",
268
+ "Electron refuses to run as root and aborts with",
269
+ '"Running as root without --no-sandbox is not supported". This is not caused',
270
+ "by the sandbox helper's permissions and is not fixed by changing them.",
271
+ "",
272
+ "Run the app as your normal desktop user instead. If you installed it with",
273
+ "sudo, only the install needed root:",
274
+ "",
275
+ " sudo npm install -g local-operator-ui # install as root",
276
+ ` ${command} # run as yourself`,
277
+ ...OPT_OUT_NOTE(command),
278
+ ];
279
+
280
+ /**
281
+ * Guidance for a launch that already failed with the helper unrepaired.
282
+ *
283
+ * The path is the real resolved one rather than a placeholder, because the whole
284
+ * complaint in issue #91 is that the user is told what is wrong without being
285
+ * told what to type.
286
+ */
287
+ // POSIX single-quote escaping: end the quoted run, emit a literal quote, start
288
+ // a new one. Copy-paste guidance whose whole point is being pasteable must
289
+ // survive an install path like /home/o'brien/.npm-global.
290
+ const shellQuote = (value) => `'${String(value).replace(/'/g, "'\\''")}'`;
291
+
292
+ const sandboxHelperGuidance = (state, command) => {
293
+ const quoted = shellQuote(state.path);
294
+ const current = `${state.uid}:${state.gid} ${state.mode.toString(8).padStart(4, "0")}`;
295
+ return [
296
+ "Local Operator UI exited before it could start, and its Chromium sandbox",
297
+ "helper is not configured the way Electron requires:",
298
+ "",
299
+ ` ${state.path}`,
300
+ ` currently ${current}`,
301
+ " required 0:0 4755",
302
+ "",
303
+ "npm removes setuid bits from package contents, so a global install leaves",
304
+ "this helper unprivileged. Restore it with:",
305
+ "",
306
+ ` sudo chown root:root ${quoted}`,
307
+ ` sudo chmod 4755 ${quoted}`,
308
+ "",
309
+ `Then run ${command} again.`,
310
+ ...OPT_OUT_NOTE(command),
311
+ ];
312
+ };
313
+
314
+ /**
315
+ * Translate a child process result into an exit code for the wrapper.
316
+ *
317
+ * A Chromium FATAL kills the process with a signal, so `close` reports
318
+ * `code === null, signal === 'SIGTRAP'` (measured). The wrapper used to call
319
+ * `process.exit(code)` with that null, which Node coerces to 0 — so an app that
320
+ * aborted on startup reported success to the shell, to scripts, and to CI. The
321
+ * conventional 128+n encoding preserves which signal it was.
322
+ */
323
+ /**
324
+ * Signals that mean "a fatal precondition check killed us", which is how a
325
+ * Chromium sandbox abort actually terminates.
326
+ *
327
+ * This is an ALLOWLIST, and that direction is the point. The previous version
328
+ * asked "is this NOT a deliberate stop?", which acquits Ctrl+C but still
329
+ * convicts every other early death -- including an app that ran its own code and
330
+ * chose an exit status. QA reproduced exactly that: with the sandbox provably
331
+ * not implicated (userns working, so the app reached its own `exit(42)`), the
332
+ * launcher still printed the full chown/chmod guidance. Asking instead "does
333
+ * this death look like a failed CHECK?" is what separates the two.
334
+ *
335
+ * Chromium's LOG(FATAL) raises the debugger trap rather than returning a status,
336
+ * so the sandbox abort arrives as code null + SIGTRAP -- measured in
337
+ * node:22-bookworm, and reported by a shell as 133 (128+5). SIGABRT and SIGILL
338
+ * are the same class of deliberate self-kill on a failed check and are included
339
+ * so the rule is not pinned to one Chromium build's trap instruction.
340
+ *
341
+ * NOT included, deliberately: SIGSEGV and SIGBUS (a memory fault is a genuine
342
+ * crash, not a misconfigured helper), and ANY numeric exit code. A numeric code
343
+ * means the process got far enough to decide one, and the zygote abort never
344
+ * does. The asymmetry is what settles the borderline: a false positive tells a
345
+ * working install to run two sudo commands that change nothing, while a false
346
+ * negative merely leaves the user with Chromium's own message -- the state
347
+ * before this file existed.
348
+ */
349
+ const ABORT_SIGNALS = new Set(["SIGTRAP", "SIGABRT", "SIGILL"]);
350
+
351
+ /**
352
+ * How long after spawn a death can still plausibly be a startup abort.
353
+ *
354
+ * The sandbox FATAL is raised during Chromium's zygote setup, before any window
355
+ * exists -- measured at well under a second in node:22-bookworm. Ten seconds is
356
+ * a deliberately loose bound around that: generous enough for a slow or loaded
357
+ * machine, far short of any session a user would call "it was running".
358
+ */
359
+ const STARTUP_WINDOW_MS = 10_000;
360
+
361
+ /**
362
+ * Did the child fail to START, as opposed to exiting non-zero after running?
363
+ *
364
+ * WHY THIS IS NOT `code !== 0`: the post-mortem below prints "your sandbox
365
+ * helper is misconfigured" and tells the user to chmod 4755. On the exact
366
+ * configuration this fix sets out to protect -- a working 0755 install using the
367
+ * unprivileged user-namespace sandbox -- the helper is PERMANENTLY in the state
368
+ * the guidance keys on, so any non-zero exit misdiagnoses. `code !== 0` is also
369
+ * true for `code === null`, which is every signal death including Ctrl+C: the
370
+ * user quits an app that ran fine for an hour and is told to fix its
371
+ * permissions. That is precisely the "sends the reader to diagnose the wrong
372
+ * thing" failure the postinstall's own comments are careful to avoid.
373
+ *
374
+ * Two conditions, BOTH required: the death has the shape of a fatal check (see
375
+ * ABORT_SIGNALS -- a deliberate stop signal and an ordinary numeric exit are
376
+ * both excluded by it), and it happened inside the startup window, since a
377
+ * process that lived past it demonstrably started.
378
+ */
379
+ const isStartupFailure = ({ signal, elapsedMs }) => {
380
+ if (!signal || !ABORT_SIGNALS.has(signal)) {
381
+ return false;
382
+ }
383
+ return typeof elapsedMs !== "number" || elapsedMs < STARTUP_WINDOW_MS;
384
+ };
385
+
386
+ const exitCodeFor = (code, signal) => {
387
+ if (typeof code === "number") {
388
+ return code;
389
+ }
390
+ if (signal) {
391
+ const number = require("node:os").constants.signals[signal];
392
+ return typeof number === "number" ? 128 + number : 1;
393
+ }
394
+ return 1;
395
+ };
396
+
397
+ /**
398
+ * Make sure Electron's dist/ exists before we try to repair the helper inside it.
399
+ *
400
+ * WHY THIS IS NECESSARY, and it is not obvious: npm runs the ROOT package's
401
+ * postinstall BEFORE the postinstall of its dependencies. Measured on a real
402
+ * `npm install -g` in node:22-bookworm, our script is line 27 of the install
403
+ * stream and `electron@35.5.1 postinstall -> node install.js` is line 57 — and
404
+ * that dependency script is what downloads and unpacks dist/. At our turn the
405
+ * helper does not exist yet and `require("electron")` throws
406
+ * "Electron failed to install correctly".
407
+ *
408
+ * The first version of this fix therefore did nothing at all: it reported the
409
+ * helper "absent", exited 0, and Electron then unpacked a fresh 0755 helper
410
+ * afterwards, leaving the bug exactly as it was while the install looked clean.
411
+ * That is the failure mode this file's whole design is meant to avoid, so it is
412
+ * recorded here rather than quietly corrected.
413
+ *
414
+ * The fix is to drive Electron's own installer first. It is idempotent and
415
+ * cache-backed: with a valid dist/ already present it returns in ~0.08s without
416
+ * touching the network, and npm's later invocation of the same script is then
417
+ * the no-op instead. It also preserves a setuid bit we have already set
418
+ * (verified), so the ordering between the two runs does not matter.
419
+ *
420
+ * Returns true when a helper is present afterwards. Every failure degrades to
421
+ * false: the install must still succeed (see bin/postinstall.js).
422
+ */
423
+ const ensureElectronDist = (packageRoot) => {
424
+ const installer = path.join(
425
+ packageRoot,
426
+ "node_modules",
427
+ "electron",
428
+ "install.js",
429
+ );
430
+ if (!fs.existsSync(installer)) {
431
+ return false;
432
+ }
433
+ const distDir = path.join(packageRoot, "node_modules", "electron", "dist");
434
+ // Key the fast path on the SAME marker electron's own isInstalled() uses
435
+ // (dist/version) as well as the helper. Keying on chrome-sandbox alone let a
436
+ // partially-extracted dist/ that happens to contain the helper report a
437
+ // healthy install, so we would skip the installer that would have repaired it.
438
+ if (
439
+ fs.existsSync(path.join(distDir, "version")) &&
440
+ fs.existsSync(path.join(distDir, "chrome-sandbox"))
441
+ ) {
442
+ return true;
443
+ }
444
+ // Inherit nothing on stdout: the download prints a progress bar that would
445
+ // otherwise appear twice in the install log, once here and once from npm's
446
+ // own run of the same script.
447
+ const result = spawnSync(process.execPath, [installer], {
448
+ cwd: path.dirname(installer),
449
+ stdio: "ignore",
450
+ // A hung download must not hang the install. Ten minutes is generous for a
451
+ // ~100MB fetch on a slow link and still bounded.
452
+ timeout: 10 * 60 * 1000,
453
+ });
454
+ return (
455
+ result.status === 0 && fs.existsSync(path.join(distDir, "chrome-sandbox"))
456
+ );
457
+ };
458
+
459
+ module.exports = {
460
+ REQUIRED_MODE,
461
+ STARTUP_WINDOW_MS,
462
+ isHelperHealthy,
463
+ isStartupFailure,
464
+ ensureElectronDist,
465
+ resolveSandboxHelper,
466
+ inspectSandboxHelper,
467
+ repairSandboxHelper,
468
+ rootGuidance,
469
+ sandboxHelperGuidance,
470
+ exitCodeFor,
471
+ };
@@ -80,7 +80,56 @@ try {
80
80
  } catch (err) {
81
81
  // Version metadata is unreadable while the binary itself resolved. Nothing
82
82
  // actionable to assert, and the app is more useful started than blocked.
83
- console.warn(`Warning: could not verify the Electron version: ${err.message}`);
83
+ console.warn(
84
+ `Warning: could not verify the Electron version: ${err.message}`,
85
+ );
86
+ }
87
+
88
+ const {
89
+ resolveSandboxHelper,
90
+ inspectSandboxHelper,
91
+ isStartupFailure,
92
+ rootGuidance,
93
+ sandboxHelperGuidance,
94
+ exitCodeFor,
95
+ } = require("./linux-sandbox.js");
96
+
97
+ // How the user invoked us, for guidance that can be copied verbatim.
98
+ //
99
+ // A global install puts `local-operator-ui` on PATH and that is the name to
100
+ // echo back. But `npx local-operator-ui` and a local ./node_modules/.bin/ run
101
+ // reach this file through a shim whose basename is still ours, so use argv[1]'s
102
+ // basename when it resolves to something other than the bare script path --
103
+ // otherwise we hand a user without a global install a command that will not
104
+ // resolve for them.
105
+ const invokedAs = (() => {
106
+ const fromArgv =
107
+ typeof process.argv[1] === "string" ? path.basename(process.argv[1]) : "";
108
+ // Strip a .js suffix: the shim is the extensionless name on PATH.
109
+ const name = fromArgv.replace(/\.[cm]?js$/, "");
110
+ return name === "" ? "local-operator-ui" : name;
111
+ })();
112
+
113
+ // Linux preflight: running as root ALWAYS aborts, whatever the sandbox helper's
114
+ // mode (measured — a correctly 4755 helper does not help root). Because it
115
+ // depends only on the effective uid, it is decidable here, so the user gets an
116
+ // explanation instead of Chromium's
117
+ // "[FATAL:electron_main_delegate.cc(288)] Running as root without --no-sandbox
118
+ // is not supported", which says nothing about what to do next.
119
+ //
120
+ // The user's own ELECTRON_DISABLE_SANDBOX opt-out must still be honoured: they
121
+ // have made the security decision explicitly, and blocking them here would
122
+ // override it. We never set that variable ourselves. See bin/linux-sandbox.js.
123
+ if (
124
+ process.platform === "linux" &&
125
+ typeof process.getuid === "function" &&
126
+ process.getuid() === 0 &&
127
+ process.env.ELECTRON_DISABLE_SANDBOX !== "1"
128
+ ) {
129
+ for (const line of rootGuidance(invokedAs)) {
130
+ console.error(line);
131
+ }
132
+ process.exit(1);
84
133
  }
85
134
 
86
135
  // Get the path to the main.js file
@@ -103,8 +152,47 @@ const child = spawn(electronPath, [appPath], {
103
152
  });
104
153
 
105
154
  // Handle process exit
106
- child.on("close", (code) => {
107
- process.exit(code);
155
+ // Wall-clock reference for the startup-failure window below. Taken immediately
156
+ // after spawn so the measurement covers the child's whole life.
157
+ const spawnedAt = Date.now();
158
+
159
+ child.on("close", (code, signal) => {
160
+ // A missing setuid bit is NOT predictable as a failure: Chromium falls back to
161
+ // the unprivileged user-namespace sandbox and starts normally where the kernel
162
+ // allows it (measured — the same container reaches readiness with the helper
163
+ // still at 0755 once seccomp permits unshare). Checking before launch would
164
+ // therefore refuse installs that work today, and the /proc indicators cannot
165
+ // see a seccomp filter that blocks the syscall.
166
+ //
167
+ // So diagnose after the fact instead: the app has already failed to START,
168
+ // and the helper is in the state known to cause exactly this.
169
+ //
170
+ // "Failed to start" is much narrower than "did not exit zero", and the
171
+ // difference is user-visible: on a working 0755 userns install the helper sits
172
+ // permanently in the state this guidance keys on, so ANY other reason for a
173
+ // non-zero exit -- Ctrl+C, a crash on quit, or the app choosing its own status
174
+ // -- would otherwise be answered with "run sudo chmod 4755". Only a fatal-check
175
+ // signal death inside the startup window qualifies; `code` is deliberately not
176
+ // consulted, because a process that chose an exit status got past the zygote.
177
+ // See isStartupFailure.
178
+ const failedToStart = isStartupFailure({
179
+ signal,
180
+ elapsedMs: Date.now() - spawnedAt,
181
+ });
182
+ if (failedToStart && process.platform === "linux") {
183
+ const helper = inspectSandboxHelper(resolveSandboxHelper(electronPath));
184
+ if (helper.exists && helper.needsRepair) {
185
+ console.error("");
186
+ for (const line of sandboxHelperGuidance(helper, invokedAs)) {
187
+ console.error(line);
188
+ }
189
+ }
190
+ }
191
+
192
+ // A Chromium FATAL terminates by signal, so `code` is null here and the old
193
+ // `process.exit(code)` reported success (Node coerces null to 0) for an app
194
+ // that never started. Map a signal death onto the conventional 128+n.
195
+ process.exit(exitCodeFor(code, signal));
108
196
  });
109
197
 
110
198
  // Handle errors