@tsconfigdotjson/vibe-os 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +928 -2
- package/THIRD_PARTY_NOTICES.md +2467 -0
- package/bin/vibe-os.mjs +13 -0
- package/dist/web/apple-touch-icon.png +0 -0
- package/dist/web/assets/index-B77Qk8B5.js +35 -0
- package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
- package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css +1 -0
- package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon-app.svg +19 -0
- package/dist/web/icon-maskable-512.png +0 -0
- package/dist/web/icon-maskable.svg +13 -0
- package/dist/web/icon.svg +15 -0
- package/dist/web/index.html +36 -0
- package/dist/web/ssh.wasm +0 -0
- package/dist/web/ssh.wasm.br +0 -0
- package/dist/web/ssh.wasm.gz +0 -0
- package/dist/web/sw.js +29 -0
- package/dist/web/wasm_exec.js +575 -0
- package/dist/web/wasm_exec.js.br +0 -0
- package/dist/web/wasm_exec.js.gz +0 -0
- package/package.json +82 -4
- package/scripts/compile.ts +62 -0
- package/scripts/compress.mjs +54 -0
- package/scripts/fetch-wasm.mjs +242 -0
- package/scripts/gen-assets.ts +76 -0
- package/scripts/lib/walk.ts +18 -0
- package/scripts/notices.ts +189 -0
- package/server/acme.ts +182 -0
- package/server/api.ts +515 -0
- package/server/assets.generated.ts +53 -0
- package/server/attach.ts +524 -0
- package/server/auth.ts +194 -0
- package/server/bridge.ts +145 -0
- package/server/browser.ts +519 -0
- package/server/cli.ts +843 -0
- package/server/config.ts +362 -0
- package/server/db.ts +262 -0
- package/server/doctor.ts +966 -0
- package/server/fsx.ts +88 -0
- package/server/harness.ts +654 -0
- package/server/http.ts +53 -0
- package/server/index.ts +469 -0
- package/server/log.ts +40 -0
- package/server/main.ts +7 -0
- package/server/mcp.ts +291 -0
- package/server/preflight.ts +87 -0
- package/server/profiles.ts +238 -0
- package/server/projects.ts +721 -0
- package/server/reaper.ts +303 -0
- package/server/runtime.ts +64 -0
- package/server/session.ts +330 -0
- package/server/ssh-ca.ts +368 -0
- package/server/static.ts +147 -0
- package/server/wallpapers.ts +248 -0
- package/server/windows.ts +237 -0
- package/shared/args.ts +91 -0
- package/shared/grid.ts +32 -0
- package/shared/wire.ts +226 -0
package/server/doctor.ts
ADDED
|
@@ -0,0 +1,966 @@
|
|
|
1
|
+
// Pre-flight for a real machine.
|
|
2
|
+
//
|
|
3
|
+
// Everything here exists because it fails *silently* otherwise. A browser
|
|
4
|
+
// window that cannot log in says "handshake failed: websocket was closed by
|
|
5
|
+
// server" and nothing else — the actual cause is in sshd's log, on a box you
|
|
6
|
+
// were hoping to administer through the terminal that just failed to open. So
|
|
7
|
+
// these checks read sshd's own effective configuration and answer the only
|
|
8
|
+
// question that matters: will a certificate this host signs actually be
|
|
9
|
+
// accepted for this user?
|
|
10
|
+
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { readFile, stat } from "node:fs/promises";
|
|
13
|
+
import net from "node:net";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { promisify } from "node:util";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
archSupported,
|
|
20
|
+
BROWSER_COMMANDS,
|
|
21
|
+
memoryHeadroom,
|
|
22
|
+
parseMemInfo,
|
|
23
|
+
portsFromUnits,
|
|
24
|
+
vncExposure,
|
|
25
|
+
vncPasswordFileFromUnit,
|
|
26
|
+
} from "./browser.ts";
|
|
27
|
+
import type { Config } from "./config.ts";
|
|
28
|
+
import {
|
|
29
|
+
CURSOR_INSTALL,
|
|
30
|
+
discoverCursor,
|
|
31
|
+
discoverHermes,
|
|
32
|
+
HERMES_INSTALL,
|
|
33
|
+
UV_INSTALL,
|
|
34
|
+
} from "./harness.ts";
|
|
35
|
+
import { findLeaks } from "./reaper.ts";
|
|
36
|
+
import { IS_COMPILED } from "./runtime.ts";
|
|
37
|
+
import { discoverHostKey, SshCa } from "./ssh-ca.ts";
|
|
38
|
+
|
|
39
|
+
const run = promisify(execFile);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What the host firewall is doing, as far as this machine can tell.
|
|
43
|
+
*
|
|
44
|
+
* Two honest limits, both stated in the check's own output rather than left for
|
|
45
|
+
* the reader to infer:
|
|
46
|
+
*
|
|
47
|
+
* - This is *configuration*, not reachability. Proving a port is unreachable
|
|
48
|
+
* needs a packet sent from somewhere else, and nothing on this box qualifies.
|
|
49
|
+
* - Your provider's firewall — OVH's Network Firewall, AWS security groups,
|
|
50
|
+
* Hetzner's — sits in front of the machine and is invisible from inside it. A
|
|
51
|
+
* clean answer here says nothing about that layer.
|
|
52
|
+
*
|
|
53
|
+
* Both of those are why this reports rather than blocks: a wrong "you are safe"
|
|
54
|
+
* is worse than no answer.
|
|
55
|
+
*/
|
|
56
|
+
interface FirewallState {
|
|
57
|
+
/** null when no firewall tool could be read at all. */
|
|
58
|
+
tool: "ufw" | "nftables" | "iptables" | null;
|
|
59
|
+
active: boolean;
|
|
60
|
+
/** Incoming denied unless explicitly allowed. */
|
|
61
|
+
defaultDeny: boolean;
|
|
62
|
+
/** An allow rule for the tailscale interface. */
|
|
63
|
+
tailnetAllowed: boolean;
|
|
64
|
+
/** 41641/udp, which keeps Tailscale on a direct path instead of a relay. */
|
|
65
|
+
directUdpAllowed: boolean;
|
|
66
|
+
/** True when we could not read the rules — almost always "not root". */
|
|
67
|
+
unreadable: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseUfw(
|
|
71
|
+
status: string,
|
|
72
|
+
): Omit<FirewallState, "tool" | "unreadable"> {
|
|
73
|
+
const text = status.toLowerCase();
|
|
74
|
+
return {
|
|
75
|
+
active: /status:\s*active/.test(text),
|
|
76
|
+
defaultDeny: /default:\s*deny\s*\(incoming\)/.test(text),
|
|
77
|
+
tailnetAllowed: /tailscale\d*\b[^\n]*allow in/.test(text),
|
|
78
|
+
directUdpAllowed: /41641\/udp[^\n]*allow in/.test(text),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parseNftOrIptables(
|
|
83
|
+
rules: string,
|
|
84
|
+
): Omit<FirewallState, "tool" | "unreadable"> {
|
|
85
|
+
const text = rules.toLowerCase();
|
|
86
|
+
return {
|
|
87
|
+
// A ruleset we could read at all, with an input chain, counts as active.
|
|
88
|
+
active: /input/.test(text),
|
|
89
|
+
defaultDeny:
|
|
90
|
+
/-p\s+input\s+drop/.test(text) ||
|
|
91
|
+
/chain\s+input\b[^\n]*policy\s+drop/.test(text) ||
|
|
92
|
+
// nft writes `hook input priority 0; policy drop;` — the policy sits after
|
|
93
|
+
// a semicolon, so this must be allowed to cross one.
|
|
94
|
+
/hook\s+input[\s\S]{0,120}?policy\s+drop/.test(text),
|
|
95
|
+
tailnetAllowed:
|
|
96
|
+
/(iifname\s+"?tailscale\d*"?|-i\s+tailscale\d*)[^\n]*(accept|-j accept)/.test(
|
|
97
|
+
text,
|
|
98
|
+
),
|
|
99
|
+
directUdpAllowed: /41641[^\n]*(accept|-j accept)/.test(text),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function firewallState(): Promise<FirewallState> {
|
|
104
|
+
const blank = {
|
|
105
|
+
active: false,
|
|
106
|
+
defaultDeny: false,
|
|
107
|
+
tailnetAllowed: false,
|
|
108
|
+
directUdpAllowed: false,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// ufw first: it is what the README documents, and its status output says
|
|
112
|
+
// plainly whether it is switched on, which a raw ruleset does not.
|
|
113
|
+
const ufw = await run("ufw", ["status", "verbose"], { timeout: 5000 })
|
|
114
|
+
.then((r) => r.stdout)
|
|
115
|
+
.catch(() => null);
|
|
116
|
+
if (ufw) return { tool: "ufw", unreadable: false, ...parseUfw(ufw) };
|
|
117
|
+
|
|
118
|
+
for (const [tool, cmd, args] of [
|
|
119
|
+
["nftables", "nft", ["list", "ruleset"]],
|
|
120
|
+
["iptables", "iptables", ["-S"]],
|
|
121
|
+
] as const) {
|
|
122
|
+
const out = await run(cmd, [...args], { timeout: 5000 })
|
|
123
|
+
.then((r) => r.stdout)
|
|
124
|
+
.catch(() => null);
|
|
125
|
+
if (out?.trim())
|
|
126
|
+
return { tool, unreadable: false, ...parseNftOrIptables(out) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { tool: null, unreadable: true, ...blank };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const CHROME_UNIT = "/etc/systemd/system/vibe-os-chrome.service";
|
|
133
|
+
const XVNC_UNIT = "/etc/systemd/system/vibe-os-xvnc.service";
|
|
134
|
+
|
|
135
|
+
export interface Check {
|
|
136
|
+
label: string;
|
|
137
|
+
ok: boolean;
|
|
138
|
+
detail: string;
|
|
139
|
+
/** A failure that stops vibe-os working at all, rather than degrading it. */
|
|
140
|
+
fatal?: boolean;
|
|
141
|
+
/** A command or edit that would fix it. */
|
|
142
|
+
fix?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const ok = (label: string, detail: string): Check => ({
|
|
146
|
+
label,
|
|
147
|
+
ok: true,
|
|
148
|
+
detail,
|
|
149
|
+
});
|
|
150
|
+
const bad = (label: string, detail: string, fix?: string): Check => ({
|
|
151
|
+
label,
|
|
152
|
+
ok: false,
|
|
153
|
+
detail,
|
|
154
|
+
fix,
|
|
155
|
+
});
|
|
156
|
+
const fatal = (label: string, detail: string, fix?: string): Check => ({
|
|
157
|
+
label,
|
|
158
|
+
ok: false,
|
|
159
|
+
detail,
|
|
160
|
+
fix,
|
|
161
|
+
fatal: true,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
function probeTcp(
|
|
165
|
+
host: string,
|
|
166
|
+
port: number,
|
|
167
|
+
timeout = 2000,
|
|
168
|
+
): Promise<boolean> {
|
|
169
|
+
return new Promise((resolve) => {
|
|
170
|
+
const socket = net.connect({ host, port });
|
|
171
|
+
const done = (result: boolean) => {
|
|
172
|
+
socket.destroy();
|
|
173
|
+
resolve(result);
|
|
174
|
+
};
|
|
175
|
+
socket.setTimeout(timeout);
|
|
176
|
+
socket.once("connect", () => done(true));
|
|
177
|
+
socket.once("timeout", () => done(false));
|
|
178
|
+
socket.once("error", () => done(false));
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Why a port is unavailable, which is not one answer but two.
|
|
184
|
+
*
|
|
185
|
+
* "Permission denied" wants a capability; "address in use" usually means
|
|
186
|
+
* vibe-os is already running and the check has nothing to report. Telling
|
|
187
|
+
* someone to run setcap because their own server is up is worse than useless.
|
|
188
|
+
*/
|
|
189
|
+
function bindStatus(
|
|
190
|
+
port: number,
|
|
191
|
+
host: string,
|
|
192
|
+
): Promise<"free" | "in-use" | "denied"> {
|
|
193
|
+
return new Promise((resolve) => {
|
|
194
|
+
const server = net.createServer();
|
|
195
|
+
server.once("error", (err: NodeJS.ErrnoException) => {
|
|
196
|
+
resolve(err.code === "EADDRINUSE" ? "in-use" : "denied");
|
|
197
|
+
});
|
|
198
|
+
server.once("listening", () => server.close(() => resolve("free")));
|
|
199
|
+
server.listen(port, host);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Is this command on PATH?
|
|
205
|
+
*
|
|
206
|
+
* One `/bin/sh -c` invocation, with the command passed as `$1` rather than
|
|
207
|
+
* interpolated into the script. The previous version ran the same thing twice —
|
|
208
|
+
* `execFile` with `shell: "/bin/sh"` already builds a `sh -c` string, so the
|
|
209
|
+
* "fall back to asking the shell directly" branch was the identical call and
|
|
210
|
+
* could only fire if /bin/sh were missing, in which case it failed too. It also
|
|
211
|
+
* built its script by interpolation, which no caller exploited but nothing
|
|
212
|
+
* stopped either.
|
|
213
|
+
*/
|
|
214
|
+
export async function onPath(command: string): Promise<boolean> {
|
|
215
|
+
try {
|
|
216
|
+
await run("/bin/sh", ["-c", 'command -v "$1" > /dev/null', "sh", command]);
|
|
217
|
+
return true;
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The always-on browser, when there is one.
|
|
225
|
+
*
|
|
226
|
+
* Returns nothing at all unless the units are installed or Chrome is present,
|
|
227
|
+
* so this stays invisible on the boxes that never asked for it.
|
|
228
|
+
*/
|
|
229
|
+
async function browserChecks(): Promise<Check[]> {
|
|
230
|
+
const readIfPresent = (p: string) =>
|
|
231
|
+
readFile(p, "utf8").catch(() => null as string | null);
|
|
232
|
+
const [chromeUnit, xvncUnit] = await Promise.all([
|
|
233
|
+
readIfPresent(CHROME_UNIT),
|
|
234
|
+
readIfPresent(XVNC_UNIT),
|
|
235
|
+
]);
|
|
236
|
+
const chromeInstalled = await onPath("google-chrome");
|
|
237
|
+
if (!chromeUnit && !xvncUnit && !chromeInstalled) return [];
|
|
238
|
+
|
|
239
|
+
const checks: Check[] = [];
|
|
240
|
+
const { vncPort, cdpPort } = portsFromUnits({
|
|
241
|
+
xvnc: xvncUnit,
|
|
242
|
+
chrome: chromeUnit,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
if (!archSupported()) {
|
|
246
|
+
checks.push(
|
|
247
|
+
bad(
|
|
248
|
+
"browser",
|
|
249
|
+
`Chrome for Linux is x86_64 only, and this is ${os.arch()}`,
|
|
250
|
+
"there is no fix — the extension needs Chrome itself, not Chromium",
|
|
251
|
+
),
|
|
252
|
+
);
|
|
253
|
+
return checks;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const missing = [];
|
|
257
|
+
for (const entry of BROWSER_COMMANDS) {
|
|
258
|
+
if (!(await onPath(entry.command))) missing.push(entry.package);
|
|
259
|
+
}
|
|
260
|
+
if (missing.length > 0) {
|
|
261
|
+
checks.push(
|
|
262
|
+
bad(
|
|
263
|
+
"browser",
|
|
264
|
+
`installed as a service, but missing: ${missing.join(", ")}`,
|
|
265
|
+
"vibe-os install-browser prints the apt line",
|
|
266
|
+
),
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Is it actually up? A unit that exists and a browser that answers are
|
|
271
|
+
// different claims, and only the second one means you can use it.
|
|
272
|
+
if (chromeUnit) {
|
|
273
|
+
const answering = await probeTcp("127.0.0.1", cdpPort, 3000);
|
|
274
|
+
checks.push(
|
|
275
|
+
answering
|
|
276
|
+
? ok("browser", `Chrome answering on 127.0.0.1:${cdpPort}`)
|
|
277
|
+
: bad(
|
|
278
|
+
"browser",
|
|
279
|
+
`installed, but nothing answers the debug port on 127.0.0.1:${cdpPort}`,
|
|
280
|
+
"systemctl status vibe-os-chrome && journalctl -u vibe-os-chrome -n 50",
|
|
281
|
+
),
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Where the display is listening is the whole security model for it: served
|
|
286
|
+
// with no password, on the argument that only loopback can reach it.
|
|
287
|
+
const ss = await run("ss", ["-ltnH"], { timeout: 5_000 })
|
|
288
|
+
.then((r) => r.stdout)
|
|
289
|
+
.catch(() => null);
|
|
290
|
+
|
|
291
|
+
if (!ss) {
|
|
292
|
+
checks.push(
|
|
293
|
+
bad(
|
|
294
|
+
"display",
|
|
295
|
+
"could not read listening sockets, so nothing here can say where VNC is bound",
|
|
296
|
+
"install iproute2, or check by hand: ss -ltn | grep 5900",
|
|
297
|
+
),
|
|
298
|
+
);
|
|
299
|
+
} else {
|
|
300
|
+
const exposure = vncExposure(ss, vncPort);
|
|
301
|
+
const auth = vncPasswordFileFromUnit(xvncUnit)
|
|
302
|
+
? "password set"
|
|
303
|
+
: "no password, so SSH is the only gate";
|
|
304
|
+
if (exposure === "loopback") {
|
|
305
|
+
checks.push(
|
|
306
|
+
ok(
|
|
307
|
+
"display",
|
|
308
|
+
`VNC on ${vncPort}, loopback only, ${auth} — reach it with ssh -L ${vncPort}:127.0.0.1:${vncPort}`,
|
|
309
|
+
),
|
|
310
|
+
);
|
|
311
|
+
} else if (exposure === "exposed") {
|
|
312
|
+
checks.push(
|
|
313
|
+
fatal(
|
|
314
|
+
"display",
|
|
315
|
+
`VNC on ${vncPort} is bound beyond loopback, and it has no password — ` +
|
|
316
|
+
"anyone who can route here gets the browser and everything signed in to it",
|
|
317
|
+
"add -localhost to the Xtigervnc line in vibe-os-xvnc.service, then: systemctl restart vibe-os-xvnc",
|
|
318
|
+
),
|
|
319
|
+
);
|
|
320
|
+
} else if (chromeUnit || xvncUnit) {
|
|
321
|
+
checks.push(
|
|
322
|
+
bad(
|
|
323
|
+
"display",
|
|
324
|
+
`nothing listening on ${vncPort} — the virtual display is not running`,
|
|
325
|
+
"systemctl status vibe-os-xvnc",
|
|
326
|
+
),
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const mem = parseMemInfo(
|
|
332
|
+
await readIfPresent("/proc/meminfo").then((t) => t ?? ""),
|
|
333
|
+
);
|
|
334
|
+
if (mem) {
|
|
335
|
+
const headroom = memoryHeadroom(mem);
|
|
336
|
+
checks.push(
|
|
337
|
+
headroom.ok
|
|
338
|
+
? ok("browser memory", headroom.detail)
|
|
339
|
+
: bad(
|
|
340
|
+
"browser memory",
|
|
341
|
+
`${headroom.detail} — Chrome idles near 0.5GiB and the OOM killer takes terminals, not tabs`,
|
|
342
|
+
"sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile",
|
|
343
|
+
),
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return checks;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Hermes, and whether its browser tools land anywhere useful.
|
|
352
|
+
*
|
|
353
|
+
* Three questions, and the second two only make sense once the first is yes.
|
|
354
|
+
* None of them is fatal: a box that never installs Hermes is a working box, the
|
|
355
|
+
* same way one without Claude is.
|
|
356
|
+
*/
|
|
357
|
+
async function hermesChecks(): Promise<Check[]> {
|
|
358
|
+
// Cheap when it is missing — this returns as soon as the binary is not found,
|
|
359
|
+
// which is the answer on most boxes.
|
|
360
|
+
const hermes = await discoverHermes();
|
|
361
|
+
if (!hermes.available) {
|
|
362
|
+
return [
|
|
363
|
+
bad(
|
|
364
|
+
"hermes",
|
|
365
|
+
"not on PATH — profiles using the Hermes harness fall back to a shell",
|
|
366
|
+
HERMES_INSTALL,
|
|
367
|
+
),
|
|
368
|
+
];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const checks: Check[] = [
|
|
372
|
+
ok(
|
|
373
|
+
"hermes",
|
|
374
|
+
`${hermes.version ?? "installed"} — the Hermes harness will run`,
|
|
375
|
+
),
|
|
376
|
+
];
|
|
377
|
+
|
|
378
|
+
// Without the CLI, Browser Use mode silently does not engage. Hermes keeps
|
|
379
|
+
// working with its twelve built-in browser tools, so nothing looks wrong;
|
|
380
|
+
// you just pay for a dozen tool schemas in every request and never find out.
|
|
381
|
+
checks.push(
|
|
382
|
+
hermes.browser.browserUse
|
|
383
|
+
? ok("browser-use", "runnable — Hermes gets the single browser_exec tool")
|
|
384
|
+
: bad(
|
|
385
|
+
"browser-use",
|
|
386
|
+
"no browser-use or uvx on PATH — Hermes keeps its twelve built-in browser tools",
|
|
387
|
+
UV_INSTALL,
|
|
388
|
+
),
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
// Only worth asking when there is a browser here to be pointed at. On a box
|
|
392
|
+
// with no `install-browser`, an unset cdp_url is the correct configuration.
|
|
393
|
+
if (hermes.browser.cdpPort !== null) {
|
|
394
|
+
checks.push(
|
|
395
|
+
hermes.browser.connected
|
|
396
|
+
? ok(
|
|
397
|
+
"hermes browser",
|
|
398
|
+
`driving this box's Chrome on 127.0.0.1:${hermes.browser.cdpPort}`,
|
|
399
|
+
)
|
|
400
|
+
: bad(
|
|
401
|
+
"hermes browser",
|
|
402
|
+
hermes.browser.cdpUrl
|
|
403
|
+
? `browser.cdp_url is ${hermes.browser.cdpUrl}, not this box's Chrome on ${hermes.browser.cdpPort}`
|
|
404
|
+
: "browser.cdp_url is unset — Hermes will not use the browser running here",
|
|
405
|
+
"vibe-os connect-hermes",
|
|
406
|
+
),
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return checks;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Cursor, and whether a window opened as it would get past the login prompt.
|
|
415
|
+
*
|
|
416
|
+
* Two questions. Neither is fatal, for the reason the Hermes checks give: a box
|
|
417
|
+
* that never installs Cursor is a working box. The login check exists because
|
|
418
|
+
* its failure is quiet in exactly the way this file cares about — the window
|
|
419
|
+
* opens, the harness runs, and it sits asking for a browser login on a box that
|
|
420
|
+
* may not have one attached.
|
|
421
|
+
*/
|
|
422
|
+
async function cursorChecks(): Promise<Check[]> {
|
|
423
|
+
const cursor = await discoverCursor();
|
|
424
|
+
if (!cursor.available) {
|
|
425
|
+
return [
|
|
426
|
+
bad(
|
|
427
|
+
"cursor",
|
|
428
|
+
"not on PATH — profiles using the Cursor harness fall back to a shell",
|
|
429
|
+
CURSOR_INSTALL,
|
|
430
|
+
),
|
|
431
|
+
];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const checks: Check[] = [
|
|
435
|
+
ok(
|
|
436
|
+
"cursor",
|
|
437
|
+
`${cursor.version ?? "installed"} — the Cursor harness will run`,
|
|
438
|
+
),
|
|
439
|
+
];
|
|
440
|
+
|
|
441
|
+
if (cursor.loggedIn === false) {
|
|
442
|
+
checks.push(
|
|
443
|
+
bad(
|
|
444
|
+
"cursor login",
|
|
445
|
+
"not logged in — Cursor windows will sit at a login prompt",
|
|
446
|
+
"cursor-agent login",
|
|
447
|
+
),
|
|
448
|
+
);
|
|
449
|
+
} else if (cursor.loggedIn === true) {
|
|
450
|
+
checks.push(
|
|
451
|
+
ok(
|
|
452
|
+
"cursor login",
|
|
453
|
+
`logged in${cursor.models.length > 0 ? `, ${cursor.models.length} models offered` : ""}`,
|
|
454
|
+
),
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
// null stays silent: status output this build cannot read is not a finding.
|
|
458
|
+
|
|
459
|
+
return checks;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** The home directory sshd will use for a user, which need not be ours. */
|
|
463
|
+
export async function homeFor(user: string): Promise<string | null> {
|
|
464
|
+
if (user === os.userInfo().username) return os.homedir();
|
|
465
|
+
try {
|
|
466
|
+
const { stdout } = await run("getent", ["passwd", user], {
|
|
467
|
+
timeout: 5_000,
|
|
468
|
+
});
|
|
469
|
+
const home = stdout.split("\n")[0]?.split(":")[5];
|
|
470
|
+
return home && home.length > 0 ? home : null;
|
|
471
|
+
} catch {
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* sshd's effective configuration, evaluated for the user who will log in.
|
|
478
|
+
*
|
|
479
|
+
* `sshd -T` is the only honest source: it applies Include directives, drop-in
|
|
480
|
+
* files and Match blocks, which reading sshd_config by hand does not. It needs
|
|
481
|
+
* root, so a non-root run degrades to "unknown" rather than guessing.
|
|
482
|
+
*/
|
|
483
|
+
async function sshdEffectiveConfig(
|
|
484
|
+
user: string,
|
|
485
|
+
): Promise<Map<string, string> | null> {
|
|
486
|
+
const binaries = ["sshd", "/usr/sbin/sshd", "/usr/local/sbin/sshd"];
|
|
487
|
+
for (const bin of binaries) {
|
|
488
|
+
try {
|
|
489
|
+
const { stdout } = await run(
|
|
490
|
+
bin,
|
|
491
|
+
["-T", "-C", `user=${user},host=localhost,addr=127.0.0.1`],
|
|
492
|
+
{ timeout: 10_000, maxBuffer: 4 * 1024 * 1024 },
|
|
493
|
+
);
|
|
494
|
+
const map = new Map<string, string>();
|
|
495
|
+
for (const line of stdout.split("\n")) {
|
|
496
|
+
const trimmed = line.trim();
|
|
497
|
+
if (!trimmed) continue;
|
|
498
|
+
const space = trimmed.indexOf(" ");
|
|
499
|
+
// sshd -T lowercases keywords and prints repeated ones on their own
|
|
500
|
+
// lines; joining preserves multi-value keywords like allowusers.
|
|
501
|
+
const key = space === -1 ? trimmed : trimmed.slice(0, space);
|
|
502
|
+
const value = space === -1 ? "" : trimmed.slice(space + 1);
|
|
503
|
+
map.set(key, map.has(key) ? `${map.get(key)} ${value}` : value);
|
|
504
|
+
}
|
|
505
|
+
return map;
|
|
506
|
+
} catch {
|
|
507
|
+
// not this path, or not root — try the next
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Expands sshd's AuthorizedKeysFile tokens into real paths.
|
|
515
|
+
*
|
|
516
|
+
* `%%` is an escaped percent, so it has to stop what follows being read as a
|
|
517
|
+
* token: `%%h` is the literal `%h`, not the home directory. Parking it on a
|
|
518
|
+
* character the input cannot contain, expanding the real tokens, then putting
|
|
519
|
+
* the percent back is what keeps those two apart in a single pass.
|
|
520
|
+
*
|
|
521
|
+
* The sentinel is spelled `\u0000` rather than written as a raw NUL byte. Same
|
|
522
|
+
* value, but a NUL in the source makes the entire file binary to grep, ripgrep
|
|
523
|
+
* and diffs, which silently breaks searching it.
|
|
524
|
+
*/
|
|
525
|
+
export function authorizedKeysPaths(
|
|
526
|
+
spec: string,
|
|
527
|
+
home: string,
|
|
528
|
+
user: string,
|
|
529
|
+
): string[] {
|
|
530
|
+
return spec
|
|
531
|
+
.split(/\s+/)
|
|
532
|
+
.filter(Boolean)
|
|
533
|
+
.map((entry) =>
|
|
534
|
+
entry
|
|
535
|
+
.replaceAll("%%", "\u0000")
|
|
536
|
+
.replaceAll("%h", home)
|
|
537
|
+
.replaceAll("%u", user)
|
|
538
|
+
.replaceAll("\u0000", "%"),
|
|
539
|
+
)
|
|
540
|
+
.map((entry) => (path.isAbsolute(entry) ? entry : path.join(home, entry)));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* StrictModes: sshd ignores authorized_keys outright if the path to it is
|
|
545
|
+
* writable by anyone but its owner. It logs why; nothing else tells you.
|
|
546
|
+
*/
|
|
547
|
+
async function badPermissions(home: string): Promise<string[]> {
|
|
548
|
+
const problems: string[] = [];
|
|
549
|
+
const targets = [
|
|
550
|
+
home,
|
|
551
|
+
path.join(home, ".ssh"),
|
|
552
|
+
path.join(home, ".ssh", "authorized_keys"),
|
|
553
|
+
];
|
|
554
|
+
for (const target of targets) {
|
|
555
|
+
try {
|
|
556
|
+
const info = await stat(target);
|
|
557
|
+
if ((info.mode & 0o022) !== 0) {
|
|
558
|
+
problems.push(
|
|
559
|
+
`${target} is ${(info.mode & 0o777).toString(8)} (group/other writable)`,
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
} catch {
|
|
563
|
+
// absent is not a permission problem; other checks cover it
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return problems;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** A tailnet address on this host, if Tailscale is up. */
|
|
570
|
+
async function tailscaleAddress(): Promise<string | null> {
|
|
571
|
+
for (const iface of Object.values(os.networkInterfaces())) {
|
|
572
|
+
for (const info of iface ?? []) {
|
|
573
|
+
// The CGNAT range Tailscale assigns from: 100.64.0.0/10.
|
|
574
|
+
if (
|
|
575
|
+
info.family === "IPv4" &&
|
|
576
|
+
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(info.address)
|
|
577
|
+
) {
|
|
578
|
+
return info.address;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export async function runDoctor(config: Config): Promise<Check[]> {
|
|
586
|
+
const checks: Check[] = [];
|
|
587
|
+
const user = config.user;
|
|
588
|
+
|
|
589
|
+
// ── the toolchain ────────────────────────────────────────────────────────
|
|
590
|
+
const bunMajor = Number((Bun.version ?? "0").split(".")[0]);
|
|
591
|
+
checks.push(
|
|
592
|
+
bunMajor >= 1
|
|
593
|
+
? ok("bun", `v${Bun.version}`)
|
|
594
|
+
: fatal("bun", "vibe-os runs on Bun 1+"),
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
for (const [command, label, why, fix] of [
|
|
598
|
+
[
|
|
599
|
+
"ssh-keygen",
|
|
600
|
+
"ssh-keygen",
|
|
601
|
+
"signs every certificate",
|
|
602
|
+
"apt install openssh-client",
|
|
603
|
+
],
|
|
604
|
+
[
|
|
605
|
+
"ssh-keyscan",
|
|
606
|
+
"ssh-keyscan",
|
|
607
|
+
"discovers the host key to pin",
|
|
608
|
+
"apt install openssh-client",
|
|
609
|
+
],
|
|
610
|
+
["git", "git", "projects and worktrees", "apt install git"],
|
|
611
|
+
] as const) {
|
|
612
|
+
const found = await onPath(command);
|
|
613
|
+
if (command === "ssh-keygen") {
|
|
614
|
+
checks.push(
|
|
615
|
+
found ? ok(label, why) : fatal(label, `not on PATH — ${why}`, fix),
|
|
616
|
+
);
|
|
617
|
+
} else {
|
|
618
|
+
checks.push(
|
|
619
|
+
found ? ok(label, why) : bad(label, `not on PATH — ${why}`, fix),
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
checks.push(
|
|
625
|
+
config.sessions
|
|
626
|
+
? ok(
|
|
627
|
+
"dtach",
|
|
628
|
+
"found — sessions survive reloads, and profiles can launch a harness",
|
|
629
|
+
)
|
|
630
|
+
: bad(
|
|
631
|
+
"dtach",
|
|
632
|
+
"not installed — windows become plain shells and profiles launch nothing at all",
|
|
633
|
+
"apt install dtach",
|
|
634
|
+
),
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
const claude = await onPath("claude");
|
|
638
|
+
checks.push(
|
|
639
|
+
claude
|
|
640
|
+
? ok("claude", "on PATH — the Claude harness will run")
|
|
641
|
+
: bad(
|
|
642
|
+
"claude",
|
|
643
|
+
"not on PATH — profiles using the Claude harness fall back to a shell",
|
|
644
|
+
"curl -fsSL https://claude.ai/install.sh | bash",
|
|
645
|
+
),
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
checks.push(...(await hermesChecks()));
|
|
649
|
+
checks.push(...(await cursorChecks()));
|
|
650
|
+
|
|
651
|
+
// ── can a browser actually log in? ───────────────────────────────────────
|
|
652
|
+
const sshdUp = await probeTcp(config.sshHost, config.sshPort);
|
|
653
|
+
checks.push(
|
|
654
|
+
sshdUp
|
|
655
|
+
? ok("sshd", `reachable at ${config.sshHost}:${config.sshPort}`)
|
|
656
|
+
: fatal(
|
|
657
|
+
"sshd",
|
|
658
|
+
`nothing listening on ${config.sshHost}:${config.sshPort} — no window will connect`,
|
|
659
|
+
"systemctl enable --now ssh",
|
|
660
|
+
),
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
const home = await homeFor(user);
|
|
664
|
+
if (!home) {
|
|
665
|
+
checks.push(
|
|
666
|
+
fatal(
|
|
667
|
+
"login user",
|
|
668
|
+
`no such user: ${user}`,
|
|
669
|
+
"pass --user <name> for an account that exists",
|
|
670
|
+
),
|
|
671
|
+
);
|
|
672
|
+
} else {
|
|
673
|
+
const mine = os.homedir();
|
|
674
|
+
checks.push(
|
|
675
|
+
home === mine
|
|
676
|
+
? ok("login user", `${user} (${home})`)
|
|
677
|
+
: bad(
|
|
678
|
+
"login user",
|
|
679
|
+
`windows log in as ${user} (${home}), but vibe-os is running as ` +
|
|
680
|
+
`${os.userInfo().username} (${mine}) and writes its CA line there`,
|
|
681
|
+
`run vibe-os as ${user}, or drop the --user flag`,
|
|
682
|
+
),
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const sshd = await sshdEffectiveConfig(user);
|
|
687
|
+
if (!sshd) {
|
|
688
|
+
checks.push(
|
|
689
|
+
bad(
|
|
690
|
+
"sshd config",
|
|
691
|
+
"could not read sshd's effective config — the checks below are the defaults, not the truth",
|
|
692
|
+
"sudo vibe-os doctor",
|
|
693
|
+
),
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (sshd) {
|
|
698
|
+
const pubkey = sshd.get("pubkeyauthentication") ?? "yes";
|
|
699
|
+
checks.push(
|
|
700
|
+
pubkey === "yes"
|
|
701
|
+
? ok("pubkey auth", "enabled")
|
|
702
|
+
: fatal(
|
|
703
|
+
"pubkey auth",
|
|
704
|
+
"PubkeyAuthentication is off — certificates cannot be used",
|
|
705
|
+
"set PubkeyAuthentication yes",
|
|
706
|
+
),
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
// Allow/Deny lists are the quiet way a hardened VPS refuses the very user
|
|
710
|
+
// vibe-os is about to hand out certificates for.
|
|
711
|
+
const allowUsers = sshd.get("allowusers");
|
|
712
|
+
const denyUsers = sshd.get("denyusers");
|
|
713
|
+
const listed = (spec: string | undefined) =>
|
|
714
|
+
spec?.split(/\s+/).filter(Boolean) ?? [];
|
|
715
|
+
if (denyUsers && listed(denyUsers).some((pattern) => pattern === user)) {
|
|
716
|
+
checks.push(
|
|
717
|
+
fatal(
|
|
718
|
+
"sshd access",
|
|
719
|
+
`DenyUsers excludes ${user}`,
|
|
720
|
+
`remove ${user} from DenyUsers`,
|
|
721
|
+
),
|
|
722
|
+
);
|
|
723
|
+
} else if (
|
|
724
|
+
allowUsers &&
|
|
725
|
+
!listed(allowUsers).some(
|
|
726
|
+
(pattern) => pattern === user || pattern.includes("*"),
|
|
727
|
+
)
|
|
728
|
+
) {
|
|
729
|
+
checks.push(
|
|
730
|
+
fatal(
|
|
731
|
+
"sshd access",
|
|
732
|
+
`AllowUsers does not include ${user}`,
|
|
733
|
+
`add ${user} to AllowUsers in sshd_config`,
|
|
734
|
+
),
|
|
735
|
+
);
|
|
736
|
+
} else {
|
|
737
|
+
checks.push(ok("sshd access", `${user} is permitted to log in`));
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ── is the CA trusted in the file sshd will actually read? ───────────────
|
|
742
|
+
const ca = new SshCa(config.stateDir);
|
|
743
|
+
let caBlob = "";
|
|
744
|
+
try {
|
|
745
|
+
await stat(ca.pubPath);
|
|
746
|
+
await ca.ensure();
|
|
747
|
+
caBlob = ca.publicKey.split(/\s+/)[1] ?? "";
|
|
748
|
+
} catch {
|
|
749
|
+
// not created yet
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (!caBlob) {
|
|
753
|
+
checks.push(ok("ssh CA", "not created yet — the first start writes it"));
|
|
754
|
+
} else if (home) {
|
|
755
|
+
const spec =
|
|
756
|
+
sshd?.get("authorizedkeysfile") ??
|
|
757
|
+
".ssh/authorized_keys .ssh/authorized_keys2";
|
|
758
|
+
const candidates = authorizedKeysPaths(spec, home, user);
|
|
759
|
+
let foundIn: string | null = null;
|
|
760
|
+
for (const file of candidates) {
|
|
761
|
+
const contents = await readFile(file, "utf8").catch(() => "");
|
|
762
|
+
if (contents.includes(caBlob)) {
|
|
763
|
+
foundIn = file;
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
checks.push(
|
|
768
|
+
foundIn
|
|
769
|
+
? ok("ssh CA", `trusted in ${foundIn}`)
|
|
770
|
+
: fatal(
|
|
771
|
+
"ssh CA",
|
|
772
|
+
`the CA line is not in any file sshd reads for ${user} (${candidates.join(", ")})`,
|
|
773
|
+
`append this to ${candidates[0]}: cert-authority ${ca.publicKey}`,
|
|
774
|
+
),
|
|
775
|
+
);
|
|
776
|
+
|
|
777
|
+
if (sshd?.get("strictmodes") !== "no") {
|
|
778
|
+
const problems = await badPermissions(home);
|
|
779
|
+
checks.push(
|
|
780
|
+
problems.length === 0
|
|
781
|
+
? ok("key file perms", "StrictModes satisfied")
|
|
782
|
+
: fatal(
|
|
783
|
+
"key file perms",
|
|
784
|
+
`sshd will ignore authorized_keys: ${problems.join("; ")}`,
|
|
785
|
+
`chmod go-w ${home} ${home}/.ssh ${home}/.ssh/authorized_keys`,
|
|
786
|
+
),
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const hostKey = await discoverHostKey(config.sshHost, config.sshPort);
|
|
792
|
+
checks.push(
|
|
793
|
+
hostKey
|
|
794
|
+
? ok(
|
|
795
|
+
"host key",
|
|
796
|
+
`${hostKey.split(" ")[0]} discovered — pinned, no trust prompt`,
|
|
797
|
+
)
|
|
798
|
+
: bad(
|
|
799
|
+
"host key",
|
|
800
|
+
"not discoverable — the browser will prompt once on first connect",
|
|
801
|
+
),
|
|
802
|
+
);
|
|
803
|
+
|
|
804
|
+
// ── serving ──────────────────────────────────────────────────────────────
|
|
805
|
+
// A compiled binary carries the web assets inside it — there is no directory
|
|
806
|
+
// to check, and checking one reports a fatal error on a perfectly good
|
|
807
|
+
// install. This is the recommended way to run on a VPS, so getting it wrong
|
|
808
|
+
// here would fail exactly the people this command exists for.
|
|
809
|
+
if (IS_COMPILED) {
|
|
810
|
+
checks.push(ok("web build", "embedded in this binary"));
|
|
811
|
+
} else {
|
|
812
|
+
const built = await stat(path.join(config.webRoot, "index.html")).then(
|
|
813
|
+
() => true,
|
|
814
|
+
() => false,
|
|
815
|
+
);
|
|
816
|
+
checks.push(
|
|
817
|
+
built
|
|
818
|
+
? ok("web build", config.webRoot)
|
|
819
|
+
: fatal("web build", `missing at ${config.webRoot}`, "bun run build"),
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const bind = await bindStatus(config.port, config.host);
|
|
824
|
+
if (bind === "free") {
|
|
825
|
+
checks.push(ok(`port ${config.port}`, `bindable on ${config.host}`));
|
|
826
|
+
} else if (bind === "in-use") {
|
|
827
|
+
checks.push(
|
|
828
|
+
ok(`port ${config.port}`, "already in use — vibe-os is probably running"),
|
|
829
|
+
);
|
|
830
|
+
} else {
|
|
831
|
+
checks.push(
|
|
832
|
+
bad(
|
|
833
|
+
`port ${config.port}`,
|
|
834
|
+
`permission denied binding ${config.host}:${config.port}`,
|
|
835
|
+
config.port < 1024
|
|
836
|
+
? "use a high port with Tailscale in front, or: sudo setcap 'cap_net_bind_service=+ep' $(readlink -f \"$(which bun)\")"
|
|
837
|
+
: "check what else holds this port",
|
|
838
|
+
),
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// ── firewall ─────────────────────────────────────────────────────────────
|
|
843
|
+
//
|
|
844
|
+
// Reads the host firewall's configuration. Deliberately a warning and never
|
|
845
|
+
// fatal: a box on a trusted LAN or inside the container legitimately has no
|
|
846
|
+
// firewall, and doctor exits non-zero on fatals.
|
|
847
|
+
const fw = await firewallState();
|
|
848
|
+
// Said on every outcome, including the good one, because a ✓ here is the
|
|
849
|
+
// moment someone is most likely to conclude more than it means.
|
|
850
|
+
const scope =
|
|
851
|
+
" (local rules only: your provider's firewall is separate, and proving a port is shut needs a packet from outside)";
|
|
852
|
+
|
|
853
|
+
if (fw.unreadable) {
|
|
854
|
+
checks.push(
|
|
855
|
+
bad(
|
|
856
|
+
"firewall",
|
|
857
|
+
"could not read any firewall — needs root, or none is installed",
|
|
858
|
+
"sudo vibe-os doctor (and see the firewall rules in the README)",
|
|
859
|
+
),
|
|
860
|
+
);
|
|
861
|
+
} else if (!fw.active) {
|
|
862
|
+
checks.push(
|
|
863
|
+
bad(
|
|
864
|
+
"firewall",
|
|
865
|
+
`${fw.tool} found but not active — this machine answers on its public address`,
|
|
866
|
+
"sudo ufw allow in on tailscale0 && sudo ufw allow 41641/udp && sudo ufw default deny incoming && sudo ufw enable",
|
|
867
|
+
),
|
|
868
|
+
);
|
|
869
|
+
} else if (!fw.defaultDeny) {
|
|
870
|
+
checks.push(
|
|
871
|
+
bad(
|
|
872
|
+
"firewall",
|
|
873
|
+
`${fw.tool} is active but does not deny incoming by default`,
|
|
874
|
+
"sudo ufw default deny incoming",
|
|
875
|
+
),
|
|
876
|
+
);
|
|
877
|
+
} else {
|
|
878
|
+
const missing = [
|
|
879
|
+
fw.tailnetAllowed ? null : "no allow rule for the tailscale interface",
|
|
880
|
+
fw.directUdpAllowed
|
|
881
|
+
? null
|
|
882
|
+
: "41641/udp closed, so Tailscale relays via DERP",
|
|
883
|
+
].filter(Boolean);
|
|
884
|
+
checks.push(
|
|
885
|
+
missing.length === 0
|
|
886
|
+
? ok(
|
|
887
|
+
"firewall",
|
|
888
|
+
`${fw.tool}: incoming denied by default, tailnet and 41641/udp allowed${scope}`,
|
|
889
|
+
)
|
|
890
|
+
: bad(
|
|
891
|
+
"firewall",
|
|
892
|
+
`${fw.tool} denies incoming by default, but ${missing.join("; ")}`,
|
|
893
|
+
"sudo ufw allow in on tailscale0 && sudo ufw allow 41641/udp",
|
|
894
|
+
),
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// ── the always-on browser ────────────────────────────────────────────────
|
|
899
|
+
//
|
|
900
|
+
// Only reported when there is something to report. vibe-os is complete
|
|
901
|
+
// without a browser on the box, and a machine that never asked for one should
|
|
902
|
+
// not be told about four things it does not have.
|
|
903
|
+
checks.push(...(await browserChecks()));
|
|
904
|
+
|
|
905
|
+
// ── leaked processes ─────────────────────────────────────────────────────
|
|
906
|
+
//
|
|
907
|
+
// Processes that outlived their workspace or session: a cwd that is a
|
|
908
|
+
// deleted path under `.vibe-worktrees/`, or a tie to a session socket that
|
|
909
|
+
// no longer exists. Teardown is supposed to make these impossible; this is
|
|
910
|
+
// how the times it did not (#38, #39) get found without an afternoon of ps.
|
|
911
|
+
const leaks = await findLeaks(config);
|
|
912
|
+
if (leaks.length === 0) {
|
|
913
|
+
checks.push(
|
|
914
|
+
ok("leaks", "no process has outlived its workspace or session"),
|
|
915
|
+
);
|
|
916
|
+
} else {
|
|
917
|
+
const shown = leaks
|
|
918
|
+
.slice(0, 4)
|
|
919
|
+
.map((l) => `${l.pid} (${l.args.slice(0, 40)})`)
|
|
920
|
+
.join(", ");
|
|
921
|
+
const more = leaks.length > 4 ? `, and ${leaks.length - 4} more` : "";
|
|
922
|
+
checks.push(
|
|
923
|
+
bad(
|
|
924
|
+
"leaks",
|
|
925
|
+
`${leaks.length} process${leaks.length === 1 ? " has" : "es have"} outlived their workspace or session: ${shown}${more}`,
|
|
926
|
+
"vibe-os doctor --reap",
|
|
927
|
+
),
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// ── exposure ─────────────────────────────────────────────────────────────
|
|
932
|
+
//
|
|
933
|
+
// The one check that is about the box rather than the software. vibe-os hands
|
|
934
|
+
// out shells; who can reach the port is the whole security model.
|
|
935
|
+
const tailnet = await tailscaleAddress();
|
|
936
|
+
const wildcard = config.host === "0.0.0.0" || config.host === "::";
|
|
937
|
+
|
|
938
|
+
if (config.token) {
|
|
939
|
+
checks.push(
|
|
940
|
+
ok(
|
|
941
|
+
"exposure",
|
|
942
|
+
`token required${tailnet ? `, and Tailscale is up (${tailnet})` : ""}`,
|
|
943
|
+
),
|
|
944
|
+
);
|
|
945
|
+
} else if (!wildcard) {
|
|
946
|
+
checks.push(ok("exposure", `no token, but bound only to ${config.host}`));
|
|
947
|
+
} else if (tailnet) {
|
|
948
|
+
checks.push(
|
|
949
|
+
bad(
|
|
950
|
+
"exposure",
|
|
951
|
+
`no token and bound to every interface — Tailscale is up (${tailnet}), but so is any public address`,
|
|
952
|
+
`bind to the tailnet only: --host ${tailnet} (and see the firewall rules in the README)`,
|
|
953
|
+
),
|
|
954
|
+
);
|
|
955
|
+
} else {
|
|
956
|
+
checks.push(
|
|
957
|
+
bad(
|
|
958
|
+
"exposure",
|
|
959
|
+
"no token, bound to every interface, and no tailnet found — anyone who can reach this port gets a shell",
|
|
960
|
+
"start with --token, or put it behind Tailscale and bind to the tailnet address",
|
|
961
|
+
),
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
return checks;
|
|
966
|
+
}
|