@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
package/server/acme.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Let's Encrypt via HTTP-01.
|
|
2
|
+
//
|
|
3
|
+
// We already own port 80, which is exactly what the HTTP-01 challenge needs, so
|
|
4
|
+
// there is no separate listener and nothing to coordinate: the challenge
|
|
5
|
+
// responder is just another route on the plain-HTTP server, checked before the
|
|
6
|
+
// redirect-to-HTTPS rule.
|
|
7
|
+
|
|
8
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
import { writeAtomic } from "./fsx.ts";
|
|
12
|
+
import { describeError, log } from "./log.ts";
|
|
13
|
+
|
|
14
|
+
const CHALLENGE_PREFIX = "/.well-known/acme-challenge/";
|
|
15
|
+
/** Renew this far ahead of expiry. Let's Encrypt certs last 90 days. */
|
|
16
|
+
const RENEW_BEFORE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
17
|
+
/**
|
|
18
|
+
* How often to re-check expiry once running.
|
|
19
|
+
*
|
|
20
|
+
* The renewal logic below only ever ran at startup, and the documented
|
|
21
|
+
* deployment is a systemd unit with `Restart=on-failure` plus a compose file
|
|
22
|
+
* with `restart: unless-stopped` — neither of which restarts a *healthy*
|
|
23
|
+
* process. A box left alone therefore served an expired certificate on day 90.
|
|
24
|
+
* Twice a day is far more often than a 30-day window needs, and costs one
|
|
25
|
+
* `readCertificateInfo` per tick when there is nothing to do.
|
|
26
|
+
*/
|
|
27
|
+
const RENEW_CHECK_MS = 12 * 60 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
export interface TlsMaterial {
|
|
30
|
+
key: string;
|
|
31
|
+
cert: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AcmeOptions {
|
|
35
|
+
domain: string;
|
|
36
|
+
email?: string;
|
|
37
|
+
staging: boolean;
|
|
38
|
+
stateDir: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class Acme {
|
|
42
|
+
private challenges = new Map<string, string>();
|
|
43
|
+
private readonly dir: string;
|
|
44
|
+
|
|
45
|
+
constructor(private readonly options: AcmeOptions) {
|
|
46
|
+
this.dir = path.join(options.stateDir, "tls");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Serves the HTTP-01 challenge. Must run before any HTTPS redirect. */
|
|
50
|
+
handleChallenge(url: URL): Response | null {
|
|
51
|
+
if (!url.pathname.startsWith(CHALLENGE_PREFIX)) return null;
|
|
52
|
+
const token = url.pathname.slice(CHALLENGE_PREFIX.length);
|
|
53
|
+
const value = this.challenges.get(token);
|
|
54
|
+
if (!value)
|
|
55
|
+
return new Response("not found\n", {
|
|
56
|
+
status: 404,
|
|
57
|
+
headers: { "content-type": "text/plain" },
|
|
58
|
+
});
|
|
59
|
+
return new Response(value, {
|
|
60
|
+
status: 200,
|
|
61
|
+
headers: { "content-type": "application/octet-stream" },
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private paths() {
|
|
66
|
+
return {
|
|
67
|
+
key: path.join(this.dir, `${this.options.domain}.key`),
|
|
68
|
+
cert: path.join(this.dir, `${this.options.domain}.crt`),
|
|
69
|
+
account: path.join(this.dir, "account.key"),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private async loadExisting(): Promise<TlsMaterial | null> {
|
|
74
|
+
const { key, cert } = this.paths();
|
|
75
|
+
try {
|
|
76
|
+
const [keyPem, certPem] = await Promise.all([
|
|
77
|
+
readFile(key, "utf8"),
|
|
78
|
+
readFile(cert, "utf8"),
|
|
79
|
+
]);
|
|
80
|
+
const acme = await import("acme-client");
|
|
81
|
+
const info = acme.crypto.readCertificateInfo(certPem);
|
|
82
|
+
const remaining = info.notAfter.getTime() - Date.now();
|
|
83
|
+
if (remaining > RENEW_BEFORE_MS) {
|
|
84
|
+
log.ok(
|
|
85
|
+
`reusing TLS certificate for ${this.options.domain} (expires ${info.notAfter.toISOString().slice(0, 10)})`,
|
|
86
|
+
);
|
|
87
|
+
return { key: keyPem, cert: certPem };
|
|
88
|
+
}
|
|
89
|
+
log.info(
|
|
90
|
+
`TLS certificate for ${this.options.domain} expires soon — renewing`,
|
|
91
|
+
);
|
|
92
|
+
return null;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
// ENOENT is the ordinary "no certificate yet" path. Anything else — a
|
|
95
|
+
// permission problem, a truncated file, a corrupt PEM — must not silently
|
|
96
|
+
// become a reissue: that burns Let's Encrypt's duplicate-certificate rate
|
|
97
|
+
// limit, and under a restart loop it burns it fast.
|
|
98
|
+
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") throw err;
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Returns usable key/cert material, ordering a new certificate if needed. */
|
|
104
|
+
async obtain(): Promise<TlsMaterial> {
|
|
105
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
106
|
+
|
|
107
|
+
const existing = await this.loadExisting();
|
|
108
|
+
if (existing) return existing;
|
|
109
|
+
|
|
110
|
+
const acme = await import("acme-client");
|
|
111
|
+
const { key: keyPath, cert: certPath, account: accountPath } = this.paths();
|
|
112
|
+
|
|
113
|
+
// Same reasoning as loadExisting, but the stakes are higher: overwriting
|
|
114
|
+
// this key discards the Let's Encrypt registration itself.
|
|
115
|
+
let accountKey: Buffer;
|
|
116
|
+
try {
|
|
117
|
+
accountKey = await readFile(accountPath);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") throw err;
|
|
120
|
+
accountKey = await acme.crypto.createPrivateKey();
|
|
121
|
+
await writeFile(accountPath, accountKey, { mode: 0o600 });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const client = new acme.Client({
|
|
125
|
+
directoryUrl: this.options.staging
|
|
126
|
+
? acme.directory.letsencrypt.staging
|
|
127
|
+
: acme.directory.letsencrypt.production,
|
|
128
|
+
accountKey,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
log.info(`requesting a TLS certificate for ${this.options.domain}…`);
|
|
132
|
+
const [privateKey, csr] = await acme.crypto.createCsr({
|
|
133
|
+
commonName: this.options.domain,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const cert = await client.auto({
|
|
137
|
+
csr,
|
|
138
|
+
email: this.options.email,
|
|
139
|
+
termsOfServiceAgreed: true,
|
|
140
|
+
challengePriority: ["http-01"],
|
|
141
|
+
challengeCreateFn: async (_authz, challenge, keyAuthorization) => {
|
|
142
|
+
if (challenge.type !== "http-01") return;
|
|
143
|
+
this.challenges.set(challenge.token, keyAuthorization);
|
|
144
|
+
},
|
|
145
|
+
challengeRemoveFn: async (_authz, challenge) => {
|
|
146
|
+
this.challenges.delete(challenge.token);
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const keyPem = privateKey.toString();
|
|
151
|
+
const certPem = cert.toString();
|
|
152
|
+
await writeAtomic(keyPath, keyPem, 0o600);
|
|
153
|
+
await writeAtomic(certPath, certPem, 0o644);
|
|
154
|
+
log.ok(`TLS certificate issued for ${this.options.domain}`);
|
|
155
|
+
|
|
156
|
+
return { key: keyPem, cert: certPem };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Re-checks expiry on a timer and hands back fresh material when it rolls.
|
|
161
|
+
*
|
|
162
|
+
* Returns a stop function. The caller owns swapping the running HTTPS server
|
|
163
|
+
* over; this only decides when there is something to swap to.
|
|
164
|
+
*/
|
|
165
|
+
watch(onRenewed: (material: TlsMaterial) => void): () => void {
|
|
166
|
+
const timer = setInterval(() => {
|
|
167
|
+
void (async () => {
|
|
168
|
+
try {
|
|
169
|
+
if (await this.loadExisting()) return; // still good
|
|
170
|
+
onRenewed(await this.obtain());
|
|
171
|
+
} catch (err) {
|
|
172
|
+
// A failed renewal must not take the process down: the current
|
|
173
|
+
// certificate is still being served and there are 30 days of runway.
|
|
174
|
+
log.warn(`TLS renewal check failed: ${describeError(err)}`);
|
|
175
|
+
}
|
|
176
|
+
})();
|
|
177
|
+
}, RENEW_CHECK_MS);
|
|
178
|
+
// Do not hold the event loop open just for this.
|
|
179
|
+
timer.unref?.();
|
|
180
|
+
return () => clearInterval(timer);
|
|
181
|
+
}
|
|
182
|
+
}
|
package/server/api.ts
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import pkg from "../package.json" with { type: "json" };
|
|
3
|
+
import type { ClientConfig } from "../shared/wire.ts";
|
|
4
|
+
import {
|
|
5
|
+
attachInfo,
|
|
6
|
+
killSession,
|
|
7
|
+
publicHost,
|
|
8
|
+
reapStaleHandoffs,
|
|
9
|
+
setHandoff,
|
|
10
|
+
} from "./attach.ts";
|
|
11
|
+
import type { Config } from "./config.ts";
|
|
12
|
+
import type { Db } from "./db.ts";
|
|
13
|
+
import { ID_PATTERN } from "./db.ts";
|
|
14
|
+
import { discoverClaude, discoverCursor, discoverHermes } from "./harness.ts";
|
|
15
|
+
import {
|
|
16
|
+
badId,
|
|
17
|
+
IMMUTABLE_CACHE_CONTROL,
|
|
18
|
+
json,
|
|
19
|
+
methodNotAllowed,
|
|
20
|
+
readJson,
|
|
21
|
+
} from "./http.ts";
|
|
22
|
+
import { describeError, log } from "./log.ts";
|
|
23
|
+
import {
|
|
24
|
+
discoverMcp,
|
|
25
|
+
discoverMcpWithDefs,
|
|
26
|
+
type McpScan,
|
|
27
|
+
syncMcpMirrors,
|
|
28
|
+
} from "./mcp.ts";
|
|
29
|
+
import {
|
|
30
|
+
createProfile,
|
|
31
|
+
deleteProfile,
|
|
32
|
+
getProfile,
|
|
33
|
+
listProfiles,
|
|
34
|
+
PALETTE,
|
|
35
|
+
updateProfile,
|
|
36
|
+
} from "./profiles.ts";
|
|
37
|
+
import {
|
|
38
|
+
createWorkspace,
|
|
39
|
+
getWorkspace,
|
|
40
|
+
listProjects,
|
|
41
|
+
listWorkspaces,
|
|
42
|
+
reconcileWorkspaces,
|
|
43
|
+
removeWorkspace,
|
|
44
|
+
scanProjects,
|
|
45
|
+
touchWorkspace,
|
|
46
|
+
} from "./projects.ts";
|
|
47
|
+
import { windowCommand } from "./session.ts";
|
|
48
|
+
import type { SshCa } from "./ssh-ca.ts";
|
|
49
|
+
import { fingerprint, MAX_PUBKEY_BYTES } from "./ssh-ca.ts";
|
|
50
|
+
import {
|
|
51
|
+
type DesktopPrefs,
|
|
52
|
+
MAX_WALLPAPER_BYTES,
|
|
53
|
+
type WallpaperStore,
|
|
54
|
+
} from "./wallpapers.ts";
|
|
55
|
+
import {
|
|
56
|
+
createWindow,
|
|
57
|
+
deleteWindow,
|
|
58
|
+
getWindow,
|
|
59
|
+
listWindows,
|
|
60
|
+
sessionNameFor,
|
|
61
|
+
updateWindow,
|
|
62
|
+
} from "./windows.ts";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Where to look for MCP servers, for one project or for all of them.
|
|
66
|
+
*
|
|
67
|
+
* A worktree is a copy of the repo, so its `.mcp.json` is the project's and is
|
|
68
|
+
* read once from the root. Local-scope servers are the opposite — they are
|
|
69
|
+
* keyed by the exact directory `claude mcp add` ran in, so every worktree is
|
|
70
|
+
* its own place to look.
|
|
71
|
+
*/
|
|
72
|
+
function mcpScan(db: Db, projectId?: string): McpScan {
|
|
73
|
+
const roots: string[] = [];
|
|
74
|
+
const dirs: string[] = [];
|
|
75
|
+
for (const project of listProjects(db)) {
|
|
76
|
+
if (projectId !== undefined && project.id !== projectId) continue;
|
|
77
|
+
roots.push(project.path);
|
|
78
|
+
dirs.push(
|
|
79
|
+
project.path,
|
|
80
|
+
...listWorkspaces(db, project.id).map((w) => w.path),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return { roots, dirs };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Brings the generated per-server files back in step with the box.
|
|
88
|
+
*
|
|
89
|
+
* Always over every project, never just the one being asked about: the sync
|
|
90
|
+
* deletes files that no longer have a server behind them, and a partial view
|
|
91
|
+
* would read another project's servers as gone.
|
|
92
|
+
*/
|
|
93
|
+
/**
|
|
94
|
+
* Re-scans every project and rewrites the mirror files.
|
|
95
|
+
*
|
|
96
|
+
* Returns nothing: both call sites discarded the list, which made the discovery
|
|
97
|
+
* work look reusable when it was not.
|
|
98
|
+
*/
|
|
99
|
+
async function refreshMcpMirrors(db: Db, stateDir: string): Promise<void> {
|
|
100
|
+
await syncMcpMirrors(
|
|
101
|
+
stateDir,
|
|
102
|
+
await discoverMcpWithDefs(stateDir, mcpScan(db)),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface ApiDeps {
|
|
107
|
+
config: Config;
|
|
108
|
+
ca: SshCa;
|
|
109
|
+
hostKey: string | null;
|
|
110
|
+
wallpapers: WallpaperStore;
|
|
111
|
+
db: Db;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function createApi(deps: ApiDeps) {
|
|
115
|
+
const { ca, config, db, hostKey, wallpapers } = deps;
|
|
116
|
+
|
|
117
|
+
return async function handleApi(
|
|
118
|
+
req: Request,
|
|
119
|
+
url: URL,
|
|
120
|
+
): Promise<Response | null> {
|
|
121
|
+
const p = url.pathname;
|
|
122
|
+
if (!p.startsWith("/api/")) return null;
|
|
123
|
+
|
|
124
|
+
if (p === "/api/health") return json({ ok: true });
|
|
125
|
+
|
|
126
|
+
// What the installed Claude CLI accepts, so the editor can offer it as
|
|
127
|
+
// dropdowns rather than asking people to remember flag spellings.
|
|
128
|
+
if (p === "/api/harness/claude" && req.method === "GET") {
|
|
129
|
+
return json(await discoverClaude());
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The same question for Hermes, which answers a narrower version of it:
|
|
133
|
+
// configured providers, its own defaults, and where its browser tools land.
|
|
134
|
+
if (p === "/api/harness/hermes" && req.method === "GET") {
|
|
135
|
+
return json(await discoverHermes());
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// And for Cursor: the account's model list, and whether anyone is logged in.
|
|
139
|
+
if (p === "/api/harness/cursor" && req.method === "GET") {
|
|
140
|
+
return json(await discoverCursor());
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (p === "/api/config" && req.method === "GET") {
|
|
144
|
+
const body: ClientConfig = {
|
|
145
|
+
version: pkg.version,
|
|
146
|
+
hostname: os.hostname(),
|
|
147
|
+
user: config.user,
|
|
148
|
+
workspaceRoot: config.workspace,
|
|
149
|
+
sessions: config.sessions,
|
|
150
|
+
authRequired: config.token !== null,
|
|
151
|
+
endpoint: { name: "local", url: "./websocket" },
|
|
152
|
+
hostKey,
|
|
153
|
+
hostKeyFingerprint: hostKey ? fingerprint(hostKey) : null,
|
|
154
|
+
certificateEndpoint: "/api/ssh/certificate",
|
|
155
|
+
maxWallpaperBytes: MAX_WALLPAPER_BYTES,
|
|
156
|
+
themeColor: config.themeColor,
|
|
157
|
+
palette: PALETTE,
|
|
158
|
+
};
|
|
159
|
+
return json(body);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── projects ─────────────────────────────────────────────────────────────
|
|
163
|
+
if (p === "/api/projects" && req.method === "GET") {
|
|
164
|
+
// Reads the table only. Finding repos means walking the disk, which is
|
|
165
|
+
// far too expensive to do on a poll — that is what the refresh button
|
|
166
|
+
// (POST /api/projects/scan) is for, plus one scan at startup.
|
|
167
|
+
return json(listProjects(db));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (p === "/api/projects/scan" && req.method === "POST") {
|
|
171
|
+
await scanProjects(db, config);
|
|
172
|
+
await reconcileWorkspaces(db, config);
|
|
173
|
+
return json(listProjects(db));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The MCP servers a profile in this project could be given. Read from the
|
|
177
|
+
// box's own config rather than a list of our own, so `claude mcp add` is
|
|
178
|
+
// all it takes for one to appear here.
|
|
179
|
+
const mcpMatch = /^\/api\/projects\/([^/]+)\/mcp$/.exec(p);
|
|
180
|
+
if (mcpMatch && req.method === "GET") {
|
|
181
|
+
const projectId = decodeURIComponent(mcpMatch[1]);
|
|
182
|
+
if (!ID_PATTERN.test(projectId)) return badId("project");
|
|
183
|
+
// Writes the files as a side effect of listing them, so that anything
|
|
184
|
+
// offered in the editor is something a launch can actually point at.
|
|
185
|
+
await refreshMcpMirrors(db, config.stateDir);
|
|
186
|
+
return json(await discoverMcp(config.stateDir, mcpScan(db, projectId)));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const workspacesMatch = /^\/api\/projects\/([^/]+)\/workspaces$/.exec(p);
|
|
190
|
+
if (workspacesMatch) {
|
|
191
|
+
const projectId = decodeURIComponent(workspacesMatch[1]);
|
|
192
|
+
if (!ID_PATTERN.test(projectId)) return badId("project");
|
|
193
|
+
|
|
194
|
+
if (req.method === "GET") return json(listWorkspaces(db, projectId));
|
|
195
|
+
|
|
196
|
+
if (req.method === "POST") {
|
|
197
|
+
try {
|
|
198
|
+
const body = await readJson<{ name?: string }>(req);
|
|
199
|
+
const created = await createWorkspace(
|
|
200
|
+
db,
|
|
201
|
+
config.workspace,
|
|
202
|
+
projectId,
|
|
203
|
+
body.name,
|
|
204
|
+
);
|
|
205
|
+
return json(created, 201);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
const message = describeError(err);
|
|
208
|
+
log.warn(`workspace creation failed: ${message}`);
|
|
209
|
+
return json({ error: message }, 400);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return methodNotAllowed();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── profiles ─────────────────────────────────────────────────────────────
|
|
216
|
+
const profilesMatch = /^\/api\/projects\/([^/]+)\/profiles$/.exec(p);
|
|
217
|
+
if (profilesMatch) {
|
|
218
|
+
const projectId = decodeURIComponent(profilesMatch[1]);
|
|
219
|
+
if (!ID_PATTERN.test(projectId)) return badId("project");
|
|
220
|
+
|
|
221
|
+
if (req.method === "GET") return json(listProfiles(db, projectId));
|
|
222
|
+
|
|
223
|
+
if (req.method === "POST") {
|
|
224
|
+
try {
|
|
225
|
+
const body = await readJson<Record<string, unknown>>(req);
|
|
226
|
+
return json(createProfile(db, projectId, body), 201);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
return json({ error: describeError(err) }, 400);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return methodNotAllowed();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const profileMatch = /^\/api\/profiles\/([^/]+)$/.exec(p);
|
|
235
|
+
if (profileMatch) {
|
|
236
|
+
const id = decodeURIComponent(profileMatch[1]);
|
|
237
|
+
if (!ID_PATTERN.test(id)) return badId("profile");
|
|
238
|
+
|
|
239
|
+
if (req.method === "PATCH") {
|
|
240
|
+
try {
|
|
241
|
+
const body = await readJson<Record<string, unknown>>(req);
|
|
242
|
+
const updated = updateProfile(db, id, body);
|
|
243
|
+
return updated
|
|
244
|
+
? json(updated)
|
|
245
|
+
: json({ error: "unknown profile" }, 404);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
return json({ error: describeError(err) }, 400);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (req.method === "DELETE") {
|
|
252
|
+
// Windows opened as this profile keep running and become plain
|
|
253
|
+
// terminals; ending live sessions is what the close button is for.
|
|
254
|
+
return deleteProfile(db, id)
|
|
255
|
+
? json({ ok: true })
|
|
256
|
+
: json({ error: "unknown profile" }, 404);
|
|
257
|
+
}
|
|
258
|
+
return methodNotAllowed();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const workspaceMatch = /^\/api\/workspaces\/([^/]+)$/.exec(p);
|
|
262
|
+
if (workspaceMatch) {
|
|
263
|
+
const id = decodeURIComponent(workspaceMatch[1]);
|
|
264
|
+
if (!ID_PATTERN.test(id)) return badId("workspace");
|
|
265
|
+
|
|
266
|
+
if (req.method === "DELETE") {
|
|
267
|
+
// Every window here has a live session; removing the worktree
|
|
268
|
+
// without ending them leaves shells sitting in a deleted directory.
|
|
269
|
+
for (const win of listWindows(db, id)) {
|
|
270
|
+
const target = sessionNameFor(db, win.id);
|
|
271
|
+
if (target && config.sessions)
|
|
272
|
+
await killSession(config, target.session).catch(() => {});
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
await removeWorkspace(db, config, id);
|
|
276
|
+
return json({ ok: true });
|
|
277
|
+
} catch (err) {
|
|
278
|
+
return json({ error: describeError(err) }, 400);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (req.method === "POST") {
|
|
283
|
+
touchWorkspace(db, id);
|
|
284
|
+
return json({ ok: true });
|
|
285
|
+
}
|
|
286
|
+
return methodNotAllowed();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── windows ──────────────────────────────────────────────────────────────
|
|
290
|
+
const windowsMatch = /^\/api\/workspaces\/([^/]+)\/windows$/.exec(p);
|
|
291
|
+
if (windowsMatch) {
|
|
292
|
+
const workspaceId = decodeURIComponent(windowsMatch[1]);
|
|
293
|
+
if (!ID_PATTERN.test(workspaceId)) return badId("workspace");
|
|
294
|
+
const workspace = getWorkspace(db, workspaceId);
|
|
295
|
+
if (!workspace) return json({ error: "unknown workspace" }, 404);
|
|
296
|
+
|
|
297
|
+
if (req.method === "GET") {
|
|
298
|
+
// Cheap unless something is actually handed off, and it is what makes
|
|
299
|
+
// closing an ssh session give the window back on its own.
|
|
300
|
+
await reapStaleHandoffs(db, config).catch((err: unknown) => {
|
|
301
|
+
log.warn(`could not check handoffs: ${describeError(err)}`);
|
|
302
|
+
});
|
|
303
|
+
return json(listWindows(db, workspaceId));
|
|
304
|
+
}
|
|
305
|
+
if (req.method === "POST") {
|
|
306
|
+
const body = await readJson<{ profileId?: unknown }>(req);
|
|
307
|
+
let profileId: string | null = null;
|
|
308
|
+
if (typeof body.profileId === "string" && body.profileId !== "") {
|
|
309
|
+
const profile = getProfile(db, body.profileId);
|
|
310
|
+
// A profile belongs to a project, so it may only open windows in that
|
|
311
|
+
// project's workspaces — otherwise one project's flags and prompt
|
|
312
|
+
// could be launched inside another project's worktree.
|
|
313
|
+
if (!profile || profile.projectId !== workspace.projectId) {
|
|
314
|
+
return json({ error: "unknown profile for this workspace" }, 400);
|
|
315
|
+
}
|
|
316
|
+
profileId = profile.id;
|
|
317
|
+
}
|
|
318
|
+
return json(createWindow(db, workspaceId, profileId), 201);
|
|
319
|
+
}
|
|
320
|
+
return methodNotAllowed();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// How to reach this window from a real terminal. Read-only: it composes
|
|
324
|
+
// strings a person can copy, and never runs anything itself.
|
|
325
|
+
const attachMatch = /^\/api\/windows\/([^/]+)\/attach$/.exec(p);
|
|
326
|
+
if (attachMatch && req.method === "GET") {
|
|
327
|
+
const id = decodeURIComponent(attachMatch[1]);
|
|
328
|
+
if (!ID_PATTERN.test(id)) return badId("window");
|
|
329
|
+
if (!config.sessions)
|
|
330
|
+
return json(
|
|
331
|
+
{ error: "this server runs plain login shells (--no-sessions)" },
|
|
332
|
+
409,
|
|
333
|
+
);
|
|
334
|
+
const info = attachInfo(db, config, id, publicHost(req, url));
|
|
335
|
+
return info ? json(info) : json({ error: "unknown window" }, 404);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Hands the window's terminal to an ssh client, or takes it back — which
|
|
339
|
+
// detaches whoever is attached, so there is never a second client.
|
|
340
|
+
const handoffMatch = /^\/api\/windows\/([^/]+)\/handoff$/.exec(p);
|
|
341
|
+
if (handoffMatch && req.method === "POST") {
|
|
342
|
+
const id = decodeURIComponent(handoffMatch[1]);
|
|
343
|
+
if (!ID_PATTERN.test(id)) return badId("window");
|
|
344
|
+
const body = await readJson<{ mode?: unknown }>(req);
|
|
345
|
+
if (body.mode !== "ssh" && body.mode !== null)
|
|
346
|
+
return json({ error: 'mode must be "ssh" or null' }, 400);
|
|
347
|
+
if (body.mode === "ssh" && !config.sessions) {
|
|
348
|
+
return json(
|
|
349
|
+
{ error: "this server runs plain login shells (--no-sessions)" },
|
|
350
|
+
409,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
const ok = await setHandoff(db, config, id, body.mode);
|
|
354
|
+
if (!ok) return json({ error: "unknown window" }, 404);
|
|
355
|
+
return json(getWindow(db, id) ?? { ok: true });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const windowMatch = /^\/api\/windows\/([^/]+)$/.exec(p);
|
|
359
|
+
if (windowMatch) {
|
|
360
|
+
const id = decodeURIComponent(windowMatch[1]);
|
|
361
|
+
if (!ID_PATTERN.test(id)) return badId("window");
|
|
362
|
+
|
|
363
|
+
if (req.method === "PATCH") {
|
|
364
|
+
const body = await readJson<Record<string, unknown>>(req);
|
|
365
|
+
const updated = updateWindow(db, id, body);
|
|
366
|
+
return updated ? json(updated) : json({ error: "unknown window" }, 404);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (req.method === "DELETE") {
|
|
370
|
+
// Closing a window is an explicit "I am done with this", so the session
|
|
371
|
+
// goes too. Window indices are reused and `new-session -A` attaches, so
|
|
372
|
+
// leaving it alive would silently resurrect it in the next window.
|
|
373
|
+
const target = sessionNameFor(db, id);
|
|
374
|
+
if (target && config.sessions) {
|
|
375
|
+
await killSession(config, target.session)
|
|
376
|
+
.then(() => log.info(`ended session ${target.session}`))
|
|
377
|
+
.catch(() => {});
|
|
378
|
+
}
|
|
379
|
+
return deleteWindow(db, id)
|
|
380
|
+
? json({ ok: true })
|
|
381
|
+
: json({ error: "unknown window" }, 404);
|
|
382
|
+
}
|
|
383
|
+
return methodNotAllowed();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ── certificates ─────────────────────────────────────────────────────────
|
|
387
|
+
if (p === "/api/ssh/certificate") {
|
|
388
|
+
if (req.method !== "POST") return methodNotAllowed();
|
|
389
|
+
|
|
390
|
+
const windowId = url.searchParams.get("window") ?? "";
|
|
391
|
+
if (!ID_PATTERN.test(windowId)) return badId("window");
|
|
392
|
+
|
|
393
|
+
const target = sessionNameFor(db, windowId);
|
|
394
|
+
if (!target) return json({ error: "unknown window" }, 404);
|
|
395
|
+
|
|
396
|
+
if (Number(req.headers.get("content-length") ?? 0) > MAX_PUBKEY_BYTES) {
|
|
397
|
+
return json({ error: "public key too large" }, 413);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
const publicKey = await req.text();
|
|
402
|
+
if (publicKey.length > MAX_PUBKEY_BYTES)
|
|
403
|
+
throw new Error("public key too large");
|
|
404
|
+
|
|
405
|
+
// Read from the window row, never from the request: the browser asks
|
|
406
|
+
// for a window, and the server decides what that window runs.
|
|
407
|
+
const profile = target.profileId
|
|
408
|
+
? getProfile(db, target.profileId)
|
|
409
|
+
: undefined;
|
|
410
|
+
|
|
411
|
+
// A profile's `--mcp-config` paths are only as good as the files behind
|
|
412
|
+
// them, and those are regenerated from the box's config. Doing it here
|
|
413
|
+
// means a server edited with `claude mcp add` takes effect on the next
|
|
414
|
+
// window rather than whenever the editor was last opened.
|
|
415
|
+
if (profile?.harness === "claude") {
|
|
416
|
+
await refreshMcpMirrors(db, config.stateDir).catch((err: unknown) => {
|
|
417
|
+
log.warn(`could not refresh mcp configs: ${describeError(err)}`);
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const forceCommand = windowCommand(
|
|
422
|
+
target.session,
|
|
423
|
+
target.cwd,
|
|
424
|
+
config,
|
|
425
|
+
profile,
|
|
426
|
+
);
|
|
427
|
+
const cert = await ca.signUserCert({
|
|
428
|
+
publicKey,
|
|
429
|
+
principal: config.user,
|
|
430
|
+
identity: `vibe-os/${target.session}`,
|
|
431
|
+
forceCommand,
|
|
432
|
+
ttlSeconds: config.certTtlSeconds,
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
log.info(
|
|
436
|
+
`issued certificate for ${target.session} in ${target.cwd}${profile ? ` as ${profile.name}` : ""}`,
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
// sshterm compares this header for exact equality against "text/plain";
|
|
440
|
+
// a "; charset=utf-8" suffix makes it reject the certificate.
|
|
441
|
+
return new Response(cert, {
|
|
442
|
+
status: 200,
|
|
443
|
+
headers: {
|
|
444
|
+
"content-type": "text/plain",
|
|
445
|
+
"cache-control": "no-store",
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
} catch (err) {
|
|
449
|
+
const message = describeError(err);
|
|
450
|
+
log.warn(`certificate request rejected: ${message}`);
|
|
451
|
+
return json({ error: message }, 400);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ── desktop preferences ──────────────────────────────────────────────────
|
|
456
|
+
if (p === "/api/desktop") {
|
|
457
|
+
if (req.method === "GET") return json(await wallpapers.prefs());
|
|
458
|
+
if (req.method === "PUT") {
|
|
459
|
+
try {
|
|
460
|
+
const body = (await req.json()) as DesktopPrefs;
|
|
461
|
+
return json(await wallpapers.setPrefs(body));
|
|
462
|
+
} catch (err) {
|
|
463
|
+
return json({ error: describeError(err) }, 400);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return methodNotAllowed();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ── wallpapers ───────────────────────────────────────────────────────────
|
|
470
|
+
if (p === "/api/wallpapers") {
|
|
471
|
+
if (req.method === "GET") return json(await wallpapers.list());
|
|
472
|
+
if (req.method === "POST") {
|
|
473
|
+
if (
|
|
474
|
+
Number(req.headers.get("content-length") ?? 0) > MAX_WALLPAPER_BYTES
|
|
475
|
+
) {
|
|
476
|
+
return json({ error: "image too large" }, 413);
|
|
477
|
+
}
|
|
478
|
+
try {
|
|
479
|
+
const name = url.searchParams.get("name") ?? "wallpaper";
|
|
480
|
+
const bytes = new Uint8Array(await req.arrayBuffer());
|
|
481
|
+
return json(await wallpapers.save(bytes, name), 201);
|
|
482
|
+
} catch (err) {
|
|
483
|
+
return json({ error: describeError(err) }, 400);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return methodNotAllowed();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (p.startsWith("/api/wallpapers/")) {
|
|
490
|
+
const id = p.slice("/api/wallpapers/".length);
|
|
491
|
+
if (req.method === "GET") {
|
|
492
|
+
const found = await wallpapers.read(id);
|
|
493
|
+
if (!found) return json({ error: "not found" }, 404);
|
|
494
|
+
return new Response(found.file, {
|
|
495
|
+
headers: {
|
|
496
|
+
"content-type": found.mime,
|
|
497
|
+
"cache-control": IMMUTABLE_CACHE_CONTROL,
|
|
498
|
+
// Defence in depth: even if a hostile file slipped past the magic
|
|
499
|
+
// byte check, the browser must not be talked into running it.
|
|
500
|
+
"content-security-policy": "default-src 'none'; sandbox",
|
|
501
|
+
"x-content-type-options": "nosniff",
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
if (req.method === "DELETE") {
|
|
506
|
+
return (await wallpapers.remove(id))
|
|
507
|
+
? json({ ok: true })
|
|
508
|
+
: json({ error: "not found" }, 404);
|
|
509
|
+
}
|
|
510
|
+
return methodNotAllowed();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return json({ error: "not found" }, 404);
|
|
514
|
+
};
|
|
515
|
+
}
|