@tsconfigdotjson/vibe-os 0.0.0-stage → 0.2.0-rc.1

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 (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +988 -2
  3. package/THIRD_PARTY_NOTICES.md +2467 -0
  4. package/bin/vibe-os.mjs +13 -0
  5. package/dist/web/apple-touch-icon.png +0 -0
  6. package/dist/web/assets/index-B77Qk8B5.js +35 -0
  7. package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
  8. package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
  9. package/dist/web/assets/index-CH5Em-Mo.css +1 -0
  10. package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
  11. package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
  12. package/dist/web/icon-192.png +0 -0
  13. package/dist/web/icon-512.png +0 -0
  14. package/dist/web/icon-app.svg +19 -0
  15. package/dist/web/icon-maskable-512.png +0 -0
  16. package/dist/web/icon-maskable.svg +13 -0
  17. package/dist/web/icon.svg +15 -0
  18. package/dist/web/index.html +36 -0
  19. package/dist/web/ssh.wasm +0 -0
  20. package/dist/web/ssh.wasm.br +0 -0
  21. package/dist/web/ssh.wasm.gz +0 -0
  22. package/dist/web/sw.js +29 -0
  23. package/dist/web/wasm_exec.js +575 -0
  24. package/dist/web/wasm_exec.js.br +0 -0
  25. package/dist/web/wasm_exec.js.gz +0 -0
  26. package/package.json +82 -4
  27. package/scripts/compile.ts +62 -0
  28. package/scripts/compress.mjs +54 -0
  29. package/scripts/fetch-wasm.mjs +242 -0
  30. package/scripts/gen-assets.ts +76 -0
  31. package/scripts/lib/walk.ts +18 -0
  32. package/scripts/notices.ts +189 -0
  33. package/server/acme.ts +182 -0
  34. package/server/api.ts +515 -0
  35. package/server/assets.generated.ts +53 -0
  36. package/server/attach.ts +524 -0
  37. package/server/auth.ts +194 -0
  38. package/server/bridge.ts +145 -0
  39. package/server/browser.ts +519 -0
  40. package/server/cli.ts +857 -0
  41. package/server/config.ts +385 -0
  42. package/server/db.ts +262 -0
  43. package/server/doctor.ts +977 -0
  44. package/server/fsx.ts +88 -0
  45. package/server/harness.ts +655 -0
  46. package/server/http.ts +53 -0
  47. package/server/index.ts +469 -0
  48. package/server/log.ts +40 -0
  49. package/server/main.ts +7 -0
  50. package/server/mcp.ts +291 -0
  51. package/server/preflight.ts +87 -0
  52. package/server/profiles.ts +238 -0
  53. package/server/projects.ts +721 -0
  54. package/server/reaper.ts +303 -0
  55. package/server/runtime.ts +64 -0
  56. package/server/session.ts +330 -0
  57. package/server/setup.ts +1151 -0
  58. package/server/ssh-ca.ts +368 -0
  59. package/server/static.ts +147 -0
  60. package/server/wallpapers.ts +248 -0
  61. package/server/windows.ts +237 -0
  62. package/shared/args.ts +91 -0
  63. package/shared/grid.ts +32 -0
  64. package/shared/wire.ts +226 -0
@@ -0,0 +1,303 @@
1
+ // An owner for every process vibe-os starts, and a broom for the ones that
2
+ // slip loose anyway.
3
+ //
4
+ // Three leaks in two days on the VPS (#38, #39) shared one cause: every
5
+ // teardown path reached exactly one pid by matching its argv, and anything
6
+ // below or beside that pid survived — reparented to init, unreachable by name,
7
+ // running for a week in a worktree that no longer existed. The rules this
8
+ // module exists to hold (#40):
9
+ //
10
+ // 1. A session survives disconnect. That is the point of dtach.
11
+ // 2. A client must not survive its terminal.
12
+ // 3. Nothing survives its workspace.
13
+ //
14
+ // The mechanism for 3 is not, as first proposed, a process group. dtach's
15
+ // master creates the pty with forkpty, and forkpty calls setsid in the child —
16
+ // so the harness is a *session* leader of its own kernel session, and the
17
+ // master's pgid never covers a single grandchild. What does cover them, all of
18
+ // them, is the environment: session.ts stamps `VIBE_OS_SOCK=<socket>` onto
19
+ // the create command, every descendant inherits it — across
20
+ // fork, exec, daemonisation, even a second setsid — and /proc/<pid>/environ
21
+ // lets it be read back later. That variable is the owner's name; this module
22
+ // is what reads it.
23
+ //
24
+ // The reaper below is the admission that the rules will still be broken
25
+ // sometimes: a server crash mid-teardown, a session created before the marker
26
+ // existed. Two cheap signals found every leak in the original batch by hand —
27
+ // a cwd that is a deleted path under `.vibe-worktrees/`, and a process tied to
28
+ // a session socket that no longer exists — so those are exactly what it looks
29
+ // for, on server start and in `vibe-os doctor`.
30
+
31
+ import { execFile } from "node:child_process";
32
+ import { existsSync } from "node:fs";
33
+ import { readFile, readlink } from "node:fs/promises";
34
+ import { promisify } from "node:util";
35
+ import type { Config } from "./config.ts";
36
+ import { log } from "./log.ts";
37
+
38
+ const run = promisify(execFile);
39
+
40
+ /** The environment variable that names a process's owning session socket. */
41
+ export const SOCK_VAR = "VIBE_OS_SOCK";
42
+
43
+ /**
44
+ * The directory component that marks a workspace worktree.
45
+ *
46
+ * projects.ts owns the real `WORKTREE_DIR` constant. It is repeated here
47
+ * rather than imported because projects.ts imports attach.ts, which imports
48
+ * this file — and if the two ever drift, the reaper misses a class of leak
49
+ * rather than killing anything wrong.
50
+ */
51
+ const WORKTREE_MARK = "/.vibe-worktrees/";
52
+
53
+ /**
54
+ * How old a process must be before a missing socket counts against it.
55
+ *
56
+ * The create path in session.ts is `rm -f <sock>; dtach -n <sock> …`, so a
57
+ * session being born is briefly a dtach whose socket does not exist yet. dtach
58
+ * binds within milliseconds; a minute is three orders of magnitude of slack.
59
+ * The deleted-cwd signal needs no such grace — teardown kills before it
60
+ * removes the worktree, so a live process in a deleted worktree is
61
+ * definitionally a leak.
62
+ */
63
+ const MIN_AGE_S = 60;
64
+
65
+ /** One process, as much of it as this platform lets us see. */
66
+ export interface ProcInfo {
67
+ pid: number;
68
+ /** Seconds since it started. */
69
+ age: number;
70
+ /** Full argv as one line, as ps prints it. */
71
+ args: string;
72
+ /** readlink of its cwd, ` (deleted)` suffix intact. Linux only. */
73
+ cwd: string | null;
74
+ /** The `VIBE_OS_SOCK` it inherited, if any. Linux only. */
75
+ sock: string | null;
76
+ }
77
+
78
+ export interface Leak {
79
+ pid: number;
80
+ reason: string;
81
+ args: string;
82
+ }
83
+
84
+ /** Pulls the session marker out of a NUL-delimited /proc environ blob. */
85
+ export function markerFromEnviron(environ: string): string | null {
86
+ for (const entry of environ.split("\0")) {
87
+ if (entry.startsWith(`${SOCK_VAR}=`))
88
+ return entry.slice(SOCK_VAR.length + 1) || null;
89
+ }
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * Seconds from a `ps -o etime` value: `[[dd-]hh:]mm:ss`.
95
+ *
96
+ * `etimes` (plain seconds) would skip the parsing but is procps-only; `etime`
97
+ * is in POSIX ps and prints the same way on Linux and macOS. A bare number is
98
+ * accepted too, so a caller with seconds in hand loses nothing.
99
+ */
100
+ export function parseEtime(value: string): number {
101
+ const m = value.trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/);
102
+ if (!m) return Number(value.trim()) || 0;
103
+ const [, d, h, min, s] = m;
104
+ return (
105
+ (Number(d ?? 0) * 24 + Number(h ?? 0)) * 3600 + Number(min) * 60 + Number(s)
106
+ );
107
+ }
108
+
109
+ async function pgrep(args: string[]): Promise<number[]> {
110
+ try {
111
+ const { stdout } = await run("pgrep", args, { timeout: 10_000 });
112
+ return stdout
113
+ .split("\n")
114
+ .map((line) => Number(line.trim()))
115
+ .filter((pid) => Number.isInteger(pid) && pid > 0);
116
+ } catch {
117
+ // pgrep exits non-zero when nothing matches.
118
+ return [];
119
+ }
120
+ }
121
+
122
+ /** Every process on the box, one ps call plus /proc reads where /proc exists. */
123
+ export async function scanProcesses(): Promise<ProcInfo[]> {
124
+ const { stdout } = await run("ps", ["-A", "-o", "pid=,etime=,args="], {
125
+ timeout: 10_000,
126
+ maxBuffer: 8 * 1024 * 1024,
127
+ }).catch(() => ({ stdout: "" }));
128
+
129
+ const rows: Array<Omit<ProcInfo, "cwd" | "sock">> = [];
130
+ for (const line of stdout.split("\n")) {
131
+ const m = line.match(/^\s*(\d+)\s+(\S+)\s+(.*)$/);
132
+ if (m) rows.push({ pid: Number(m[1]), age: parseEtime(m[2]), args: m[3] });
133
+ }
134
+
135
+ // Both reads fail for processes owned by other users; those are not ours to
136
+ // reap, so "could not see" and "not a leak" are the same answer.
137
+ const linux = process.platform === "linux";
138
+ return Promise.all(
139
+ rows.map(async (row) => ({
140
+ ...row,
141
+ cwd: linux
142
+ ? await readlink(`/proc/${row.pid}/cwd`).catch(() => null)
143
+ : null,
144
+ sock: linux
145
+ ? markerFromEnviron(
146
+ await readFile(`/proc/${row.pid}/environ`, "utf8").catch(() => ""),
147
+ )
148
+ : null,
149
+ })),
150
+ );
151
+ }
152
+
153
+ /**
154
+ * Is this process a leak? Pure, so the answer is testable without a /proc.
155
+ *
156
+ * Three signals, each sufficient on its own:
157
+ * - its cwd is a deleted path under `.vibe-worktrees/` — the workspace is
158
+ * gone, so rule 3 says the process should be too;
159
+ * - the `VIBE_OS_SOCK` it inherited names a socket that no longer exists;
160
+ * - it is a dtach whose argv names such a socket, which is the same fact for
161
+ * processes older than the marker.
162
+ */
163
+ export function classifyLeak(
164
+ p: ProcInfo,
165
+ opts: { sessionsDir: string; socketExists: (path: string) => boolean },
166
+ ): Leak | null {
167
+ if (p.cwd?.includes(WORKTREE_MARK) && p.cwd.endsWith(" (deleted)"))
168
+ return { pid: p.pid, args: p.args, reason: "its worktree was deleted" };
169
+
170
+ const dir = `${opts.sessionsDir}/`;
171
+ const gone = (sock: string) =>
172
+ sock.startsWith(dir) && p.age > MIN_AGE_S && !opts.socketExists(sock);
173
+
174
+ if (p.sock && gone(p.sock))
175
+ return { pid: p.pid, args: p.args, reason: "its session socket is gone" };
176
+
177
+ const dtach = p.args.match(/^dtach -[nap] (\S+\.sock)\b/);
178
+ if (dtach && gone(dtach[1]))
179
+ return { pid: p.pid, args: p.args, reason: "its session socket is gone" };
180
+
181
+ return null;
182
+ }
183
+
184
+ /**
185
+ * Finds the leaks, says what they were, ends them.
186
+ *
187
+ * The log line carries the argv because by the time anyone reads it the
188
+ * process is gone, and "reaped 444043" answers nothing.
189
+ */
190
+ export async function reapLeaks(config: Config): Promise<Leak[]> {
191
+ const leaks = await findLeaks(config);
192
+ for (const leak of leaks)
193
+ log.warn(`reaping ${leak.pid} (${leak.reason}): ${leak.args}`);
194
+ await terminate(leaks.map((leak) => leak.pid));
195
+ return leaks;
196
+ }
197
+
198
+ /** Every leaked process this platform can see, scoped to this state dir. */
199
+ export async function findLeaks(config: Config): Promise<Leak[]> {
200
+ const sessionsDir = `${config.stateDir}/sessions`;
201
+ const leaks: Leak[] = [];
202
+ for (const p of await scanProcesses()) {
203
+ if (p.pid <= 1 || p.pid === process.pid) continue;
204
+ const leak = classifyLeak(p, {
205
+ sessionsDir,
206
+ socketExists: (sock) => existsSync(sock),
207
+ });
208
+ if (leak) leaks.push(leak);
209
+ }
210
+ return leaks;
211
+ }
212
+
213
+ /**
214
+ * Every process belonging to a session, to the last grandchild.
215
+ *
216
+ * Three nets, because no single one reaches everything:
217
+ * - the dtach master itself, by argv — the only thing the old pkill reached;
218
+ * - everything sharing the pty's kernel session: forkpty makes dtach's child
219
+ * a session leader, so the harness and every job it started carry that sid
220
+ * (`ps -o sess` reads it back on Linux and macOS alike), including children
221
+ * already reparented to init;
222
+ * - everything that inherited the `VIBE_OS_SOCK` marker, which is the only
223
+ * net that still holds after a second setsid or after the master has died.
224
+ */
225
+ export async function sessionProcesses(sock: string): Promise<number[]> {
226
+ const pids = new Set<number>();
227
+
228
+ const masters = await pgrep(["-f", `^dtach -n ${sock}`]);
229
+ for (const pid of masters) pids.add(pid);
230
+
231
+ const leaders = new Set<number>();
232
+ for (const master of masters)
233
+ for (const child of await pgrep(["-P", String(master)])) leaders.add(child);
234
+
235
+ if (leaders.size > 0) {
236
+ const { stdout } = await run("ps", ["-A", "-o", "pid=,sess="], {
237
+ timeout: 10_000,
238
+ maxBuffer: 8 * 1024 * 1024,
239
+ }).catch(() => ({ stdout: "" }));
240
+ for (const line of stdout.split("\n")) {
241
+ const m = line.match(/^\s*(\d+)\s+(\d+)\s*$/);
242
+ if (m && leaders.has(Number(m[2]))) pids.add(Number(m[1]));
243
+ }
244
+ }
245
+
246
+ if (process.platform === "linux") {
247
+ for (const p of await scanProcesses()) if (p.sock === sock) pids.add(p.pid);
248
+ }
249
+
250
+ pids.delete(process.pid);
251
+ return [...pids];
252
+ }
253
+
254
+ const isAlive = (pid: number): boolean => {
255
+ try {
256
+ process.kill(pid, 0);
257
+ return true;
258
+ } catch (err) {
259
+ // EPERM is "alive but not ours" — SIGKILL would fail the same way, so
260
+ // there is nothing more to do with it, but it must not count as dead.
261
+ return (err as NodeJS.ErrnoException).code === "EPERM";
262
+ }
263
+ };
264
+
265
+ /**
266
+ * TERM, a short grace, then KILL for whatever ignored it.
267
+ *
268
+ * Returns the pids actually signalled. The grace is polled rather than slept
269
+ * through so the common case — everything honours TERM at once — costs one
270
+ * tick, not the whole allowance.
271
+ */
272
+ export async function terminate(
273
+ pids: number[],
274
+ graceMs = 2000,
275
+ ): Promise<number[]> {
276
+ const targets = [...new Set(pids)].filter(
277
+ (pid) => pid > 1 && pid !== process.pid,
278
+ );
279
+ const signalled: number[] = [];
280
+ for (const pid of targets) {
281
+ try {
282
+ process.kill(pid, "SIGTERM");
283
+ signalled.push(pid);
284
+ } catch {
285
+ // Already gone, or not ours to signal.
286
+ }
287
+ }
288
+
289
+ const deadline = Date.now() + graceMs;
290
+ let alive = signalled.filter(isAlive);
291
+ while (alive.length > 0 && Date.now() < deadline) {
292
+ await new Promise((resolve) => setTimeout(resolve, 100));
293
+ alive = alive.filter(isAlive);
294
+ }
295
+ for (const pid of alive) {
296
+ try {
297
+ process.kill(pid, "SIGKILL");
298
+ } catch {
299
+ // Died between the poll and now.
300
+ }
301
+ }
302
+ return signalled;
303
+ }
@@ -0,0 +1,64 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ /**
5
+ * Whether we are running inside a `bun build --compile` binary.
6
+ *
7
+ * Bun rewrites module paths to a virtual `/$bunfs/` root in standalone
8
+ * executables. That matters because a compiled binary carries the web assets
9
+ * inside it: there is no package directory to read from, nothing to fetch, and
10
+ * the preflight checks that make sense from a git checkout would all fail.
11
+ */
12
+ export const IS_COMPILED =
13
+ import.meta.url.includes("$bunfs") || import.meta.url.includes("B:/~BUN");
14
+
15
+ /**
16
+ * Arguments passed to the CLI, independent of how it was launched.
17
+ *
18
+ * Bun keeps argv the same shape in both modes — [runtime, entry, ...args] —
19
+ * where a compiled binary reports its entry as "/$bunfs/root/<name>". Verified
20
+ * rather than assumed: it is tempting to think a standalone executable drops
21
+ * the entry slot, and slicing 1 there silently turns the entry path into the
22
+ * subcommand.
23
+ */
24
+ export function cliArgs(): string[] {
25
+ return process.argv.slice(2);
26
+ }
27
+
28
+ /**
29
+ * The package root — one directory above `server/`.
30
+ *
31
+ * Both `cli.ts` and `preflight.ts` derived this the same way from their own
32
+ * `import.meta.url`, which works only because both happen to live one level
33
+ * down. Deriving it here makes that assumption a single fact.
34
+ */
35
+ export const PKG_ROOT = path.resolve(
36
+ path.dirname(fileURLToPath(import.meta.url)),
37
+ "..",
38
+ );
39
+
40
+ /** The CLI entry point, for re-invoking ourselves from a checkout. */
41
+ export const ENTRY = path.join(PKG_ROOT, "server", "cli.ts");
42
+
43
+ /** The `fetch-wasm` script, spawned from both the CLI and preflight. */
44
+ export const FETCH_WASM = path.join(PKG_ROOT, "scripts", "fetch-wasm.mjs");
45
+
46
+ /** Directories on a default PATH, where a bare command name resolves. */
47
+ const DEFAULT_PATH = new Set([
48
+ "/usr/local/sbin",
49
+ "/usr/local/bin",
50
+ "/usr/sbin",
51
+ "/usr/bin",
52
+ "/sbin",
53
+ "/bin",
54
+ ]);
55
+
56
+ /**
57
+ * How to run vibe-os again — the string printed into ssh commands people copy,
58
+ * and into the systemd unit. Both used to build it separately.
59
+ */
60
+ export function invocation(): string {
61
+ if (!IS_COMPILED) return `${process.execPath} ${ENTRY}`;
62
+ const exe = process.execPath;
63
+ return DEFAULT_PATH.has(path.dirname(exe)) ? path.basename(exe) : exe;
64
+ }