@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.
- package/LICENSE +21 -0
- package/README.md +988 -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 +857 -0
- package/server/config.ts +385 -0
- package/server/db.ts +262 -0
- package/server/doctor.ts +977 -0
- package/server/fsx.ts +88 -0
- package/server/harness.ts +655 -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/setup.ts +1151 -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
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// @ts-nocheck — generated; `with { type: 'file' }` imports are not modelled by tsc.
|
|
2
|
+
// Generated by scripts/gen-assets.ts — do not edit.
|
|
3
|
+
//
|
|
4
|
+
// Maps URL paths to files embedded in the compiled binary. Under `bun run`
|
|
5
|
+
// these resolve to real paths on disk, so the same code serves both modes.
|
|
6
|
+
|
|
7
|
+
import a0 from "../dist/web/apple-touch-icon.png" with { type: 'file' };
|
|
8
|
+
import a1 from "../dist/web/assets/index-B77Qk8B5.js" with { type: 'file' };
|
|
9
|
+
import a2 from "../dist/web/assets/index-B77Qk8B5.js.br" with { type: 'file' };
|
|
10
|
+
import a3 from "../dist/web/assets/index-B77Qk8B5.js.gz" with { type: 'file' };
|
|
11
|
+
import a4 from "../dist/web/assets/index-CH5Em-Mo.css" with { type: 'file' };
|
|
12
|
+
import a5 from "../dist/web/assets/index-CH5Em-Mo.css.br" with { type: 'file' };
|
|
13
|
+
import a6 from "../dist/web/assets/index-CH5Em-Mo.css.gz" with { type: 'file' };
|
|
14
|
+
import a7 from "../dist/web/icon-192.png" with { type: 'file' };
|
|
15
|
+
import a8 from "../dist/web/icon-512.png" with { type: 'file' };
|
|
16
|
+
import a9 from "../dist/web/icon-app.svg" with { type: 'file' };
|
|
17
|
+
import a10 from "../dist/web/icon-maskable-512.png" with { type: 'file' };
|
|
18
|
+
import a11 from "../dist/web/icon-maskable.svg" with { type: 'file' };
|
|
19
|
+
import a12 from "../dist/web/icon.svg" with { type: 'file' };
|
|
20
|
+
import a13 from "../dist/web/index.html" with { type: 'file' };
|
|
21
|
+
import a14 from "../dist/web/ssh.wasm" with { type: 'file' };
|
|
22
|
+
import a15 from "../dist/web/ssh.wasm.br" with { type: 'file' };
|
|
23
|
+
import a16 from "../dist/web/ssh.wasm.gz" with { type: 'file' };
|
|
24
|
+
import a17 from "../dist/web/sw.js" with { type: 'file' };
|
|
25
|
+
import a18 from "../dist/web/wasm_exec.js" with { type: 'file' };
|
|
26
|
+
import a19 from "../dist/web/wasm_exec.js.br" with { type: 'file' };
|
|
27
|
+
import a20 from "../dist/web/wasm_exec.js.gz" with { type: 'file' };
|
|
28
|
+
|
|
29
|
+
export const BUILD_ID = "4784e0916592";
|
|
30
|
+
|
|
31
|
+
export const EMBEDDED: Record<string, string> = {
|
|
32
|
+
"/apple-touch-icon.png": a0,
|
|
33
|
+
"/assets/index-B77Qk8B5.js": a1,
|
|
34
|
+
"/assets/index-B77Qk8B5.js.br": a2,
|
|
35
|
+
"/assets/index-B77Qk8B5.js.gz": a3,
|
|
36
|
+
"/assets/index-CH5Em-Mo.css": a4,
|
|
37
|
+
"/assets/index-CH5Em-Mo.css.br": a5,
|
|
38
|
+
"/assets/index-CH5Em-Mo.css.gz": a6,
|
|
39
|
+
"/icon-192.png": a7,
|
|
40
|
+
"/icon-512.png": a8,
|
|
41
|
+
"/icon-app.svg": a9,
|
|
42
|
+
"/icon-maskable-512.png": a10,
|
|
43
|
+
"/icon-maskable.svg": a11,
|
|
44
|
+
"/icon.svg": a12,
|
|
45
|
+
"/index.html": a13,
|
|
46
|
+
"/ssh.wasm": a14,
|
|
47
|
+
"/ssh.wasm.br": a15,
|
|
48
|
+
"/ssh.wasm.gz": a16,
|
|
49
|
+
"/sw.js": a17,
|
|
50
|
+
"/wasm_exec.js": a18,
|
|
51
|
+
"/wasm_exec.js.br": a19,
|
|
52
|
+
"/wasm_exec.js.gz": a20,
|
|
53
|
+
};
|
package/server/attach.ts
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
// Popping a terminal out to a real terminal.
|
|
2
|
+
//
|
|
3
|
+
// A window is a dtach session, and dtach does not care who attaches to it. The
|
|
4
|
+
// browser reaches it through the WASM/bridge/sshd path; someone on the tailnet
|
|
5
|
+
// can reach the same session with plain `ssh`, and land in exactly the same
|
|
6
|
+
// worktree running exactly the same thing — because both ask session.ts for the
|
|
7
|
+
// command rather than composing one of their own.
|
|
8
|
+
//
|
|
9
|
+
// The only rule that has to survive is the one the pop-out already enforces:
|
|
10
|
+
// **one client per session**. dtach lets several attach at once and sizes the
|
|
11
|
+
// pty to whoever most recently arrived, so a desktop window and a full-screen
|
|
12
|
+
// terminal attached together would fight over the size. The browser pop-out
|
|
13
|
+
// solves that by unmounting the desktop's terminal and gossiping over a
|
|
14
|
+
// BroadcastChannel. An SSH client cannot join that conversation, so the handoff
|
|
15
|
+
// is recorded on the window row instead — durable across a reload, and undone
|
|
16
|
+
// by killing the attached client, which is the server-side equivalent of
|
|
17
|
+
// closing the pop-out window.
|
|
18
|
+
//
|
|
19
|
+
// Killing "the client" is safe because of how session.ts starts things: the
|
|
20
|
+
// process that owns the pty is `dtach -n <socket> …` and every attachment is
|
|
21
|
+
// `dtach -a <socket>`. They differ in argv, so the two are never confused.
|
|
22
|
+
|
|
23
|
+
import { execFile } from "node:child_process";
|
|
24
|
+
import { readdir, rm } from "node:fs/promises";
|
|
25
|
+
import { promisify } from "node:util";
|
|
26
|
+
import { and, desc, eq } from "drizzle-orm";
|
|
27
|
+
import type { AttachInfo, Handoff, SshEndpoint } from "../shared/wire.ts";
|
|
28
|
+
import type { Config } from "./config.ts";
|
|
29
|
+
import {
|
|
30
|
+
type Db,
|
|
31
|
+
ID_PATTERN,
|
|
32
|
+
profiles,
|
|
33
|
+
projects,
|
|
34
|
+
windows,
|
|
35
|
+
workspaces,
|
|
36
|
+
} from "./db.ts";
|
|
37
|
+
import { log } from "./log.ts";
|
|
38
|
+
import { getProfile } from "./profiles.ts";
|
|
39
|
+
import { sessionProcesses, terminate } from "./reaper.ts";
|
|
40
|
+
import { invocation } from "./runtime.ts";
|
|
41
|
+
import { socketPath, windowCommand } from "./session.ts";
|
|
42
|
+
|
|
43
|
+
const run = promisify(execFile);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* How long a handoff nobody ever used is left alone.
|
|
47
|
+
*
|
|
48
|
+
* This only applies before the first client shows up. A handoff is made the
|
|
49
|
+
* moment you pick "SSH session", which is necessarily *before* you have pasted
|
|
50
|
+
* the command anywhere — so for a while the row says the terminal is out there
|
|
51
|
+
* and nobody is attached yet. Reaping on that would take the window back
|
|
52
|
+
* while the command is still on the clipboard, and then the terminal you
|
|
53
|
+
* eventually paste into becomes a second client, which is the one thing the
|
|
54
|
+
* handoff exists to prevent.
|
|
55
|
+
*
|
|
56
|
+
* Generous, because the case it guards is "I got distracted", and because it
|
|
57
|
+
* costs nothing: once a client has attached, `handoffSeen` takes over and its
|
|
58
|
+
* departure is reaped straight away rather than waiting this out.
|
|
59
|
+
*/
|
|
60
|
+
const HANDOFF_GRACE_MS = 15 * 60_000;
|
|
61
|
+
|
|
62
|
+
/** A ref is `<workspace>-<index>`; the `vibe-` session prefix is optional. */
|
|
63
|
+
const REF = /^(?:vibe-)?([a-z0-9]+(?:-[a-z0-9]+)*)-(\d+)$/;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The dtach session name for a window. `REF` above parses this back, so the two
|
|
67
|
+
* have to agree; keeping the prefix in one place is what makes that checkable.
|
|
68
|
+
*/
|
|
69
|
+
export const sessionName = (workspace: string, idx: number): string =>
|
|
70
|
+
`vibe-${workspace}-${idx}`;
|
|
71
|
+
|
|
72
|
+
/** The same pair as a person types it, without the prefix. */
|
|
73
|
+
export const sessionRef = (workspace: string, idx: number): string =>
|
|
74
|
+
`${workspace}-${idx}`;
|
|
75
|
+
|
|
76
|
+
export interface AttachTarget {
|
|
77
|
+
windowId: string;
|
|
78
|
+
/** What a person types: `quiet-amber-otter-1`. */
|
|
79
|
+
ref: string;
|
|
80
|
+
session: string;
|
|
81
|
+
cwd: string;
|
|
82
|
+
profileId: string | null;
|
|
83
|
+
workspace: string;
|
|
84
|
+
project: string;
|
|
85
|
+
/** The role the window was opened as, when it has one. */
|
|
86
|
+
role: string | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── resolution ───────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Turns a ref or a window id into everything needed to attach.
|
|
93
|
+
*
|
|
94
|
+
* Both spellings are accepted because they serve different callers: the UI has
|
|
95
|
+
* an opaque window id in hand, and a person at a shell prompt has the label off
|
|
96
|
+
* the title bar. A ref is matched against the workspace name and window index
|
|
97
|
+
* that produce the session name, so what you type is what the socket is called.
|
|
98
|
+
*/
|
|
99
|
+
/**
|
|
100
|
+
* The join every attach lookup needs, and the mapping from a row to a target.
|
|
101
|
+
*
|
|
102
|
+
* Both were written out twice — same five tables, same seven fields — so a
|
|
103
|
+
* column added to `AttachTarget` had to be added in two places, and a miss
|
|
104
|
+
* compiled cleanly in whichever copy happened to leave the field optional.
|
|
105
|
+
*/
|
|
106
|
+
const targetRows = (db: Db) =>
|
|
107
|
+
db
|
|
108
|
+
.select({
|
|
109
|
+
windowId: windows.id,
|
|
110
|
+
idx: windows.idx,
|
|
111
|
+
profileId: windows.profileId,
|
|
112
|
+
workspace: workspaces.name,
|
|
113
|
+
cwd: workspaces.path,
|
|
114
|
+
lastOpenedAt: workspaces.lastOpenedAt,
|
|
115
|
+
project: projects.name,
|
|
116
|
+
role: profiles.name,
|
|
117
|
+
})
|
|
118
|
+
.from(windows)
|
|
119
|
+
.innerJoin(workspaces, eq(windows.workspaceId, workspaces.id))
|
|
120
|
+
.innerJoin(projects, eq(workspaces.projectId, projects.id))
|
|
121
|
+
.leftJoin(profiles, eq(windows.profileId, profiles.id));
|
|
122
|
+
|
|
123
|
+
type TargetRow = Awaited<
|
|
124
|
+
ReturnType<ReturnType<typeof targetRows>["all"]>
|
|
125
|
+
>[number];
|
|
126
|
+
|
|
127
|
+
const toTarget = (r: TargetRow): AttachTarget => ({
|
|
128
|
+
windowId: r.windowId,
|
|
129
|
+
ref: sessionRef(r.workspace, r.idx),
|
|
130
|
+
session: sessionName(r.workspace, r.idx),
|
|
131
|
+
cwd: r.cwd,
|
|
132
|
+
profileId: r.profileId,
|
|
133
|
+
workspace: r.workspace,
|
|
134
|
+
project: r.project,
|
|
135
|
+
role: r.role,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
export function resolveTarget(
|
|
139
|
+
db: Db,
|
|
140
|
+
refOrId: string,
|
|
141
|
+
): AttachTarget | undefined {
|
|
142
|
+
const rows = targetRows(db);
|
|
143
|
+
|
|
144
|
+
/*
|
|
145
|
+
* The id is tried first, and both are tried — the obvious shape, matching on
|
|
146
|
+
* whichever pattern the input looks like, is wrong.
|
|
147
|
+
*
|
|
148
|
+
* Window ids are UUIDs, and the last group of a UUID is twelve hex digits
|
|
149
|
+
* that are sometimes all decimal: `…-a716-446655440001` is a perfectly
|
|
150
|
+
* ordinary id that also parses as the ref "workspace …-a716, window
|
|
151
|
+
* 446655440001". Choosing by shape would send that id down the ref path,
|
|
152
|
+
* find nothing, and give up — an intermittent failure in the API, which
|
|
153
|
+
* passes ids, on roughly one window in two hundred.
|
|
154
|
+
*/
|
|
155
|
+
const trimmed = refOrId.trim();
|
|
156
|
+
const match = REF.exec(trimmed);
|
|
157
|
+
const found =
|
|
158
|
+
(ID_PATTERN.test(trimmed)
|
|
159
|
+
? rows.where(eq(windows.id, trimmed)).all()[0]
|
|
160
|
+
: undefined) ??
|
|
161
|
+
(match
|
|
162
|
+
? rows
|
|
163
|
+
.where(
|
|
164
|
+
and(
|
|
165
|
+
eq(workspaces.name, match[1]),
|
|
166
|
+
eq(windows.idx, Number(match[2])),
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
// Workspace names are three random words, so a collision across two
|
|
170
|
+
// projects is vanishingly unlikely rather than impossible. Preferring
|
|
171
|
+
// the one you touched most recently beats picking by row order.
|
|
172
|
+
.orderBy(desc(workspaces.lastOpenedAt))
|
|
173
|
+
.all()[0]
|
|
174
|
+
: undefined);
|
|
175
|
+
|
|
176
|
+
return found ? toTarget(found) : undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Every window that could be attached to, newest workspace first. */
|
|
180
|
+
export function listTargets(db: Db): AttachTarget[] {
|
|
181
|
+
return targetRows(db)
|
|
182
|
+
.orderBy(desc(workspaces.lastOpenedAt), windows.idx)
|
|
183
|
+
.all()
|
|
184
|
+
.map(toTarget);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The session command for a target — the same one the certificate forces. */
|
|
188
|
+
export function commandFor(
|
|
189
|
+
db: Db,
|
|
190
|
+
config: Config,
|
|
191
|
+
target: AttachTarget,
|
|
192
|
+
): string | undefined {
|
|
193
|
+
const profile = target.profileId
|
|
194
|
+
? getProfile(db, target.profileId)
|
|
195
|
+
: undefined;
|
|
196
|
+
return windowCommand(target.session, target.cwd, config, profile);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── talking to dtach ─────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/** Sessions with something still holding the other end of their socket. */
|
|
202
|
+
export async function liveSessions(config: Config): Promise<Set<string>> {
|
|
203
|
+
try {
|
|
204
|
+
const dir = `${config.stateDir}/sessions`;
|
|
205
|
+
const names = await readdir(dir);
|
|
206
|
+
const live = new Set<string>();
|
|
207
|
+
await Promise.all(
|
|
208
|
+
names
|
|
209
|
+
.filter((n) => n.endsWith(".sock"))
|
|
210
|
+
.map(async (n) => {
|
|
211
|
+
// Writing nothing to the socket succeeds only if something is still
|
|
212
|
+
// holding the other end, which is what separates a live session from
|
|
213
|
+
// a socket a crashed one left behind.
|
|
214
|
+
const ok = await run("dtach", ["-p", `${dir}/${n}`], {
|
|
215
|
+
timeout: 10_000,
|
|
216
|
+
})
|
|
217
|
+
.then(() => true)
|
|
218
|
+
.catch(() => false);
|
|
219
|
+
if (ok) live.add(n.replace(/\.sock$/, ""));
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
return live;
|
|
223
|
+
} catch {
|
|
224
|
+
// No sessions directory yet: nothing has ever been opened.
|
|
225
|
+
return new Set();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Processes currently attached to a session.
|
|
231
|
+
*
|
|
232
|
+
* A client is `dtach -a <socket>`; the process that owns the pty is
|
|
233
|
+
* `dtach -n <socket> …`. They differ in argv precisely so this can tell them
|
|
234
|
+
* apart — matching too loosely here would mean "detach the clients" killing the
|
|
235
|
+
* session along with them.
|
|
236
|
+
*/
|
|
237
|
+
export async function clientsOn(
|
|
238
|
+
config: Config,
|
|
239
|
+
session: string,
|
|
240
|
+
): Promise<string[]> {
|
|
241
|
+
const sock = socketPath(config, session);
|
|
242
|
+
try {
|
|
243
|
+
const { stdout } = await run("pgrep", ["-f", `^dtach -a ${sock}`], {
|
|
244
|
+
timeout: 10_000,
|
|
245
|
+
});
|
|
246
|
+
return stdout
|
|
247
|
+
.split("\n")
|
|
248
|
+
.map((l) => l.trim())
|
|
249
|
+
.filter(Boolean);
|
|
250
|
+
} catch {
|
|
251
|
+
// pgrep exits non-zero when nothing matches.
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Kicks every client off a session, leaving the session itself running.
|
|
258
|
+
*
|
|
259
|
+
* This is what "Bring it back" does to a terminal, and the exact counterpart of
|
|
260
|
+
* closing a browser pop-out: the attached dtach exits, the forced command it
|
|
261
|
+
* was running returns, and the ssh session ends. What was running is untouched
|
|
262
|
+
* — the process that owns the pty is a different process and is left alone.
|
|
263
|
+
*/
|
|
264
|
+
export async function detachClients(
|
|
265
|
+
config: Config,
|
|
266
|
+
session: string,
|
|
267
|
+
): Promise<void> {
|
|
268
|
+
const pids = await clientsOn(config, session);
|
|
269
|
+
for (const pid of pids) {
|
|
270
|
+
await run("kill", [pid], { timeout: 10_000 }).catch(() => {
|
|
271
|
+
// Already gone between listing and killing; that is the desired state.
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Ends a session outright: the whole process tree, then the socket.
|
|
278
|
+
*
|
|
279
|
+
* "Whole tree" is the load-bearing part. This used to pkill exactly the dtach
|
|
280
|
+
* master, whose death takes the pty and with it the harness — but not the
|
|
281
|
+
* harness's children, which were reparented to init and ran for a week in
|
|
282
|
+
* worktrees that no longer existed (#38). `sessionProcesses` resolves
|
|
283
|
+
* everything the session ever started, by the pty's kernel session id and by
|
|
284
|
+
* the `VIBE_OS_SOCK` marker stamped on the create command, and `terminate`
|
|
285
|
+
* gives it TERM, a grace, then KILL.
|
|
286
|
+
*/
|
|
287
|
+
export async function killSession(
|
|
288
|
+
config: Config,
|
|
289
|
+
session: string,
|
|
290
|
+
): Promise<void> {
|
|
291
|
+
const sock = socketPath(config, session);
|
|
292
|
+
await terminate(await sessionProcesses(sock));
|
|
293
|
+
await rm(sock, { force: true }).catch(() => {});
|
|
294
|
+
// The lock file the forced command serialises on. Harmless to leave behind —
|
|
295
|
+
// `liveSessions` only looks at `.sock` — but it is ours to clean up.
|
|
296
|
+
await rm(`${sock}.lock`, { force: true }).catch(() => {});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Ends every dtach session belonging to a workspace.
|
|
301
|
+
*
|
|
302
|
+
* Call this *before* deleting the window rows. The name of a session is derived
|
|
303
|
+
* from the workspace name and the window index, so once the rows are gone there
|
|
304
|
+
* is no way left to name — and therefore no way to reach — the processes still
|
|
305
|
+
* holding those ptys. Three deletion paths in `projects.ts` used to drop the
|
|
306
|
+
* rows on their own and strand a dtach process plus its socket each time.
|
|
307
|
+
*/
|
|
308
|
+
export async function killWorkspaceSessions(
|
|
309
|
+
db: Db,
|
|
310
|
+
config: Config,
|
|
311
|
+
workspaceId: string,
|
|
312
|
+
): Promise<void> {
|
|
313
|
+
const rows = db
|
|
314
|
+
.select({ idx: windows.idx, workspace: workspaces.name })
|
|
315
|
+
.from(windows)
|
|
316
|
+
.innerJoin(workspaces, eq(windows.workspaceId, workspaces.id))
|
|
317
|
+
.where(eq(windows.workspaceId, workspaceId))
|
|
318
|
+
.all();
|
|
319
|
+
await Promise.all(
|
|
320
|
+
rows.map((r) => killSession(config, sessionName(r.workspace, r.idx))),
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── where to point ssh ───────────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The endpoint to print in an ssh command.
|
|
328
|
+
*
|
|
329
|
+
* `config.sshHost` is the wrong answer and the tempting one: it is where the
|
|
330
|
+
* *bridge* dials, which is `127.0.0.1` in every recommended deployment. So is
|
|
331
|
+
* `os.hostname()`, which on a VPS is something like `ubuntu-2gb-fsn1` and
|
|
332
|
+
* resolves nowhere.
|
|
333
|
+
*
|
|
334
|
+
* The right answer is the host the browser is already talking to. If you
|
|
335
|
+
* reached the desktop at `vibe-os.your-tailnet.ts.net`, ssh to that name works —
|
|
336
|
+
* it is the same machine, and on a tailnet it is the same name sshd answers on.
|
|
337
|
+
* `--ssh-advertise` overrides it for the case where the two genuinely differ,
|
|
338
|
+
* such as a reverse proxy in front of the web port.
|
|
339
|
+
*/
|
|
340
|
+
/** The host a person would type, from the same forwarded headers. */
|
|
341
|
+
export function publicHost(req: Request, url: URL): string {
|
|
342
|
+
const first = (name: string) => req.headers.get(name)?.split(",")[0].trim();
|
|
343
|
+
return first("x-forwarded-host") || first("host") || url.host;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function sshEndpoint(config: Config, requestHost: string): SshEndpoint {
|
|
347
|
+
const advertised = config.sshAdvertise?.trim();
|
|
348
|
+
if (advertised) {
|
|
349
|
+
// Splitting on every colon breaks IPv6: `::1` becomes host "" port "".
|
|
350
|
+
// A bracketed literal keeps its address, and a trailing `:port` is only a
|
|
351
|
+
// port when it is one — `--ssh-advertise host:abc` used to render
|
|
352
|
+
// `ssh -t -p NaN`.
|
|
353
|
+
const bracketed = /^\[(.+)\](?::(\d+))?$/.exec(advertised);
|
|
354
|
+
const plain = /^([^:]+)(?::(\d+))?$/.exec(advertised);
|
|
355
|
+
const m = bracketed ?? plain;
|
|
356
|
+
const host = m ? m[1] : advertised;
|
|
357
|
+
const port = m?.[2] ? Number(m[2]) : 22;
|
|
358
|
+
return { user: config.user, host, port };
|
|
359
|
+
}
|
|
360
|
+
// Strips the web port, which is rarely 22 and never relevant to ssh.
|
|
361
|
+
return {
|
|
362
|
+
user: config.user,
|
|
363
|
+
host: requestHost.replace(/:\d+$/, ""),
|
|
364
|
+
port: 22,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* How to spell "run vibe-os" on the far side of an ssh command.
|
|
370
|
+
*
|
|
371
|
+
* `ssh host <command>` runs it through `$SHELL -c`, which is neither a login
|
|
372
|
+
* shell nor an interactive one, so the PATH is sshd's default rather than the
|
|
373
|
+
* one `.profile` builds. A binary in `/usr/local/bin` — where the install
|
|
374
|
+
* instructions put it — is on that PATH and can be named bare. Anywhere else,
|
|
375
|
+
* and `~/.local/bin` in particular, has to be spelled out in full or the
|
|
376
|
+
* command fails with "not found" for someone who can run it fine by hand.
|
|
377
|
+
*/
|
|
378
|
+
/** `ssh -t [-p n] user@host …`, with the tail supplied by the caller. */
|
|
379
|
+
function sshPrefix(endpoint: SshEndpoint): string {
|
|
380
|
+
const port = endpoint.port === 22 ? "" : ` -p ${endpoint.port}`;
|
|
381
|
+
return `ssh -t${port} ${endpoint.user}@${endpoint.host}`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Everything the UI needs to offer an SSH handoff. */
|
|
385
|
+
export function attachInfo(
|
|
386
|
+
db: Db,
|
|
387
|
+
config: Config,
|
|
388
|
+
refOrId: string,
|
|
389
|
+
requestHost: string,
|
|
390
|
+
): AttachInfo | undefined {
|
|
391
|
+
const target = resolveTarget(db, refOrId);
|
|
392
|
+
if (!target) return undefined;
|
|
393
|
+
// Not used in the command, but a window with no way to launch anything is not
|
|
394
|
+
// one worth offering a handoff for.
|
|
395
|
+
if (!commandFor(db, config, target)) return undefined;
|
|
396
|
+
|
|
397
|
+
const endpoint = sshEndpoint(config, requestHost);
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
ref: target.ref,
|
|
401
|
+
session: target.session,
|
|
402
|
+
cwd: target.cwd,
|
|
403
|
+
workspace: target.workspace,
|
|
404
|
+
project: target.project,
|
|
405
|
+
role: target.role,
|
|
406
|
+
endpoint,
|
|
407
|
+
command: `${sshPrefix(endpoint)} ${invocation()} attach ${target.ref}`,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ── the handoff ──────────────────────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Records that a window's terminal has been handed to an ssh client — or taken
|
|
415
|
+
* back, which also kicks whoever is attached.
|
|
416
|
+
*
|
|
417
|
+
* Persisted rather than kept in the desktop's memory because the whole point is
|
|
418
|
+
* that you close the laptop and the terminal keeps running. The browser
|
|
419
|
+
* pop-out can gossip over a BroadcastChannel because both ends are documents on
|
|
420
|
+
* one origin; an ssh client has no such channel, so the row is the record.
|
|
421
|
+
*/
|
|
422
|
+
export async function setHandoff(
|
|
423
|
+
db: Db,
|
|
424
|
+
config: Config,
|
|
425
|
+
windowId: string,
|
|
426
|
+
mode: Handoff,
|
|
427
|
+
): Promise<boolean> {
|
|
428
|
+
const target = resolveTarget(db, windowId);
|
|
429
|
+
if (!target) return false;
|
|
430
|
+
|
|
431
|
+
if (config.sessions) {
|
|
432
|
+
/*
|
|
433
|
+
* Both directions detach every client, and the reason differs.
|
|
434
|
+
*
|
|
435
|
+
* Taking it back is the obvious one: the terminal has to let go, and this
|
|
436
|
+
* is the server-side equivalent of closing a browser pop-out. It happens
|
|
437
|
+
* before the flag is cleared, so a failure leaves the row saying the
|
|
438
|
+
* terminal is still out there, which is the honest state.
|
|
439
|
+
*
|
|
440
|
+
* Handing it out is the subtle one. The desktop that asked has already
|
|
441
|
+
* unmounted its terminal — but any *other* desktop showing this workspace
|
|
442
|
+
* has not heard yet, and until it polls it is still a client. The
|
|
443
|
+
* terminal would then arrive as a second one and the session would shrink
|
|
444
|
+
* to whichever is smaller. Broadcasting to sibling tabs closes that for one
|
|
445
|
+
* browser; only the server can close it for another machine, and it closes
|
|
446
|
+
* it here to zero rather than to a poll interval. Nothing is lost: a client
|
|
447
|
+
* detaching is not a session ending, and the browsers reconnect or show the
|
|
448
|
+
* handoff as soon as they catch up.
|
|
449
|
+
*/
|
|
450
|
+
await detachClients(config, target.session);
|
|
451
|
+
log.info(
|
|
452
|
+
mode === "ssh"
|
|
453
|
+
? `handed ${target.session} to a terminal`
|
|
454
|
+
: `reclaimed ${target.session} from its terminal`,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
db.update(windows)
|
|
459
|
+
.set({ handoff: mode, handoffAt: mode ? Date.now() : null, handoffSeen: 0 })
|
|
460
|
+
.where(eq(windows.id, windowId))
|
|
461
|
+
.run();
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Clears handoffs whose terminal has gone away.
|
|
467
|
+
*
|
|
468
|
+
* Without this a window stays a placeholder forever after you close the ssh
|
|
469
|
+
* session — the desktop would be waiting for a client that will never come
|
|
470
|
+
* back, and the only way out is a button you would have to know to press. With
|
|
471
|
+
* it, closing the terminal hands the window back on the next poll.
|
|
472
|
+
*
|
|
473
|
+
* The two cases are deliberately not treated the same, because "nobody has
|
|
474
|
+
* attached yet" and "the terminal that was attached has gone" look identical
|
|
475
|
+
* in `list-clients` and mean opposite things. Once a client has been seen the
|
|
476
|
+
* session is known to have been claimed, so its disappearance is real and gets
|
|
477
|
+
* reaped at once; before that, an empty list is just someone still finding
|
|
478
|
+
* their terminal window, and only the grace period settles it.
|
|
479
|
+
*
|
|
480
|
+
* Runs on the window list, which the desktop polls while anything is out, and
|
|
481
|
+
* only ever does session work for windows that are actually handed off.
|
|
482
|
+
*/
|
|
483
|
+
export async function reapStaleHandoffs(db: Db, config: Config): Promise<void> {
|
|
484
|
+
if (!config.sessions) return;
|
|
485
|
+
const outstanding = db
|
|
486
|
+
.select()
|
|
487
|
+
.from(windows)
|
|
488
|
+
.where(eq(windows.handoff, "ssh"))
|
|
489
|
+
.all();
|
|
490
|
+
if (outstanding.length === 0) return;
|
|
491
|
+
|
|
492
|
+
const now = Date.now();
|
|
493
|
+
for (const row of outstanding) {
|
|
494
|
+
const target = resolveTarget(db, row.id);
|
|
495
|
+
if (!target) continue;
|
|
496
|
+
|
|
497
|
+
if ((await clientsOn(config, target.session)).length > 0) {
|
|
498
|
+
// First sighting. From here on, an empty client list means it left.
|
|
499
|
+
if (!row.handoffSeen) {
|
|
500
|
+
db.update(windows)
|
|
501
|
+
.set({ handoffSeen: 1 })
|
|
502
|
+
.where(eq(windows.id, row.id))
|
|
503
|
+
.run();
|
|
504
|
+
log.debug(`${target.session} picked up by a terminal`);
|
|
505
|
+
}
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (!row.handoffSeen && now - (row.handoffAt ?? 0) < HANDOFF_GRACE_MS)
|
|
510
|
+
continue;
|
|
511
|
+
|
|
512
|
+
db.update(windows)
|
|
513
|
+
.set({ handoff: null, handoffAt: null, handoffSeen: 0 })
|
|
514
|
+
.where(eq(windows.id, row.id))
|
|
515
|
+
.run();
|
|
516
|
+
log.info(
|
|
517
|
+
row.handoffSeen
|
|
518
|
+
? `${target.session} was left by its terminal — handing it back to the desktop`
|
|
519
|
+
: `${target.session} was never picked up — handing it back to the desktop`,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export type { AttachInfo, Handoff, SshEndpoint };
|