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.
- package/bin/linux-sandbox.js +471 -0
- package/bin/local-operator-ui.js +91 -3
- package/bin/postinstall.js +120 -0
- package/out/renderer/assets/{_basePickBy-CfllbLPT.js → _basePickBy-CefSvQ3Q.js} +1 -1
- package/out/renderer/assets/{_baseUniq-t3IzOokH.js → _baseUniq-DlDqTbuA.js} +1 -1
- package/out/renderer/assets/{agent-details-page-BU1kYKxK.js → agent-details-page-BCuUg55n.js} +1 -1
- package/out/renderer/assets/{agent-hub-page-DrFYnBSt.js → agent-hub-page-BqCCuxo-.js} +1 -1
- package/out/renderer/assets/{agents-page-cq-zLmOE.js → agents-page-DG4vCZu3.js} +1 -1
- package/out/renderer/assets/{architectureDiagram-IEHRJDOE-DZdNjiiw.js → architectureDiagram-IEHRJDOE-CUlo4ACF.js} +1 -1
- package/out/renderer/assets/{blockDiagram-JOT3LUYC-DfSTYCE8.js → blockDiagram-JOT3LUYC-nU3IOM88.js} +1 -1
- package/out/renderer/assets/{c4Diagram-VJAJSXHY-kj8ONcdJ.js → c4Diagram-VJAJSXHY-MT0nxa4-.js} +1 -1
- package/out/renderer/assets/channel-CCadi6VR.js +1 -0
- package/out/renderer/assets/{chunk-4BMEZGHF-DTEIsOQ8.js → chunk-4BMEZGHF-CXkPsNEi.js} +1 -1
- package/out/renderer/assets/{chunk-A2AXSNBT-H5FcAjIP.js → chunk-A2AXSNBT-DiTT6jkA.js} +1 -1
- package/out/renderer/assets/{chunk-AEK57VVT-Ckh1RshY.js → chunk-AEK57VVT-CIZ7-t01.js} +1 -1
- package/out/renderer/assets/{chunk-D6G4REZN-BI15Ho6z.js → chunk-D6G4REZN-BIKuFzEX.js} +1 -1
- package/out/renderer/assets/{chunk-RZ5BOZE2-B4XztrFv.js → chunk-RZ5BOZE2-byZRPh67.js} +1 -1
- package/out/renderer/assets/{chunk-XZIHB7SX-RYtDnxT5.js → chunk-XZIHB7SX-BM-t-ERh.js} +1 -1
- package/out/renderer/assets/{classDiagram-GIVACNV2-BZfvXxWD.js → classDiagram-GIVACNV2-Bkvtrmw4.js} +1 -1
- package/out/renderer/assets/{classDiagram-v2-COTLJTTW-BZfvXxWD.js → classDiagram-v2-COTLJTTW-Bkvtrmw4.js} +1 -1
- package/out/renderer/assets/clone-DTDvO4Il.js +1 -0
- package/out/renderer/assets/{dagre-OKDRZEBW-CVqq_Qpq.js → dagre-OKDRZEBW-DU9huU8P.js} +1 -1
- package/out/renderer/assets/{diagram-SSKATNLV-7I1hPrEB.js → diagram-SSKATNLV-C6KmXMQc.js} +1 -1
- package/out/renderer/assets/{diagram-VNBRO52H-B4In8mRQ.js → diagram-VNBRO52H-B2c-8R3S.js} +1 -1
- package/out/renderer/assets/{erDiagram-Q7BY3M3F-D8m7xWyt.js → erDiagram-Q7BY3M3F-D6QA70K4.js} +1 -1
- package/out/renderer/assets/{flowDiagram-4HSFHLVR-HzyvxZJ_.js → flowDiagram-4HSFHLVR-Dk0lRNK2.js} +1 -1
- package/out/renderer/assets/{ganttDiagram-APWFNJXF-D9VGOeKz.js → ganttDiagram-APWFNJXF-BevHed3v.js} +1 -1
- package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-bCjN7hkh.js → gitGraphDiagram-7IBYFJ6S-CmOfsj5i.js} +1 -1
- package/out/renderer/assets/{graph-Bm4dxGQt.js → graph-CUJVKfm_.js} +1 -1
- package/out/renderer/assets/{index-C_toWewb.js → index-8mt-yi7u.js} +1 -1
- package/out/renderer/assets/{index-fNnd2qrg.js → index-DZnGa8Vt.js} +265 -265
- package/out/renderer/assets/{index-B1ky8xLm.js → index-Eh3n7DJx.js} +1 -1
- package/out/renderer/assets/{infoDiagram-PH2N3AL5-B2JkP-Hd.js → infoDiagram-PH2N3AL5-DWdIO3pN.js} +1 -1
- package/out/renderer/assets/{journeyDiagram-U35MCT3I-NnbVNsik.js → journeyDiagram-U35MCT3I-BNOCDq91.js} +1 -1
- package/out/renderer/assets/{kanban-definition-NDS4AKOZ-DBgR7W50.js → kanban-definition-NDS4AKOZ-CIVzZzmM.js} +1 -1
- package/out/renderer/assets/{layout-CE8ShubG.js → layout-zNSVRDKv.js} +1 -1
- package/out/renderer/assets/{mermaid.core-CExbFl-7.js → mermaid.core-CFY9BM3R.js} +5 -5
- package/out/renderer/assets/{mindmap-definition-ALO5MXBD-ag_HQs7G.js → mindmap-definition-ALO5MXBD-yS5L1WQ2.js} +1 -1
- package/out/renderer/assets/{parseISO-LV1Yh_aZ.js → parseISO-DIyaQXAe.js} +1 -1
- package/out/renderer/assets/{pieDiagram-IB7DONF6-DJ-5Rt4W.js → pieDiagram-IB7DONF6-BPWYAs8_.js} +1 -1
- package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-BQceCClO.js → quadrantDiagram-7GDLP6J5-B82PFOoq.js} +1 -1
- package/out/renderer/assets/{radar-MK3ICKWK-D2qI0wBa.js → radar-MK3ICKWK-Z4U7o29x.js} +1 -1
- package/out/renderer/assets/{requirementDiagram-KVF5MWMF-Bd6T_ndu.js → requirementDiagram-KVF5MWMF-BwhazqTR.js} +1 -1
- package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-CnMYNt__.js → sankeyDiagram-QLVOVGJD-Cg4M6UvE.js} +1 -1
- package/out/renderer/assets/{schedules-page-DqrcIxzB.js → schedules-page-ChiypWrp.js} +1 -1
- package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-SiFK0EWD.js → sequenceDiagram-X6HHIX6F-hTniLZHQ.js} +1 -1
- package/out/renderer/assets/{settings-page-D_JWK3qq.js → settings-page-CKzoAptg.js} +1 -1
- package/out/renderer/assets/{stateDiagram-DGXRK772-BrX4XGdu.js → stateDiagram-DGXRK772-DI0pBdbL.js} +1 -1
- package/out/renderer/assets/{stateDiagram-v2-YXO3MK2T-CEzipxL2.js → stateDiagram-v2-YXO3MK2T-CG5hsBHO.js} +1 -1
- package/out/renderer/assets/{timeline-definition-BDJGKUSR-CtwXxKAC.js → timeline-definition-BDJGKUSR-t0KbQhGu.js} +1 -1
- package/out/renderer/assets/{use-agent-like-mutation-f9aYefiU.js → use-agent-like-mutation-C_nJtNuz.js} +1 -1
- package/out/renderer/assets/{xychartDiagram-VJFVF3MP-BUx2vked.js → xychartDiagram-VJFVF3MP-BQjVqv5u.js} +1 -1
- package/out/renderer/index.html +1 -1
- package/package.json +6 -5
- package/out/renderer/assets/channel-Dy-Y0W96.js +0 -1
- 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
|
+
};
|
package/bin/local-operator-ui.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
107
|
-
|
|
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
|