@bivy/bivy 0.16.25-staging.35 → 0.16.25-staging.37
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/README.md +3 -0
- package/bin/bivy.mjs +55 -0
- package/dist/apps/gateway.js +357 -0
- package/dist/apps/preview-shell.js +55 -0
- package/dist/apps/registry.js +160 -0
- package/dist/apps/service.js +62 -0
- package/dist/apps/styles.css +3165 -0
- package/dist/apps/tokens.css +203 -0
- package/dist/apps/types.js +9 -0
- package/dist/controllers/app-commands.js +28 -0
- package/dist/protocol/client-command-routes.js +1 -0
- package/dist/protocol/client-command-schemas.js +4 -0
- package/dist/server.js +47 -0
- package/dist/session/event-log.js +21 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -206,6 +206,9 @@ answer questions, approve supported tool calls, or stop the agent.
|
|
|
206
206
|
|
|
207
207
|
- Send screenshots, images, logs, and other files from your phone.
|
|
208
208
|
- Download reports and artifacts the agent creates.
|
|
209
|
+
- Publish [session apps](docs/apps.md) with web and CLI/TUI views using
|
|
210
|
+
`bivy app publish bivy.app.json`. Web previews require a separately configured
|
|
211
|
+
HTTPS preview domain; terminal views use the existing connection.
|
|
209
212
|
- Use voice input and read-aloud where supported; provider-backed voice may
|
|
210
213
|
send audio or text to the selected provider.
|
|
211
214
|
- Keep a native terminal workflow or use structured chat, depending on the agent.
|
package/bin/bivy.mjs
CHANGED
|
@@ -2418,6 +2418,57 @@ async function cmdAttach(args = []) {
|
|
|
2418
2418
|
console.log(c.green(`Attached ${body.name} (${body.kind}, ${body.size} bytes) to the chat.`));
|
|
2419
2419
|
}
|
|
2420
2420
|
|
|
2421
|
+
// Universal app publishing: every agent that can write JSON and run a command
|
|
2422
|
+
// uses the same contract. Relative static directories are manifest-relative.
|
|
2423
|
+
async function cmdApp(args = []) {
|
|
2424
|
+
const [action, ...rest] = args;
|
|
2425
|
+
if (!action || rest.some((arg) => arg === "--help" || arg === "-h") || ["help", "--help", "-h"].includes(action)) {
|
|
2426
|
+
console.log(`Usage: bivy app publish <manifest.json> [--session <id>]
|
|
2427
|
+
bivy app list [--session <id>]
|
|
2428
|
+
bivy app remove <app-id> [--session <id>]
|
|
2429
|
+
|
|
2430
|
+
Manifest: {"version":1,"name":"My app","views":[
|
|
2431
|
+
{"kind":"web","name":"Website","source":{"kind":"service","port":3000}},
|
|
2432
|
+
{"kind":"terminal","name":"Console","command":"bin/rails","args":["console"]}
|
|
2433
|
+
]}
|
|
2434
|
+
|
|
2435
|
+
Static web source: {"kind":"static","directory":"./dist"}
|
|
2436
|
+
Publishing does not start commands. Open Apps in the session menu to view or run.
|
|
2437
|
+
Terminal commands run with the node user's permissions, not in a new sandbox.
|
|
2438
|
+
Web previews require operator setup; see docs/apps.md.`);
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
if (!["publish", "list", "remove"].includes(action)) throw new Error("Unknown app command. Run bivy app --help.");
|
|
2442
|
+
const sessionIndex = rest.indexOf("--session");
|
|
2443
|
+
if (sessionIndex >= 0 && (!rest[sessionIndex + 1] || rest[sessionIndex + 1].startsWith("--"))) throw new Error("--session requires an id.");
|
|
2444
|
+
const sessionId = resolveAttachSessionId({ sessionFlag: sessionIndex >= 0 ? rest[sessionIndex + 1] : undefined, env: process.env });
|
|
2445
|
+
if (!sessionId) throw new Error("Set --session <id> or run inside a Bivy agent session.");
|
|
2446
|
+
const positional = rest.filter((_, i) => i !== sessionIndex && (sessionIndex < 0 || i !== sessionIndex + 1));
|
|
2447
|
+
if (positional.some((a) => a.startsWith("-")) || positional.length !== (action === "list" ? 0 : 1)) throw new Error("Invalid arguments. Run bivy app --help.");
|
|
2448
|
+
const body = { sessionId };
|
|
2449
|
+
if (action === "publish") {
|
|
2450
|
+
const file = path.resolve(positional[0]);
|
|
2451
|
+
if (fs.statSync(file).size > 64 * 1024) throw new Error("App manifest exceeds 64 KiB.");
|
|
2452
|
+
const manifest = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
2453
|
+
if (!manifest || typeof manifest !== "object") throw new Error("App manifest must be a JSON object.");
|
|
2454
|
+
if (Array.isArray(manifest.views)) for (const view of manifest.views) {
|
|
2455
|
+
if (view?.kind === "web" && view.source?.kind === "static" && typeof view.source.directory === "string") {
|
|
2456
|
+
view.source.directory = path.resolve(path.dirname(file), view.source.directory);
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
body.manifest = manifest;
|
|
2460
|
+
} else if (action === "remove") body.appId = positional[0];
|
|
2461
|
+
const config = loadConfig();
|
|
2462
|
+
if (!(await ensureNodeRunning(config))) throw new Error("Could not reach the Bivy node.");
|
|
2463
|
+
const token = await localDeviceToken(config);
|
|
2464
|
+
const response = await fetch(`${url(config)}/api/apps/${action}`, {
|
|
2465
|
+
method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify(body),
|
|
2466
|
+
});
|
|
2467
|
+
const result = await response.json();
|
|
2468
|
+
if (!response.ok || result.error) throw new Error(result.error || `App command failed (${response.status}).`);
|
|
2469
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2421
2472
|
// Map a saved session's runtime id to the `bivy run` agent whose native CLI can
|
|
2422
2473
|
// resume it in a terminal. Only agents with a real native resume qualify; other
|
|
2423
2474
|
// runtimes (generic-cli, SDK-only) have no terminal resume and open in the web app.
|
|
@@ -5045,6 +5096,7 @@ ${c.bold("bivy")} — Bivy node CLI
|
|
|
5045
5096
|
${c.cyan("bivy token")} Print a device token for this node (for 'bivy nodes add' elsewhere)
|
|
5046
5097
|
${c.cyan("bivy sessions")} List recent sessions (live + saved) and resume one (alias: ls)
|
|
5047
5098
|
${c.cyan("bivy resume")} [n|id] Resume a session directly (default: most recent)
|
|
5099
|
+
${c.cyan("bivy app")} Publish and list session apps (web and terminal views)
|
|
5048
5100
|
${c.cyan("bivy send <id>")} "..." Send a prompt to an existing session and stream the reply
|
|
5049
5101
|
${c.cyan("bivy kill <id>")} Stop a session/terminal (--delete also removes a saved session)
|
|
5050
5102
|
${c.cyan("bivy prune")} Delete old sessions/workspaces/worktrees (--keep N, --older-than 7d, --dry-run)
|
|
@@ -5167,6 +5219,9 @@ An agent's own --help passes through, e.g. 'bivy run claude --help'.`);
|
|
|
5167
5219
|
case "send":
|
|
5168
5220
|
await cmdSend(args);
|
|
5169
5221
|
break;
|
|
5222
|
+
case "app":
|
|
5223
|
+
await cmdApp(args);
|
|
5224
|
+
break;
|
|
5170
5225
|
case "attach":
|
|
5171
5226
|
await cmdAttach(args);
|
|
5172
5227
|
break;
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { previewShell } from "./preview-shell.js";
|
|
8
|
+
const MIME = {
|
|
9
|
+
".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8",
|
|
10
|
+
".css": "text/css; charset=utf-8", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png",
|
|
11
|
+
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".ico": "image/x-icon",
|
|
12
|
+
".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".wasm": "application/wasm",
|
|
13
|
+
".txt": "text/plain; charset=utf-8", ".pdf": "application/pdf", ".mp4": "video/mp4", ".webm": "video/webm",
|
|
14
|
+
};
|
|
15
|
+
const COOKIE = "__Host-bivy-preview";
|
|
16
|
+
const OPEN_PATH = "/__bivy/open";
|
|
17
|
+
const REDEEM_PATH = "/__bivy/redeem";
|
|
18
|
+
const HOUR = 60 * 60_000;
|
|
19
|
+
const HOP = new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"]);
|
|
20
|
+
/** Separate per-app origins are mandatory. TLS terminates at the operator's
|
|
21
|
+
* reverse proxy, which must preserve Host and reach ONLY this gateway port. */
|
|
22
|
+
export function previewOriginTemplate(raw) {
|
|
23
|
+
const parsed = new URL(raw.replace("{app}", "a"));
|
|
24
|
+
if (!/^https:\/\/\{app\}\.[a-z0-9.-]+\/?$/.test(raw) || raw.split("{app}").length !== 2 || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash || parsed.port) {
|
|
25
|
+
throw new Error("BIVY_APPS_ORIGIN must be https://{app}.<dedicated-preview-domain> (no path or port).");
|
|
26
|
+
}
|
|
27
|
+
return raw.replace(/\/$/, "").toLowerCase();
|
|
28
|
+
}
|
|
29
|
+
function cleanHeaders(headers) {
|
|
30
|
+
const blocked = new Set([...HOP, ...(headers.connection ?? "").toLowerCase().split(",").map((s) => s.trim())]);
|
|
31
|
+
return Object.fromEntries(Object.entries(headers).filter(([key]) => !blocked.has(key) && !key.startsWith("x-forwarded-") && key !== "forwarded"));
|
|
32
|
+
}
|
|
33
|
+
function upstreamHeaders(req, origin) {
|
|
34
|
+
const headers = cleanHeaders(req.headers);
|
|
35
|
+
// The preview credential is never disclosed to generated code/backends.
|
|
36
|
+
headers.cookie = (req.headers.cookie ?? "").split(";").filter((part) => part.trim().split("=", 1)[0] !== COOKIE).join(";");
|
|
37
|
+
headers.host = new URL(origin).host;
|
|
38
|
+
headers["x-forwarded-host"] = headers.host;
|
|
39
|
+
headers["x-forwarded-proto"] = "https";
|
|
40
|
+
return headers;
|
|
41
|
+
}
|
|
42
|
+
function responseHeaders(headers, shellOrigin) {
|
|
43
|
+
const result = cleanHeaders(headers);
|
|
44
|
+
if (headers["set-cookie"]) {
|
|
45
|
+
result["set-cookie"] = headers["set-cookie"].filter((cookie) => cookie.split("=", 1)[0].trim() !== COOKIE)
|
|
46
|
+
.map((cookie) => cookie.replace(/;\s*domain=[^;]*/ig, ""));
|
|
47
|
+
}
|
|
48
|
+
// Preview data must not become a shared proxy cache entry. Apps cannot frame
|
|
49
|
+
// Bivy, retain an opener, or cache an offline service-worker copy of the gate.
|
|
50
|
+
result["cache-control"] = "no-store";
|
|
51
|
+
result["referrer-policy"] = "no-referrer";
|
|
52
|
+
result["cross-origin-opener-policy"] = "same-origin";
|
|
53
|
+
result["x-content-type-options"] = "nosniff";
|
|
54
|
+
// The preview shell is the only allowed ancestor. Preserve the app's other
|
|
55
|
+
// CSP directives, but replace framing restrictions with this narrower host.
|
|
56
|
+
delete result["x-frame-options"];
|
|
57
|
+
const csp = result["content-security-policy"];
|
|
58
|
+
const policies = (Array.isArray(csp) ? csp : csp ? [String(csp)] : []).map((policy) => policy.replace(/(^|[;,])\s*frame-ancestors[^;,]*/gi, "$1"));
|
|
59
|
+
result["content-security-policy"] = [...policies, `frame-ancestors ${shellOrigin}; worker-src 'none'`].join(", ");
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
export class AppGateway {
|
|
63
|
+
registry;
|
|
64
|
+
returnOrigins;
|
|
65
|
+
server;
|
|
66
|
+
template;
|
|
67
|
+
tickets = new Map();
|
|
68
|
+
styles = new Map([
|
|
69
|
+
["/__bivy/tokens.css", readFileSync(new URL("./tokens.css", import.meta.url))],
|
|
70
|
+
["/__bivy/styles.css", readFileSync(new URL("./styles.css", import.meta.url))],
|
|
71
|
+
]);
|
|
72
|
+
sessions = new Map();
|
|
73
|
+
sockets = new Map();
|
|
74
|
+
constructor(registry, originTemplate, returnOrigins = () => []) {
|
|
75
|
+
this.registry = registry;
|
|
76
|
+
this.returnOrigins = returnOrigins;
|
|
77
|
+
this.template = previewOriginTemplate(originTemplate);
|
|
78
|
+
this.server = http.createServer((req, res) => { void this.handle(req, res).catch(() => { if (!res.headersSent)
|
|
79
|
+
res.writeHead(502); res.end("Preview request failed."); }); });
|
|
80
|
+
this.server.maxConnections = 256;
|
|
81
|
+
this.server.headersTimeout = 15_000;
|
|
82
|
+
this.server.requestTimeout = 60_000;
|
|
83
|
+
this.server.on("upgrade", (req, socket, head) => this.upgrade(req, socket, head));
|
|
84
|
+
}
|
|
85
|
+
origin(id) { return this.template.replace("{app}", id); }
|
|
86
|
+
shellOrigin(id) { return this.template.replace("{app}", `view-${id}`); }
|
|
87
|
+
open(id, returnTo) {
|
|
88
|
+
const entry = this.registry.getView(id);
|
|
89
|
+
if (entry?.view.kind !== "web")
|
|
90
|
+
throw new Error("Web view not found.");
|
|
91
|
+
if (returnTo) {
|
|
92
|
+
const url = new URL(returnTo);
|
|
93
|
+
const safeScheme = url.protocol === "https:" || (url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname));
|
|
94
|
+
if (!this.returnOrigins().includes(url.origin))
|
|
95
|
+
throw new Error("Return-to-chat origin is not configured on this machine. Set BIVY_APPS_RETURN_ORIGINS for an additional Bivy client origin.");
|
|
96
|
+
if (!safeScheme || url.username || url.password || url.search || url.hash || url.pathname !== `/sessions/${encodeURIComponent(entry.app.sessionId)}`)
|
|
97
|
+
throw new Error("Invalid return-to-chat URL.");
|
|
98
|
+
returnTo = url.href;
|
|
99
|
+
}
|
|
100
|
+
this.sweep();
|
|
101
|
+
if (this.tickets.size >= 500 || this.sessions.size >= 500)
|
|
102
|
+
throw new Error("Too many preview grants. Try again later.");
|
|
103
|
+
const ticket = randomBytes(32).toString("hex");
|
|
104
|
+
this.tickets.set(ticket, { appId: id, expires: Date.now() + 60_000, returnTo });
|
|
105
|
+
// Fragment never reaches reverse-proxy access logs or the application.
|
|
106
|
+
return `${this.shellOrigin(id)}${OPEN_PATH}#${ticket}`;
|
|
107
|
+
}
|
|
108
|
+
revoke(id) {
|
|
109
|
+
for (const map of [this.tickets, this.sessions])
|
|
110
|
+
for (const [key, grant] of map)
|
|
111
|
+
if (grant.appId === id)
|
|
112
|
+
map.delete(key);
|
|
113
|
+
for (const socket of this.sockets.get(id) ?? [])
|
|
114
|
+
socket.destroy();
|
|
115
|
+
this.sockets.delete(id);
|
|
116
|
+
}
|
|
117
|
+
close() {
|
|
118
|
+
for (const id of this.sockets.keys())
|
|
119
|
+
this.revoke(id);
|
|
120
|
+
this.tickets.clear();
|
|
121
|
+
this.sessions.clear();
|
|
122
|
+
this.server.close();
|
|
123
|
+
this.server.closeAllConnections();
|
|
124
|
+
}
|
|
125
|
+
sweep() {
|
|
126
|
+
for (const map of [this.tickets, this.sessions])
|
|
127
|
+
for (const [key, grant] of map)
|
|
128
|
+
if (grant.expires <= Date.now())
|
|
129
|
+
map.delete(key);
|
|
130
|
+
}
|
|
131
|
+
entry(req) {
|
|
132
|
+
const host = req.headers.host ?? "";
|
|
133
|
+
const id = host.split(".")[0];
|
|
134
|
+
if (!/^[a-f0-9]{32}$/.test(id) || host !== new URL(this.origin(id)).host)
|
|
135
|
+
return undefined;
|
|
136
|
+
const entry = this.registry.getView(id);
|
|
137
|
+
return entry?.view.kind === "web" ? entry : undefined;
|
|
138
|
+
}
|
|
139
|
+
authorize(req, id) {
|
|
140
|
+
this.sweep();
|
|
141
|
+
const token = (req.headers.cookie ?? "").split(";").map((part) => part.trim()).find((part) => part.startsWith(`${COOKIE}=`))?.slice(COOKIE.length + 1);
|
|
142
|
+
const session = token && this.sessions.get(token);
|
|
143
|
+
return session && session.appId === id ? session.expires : undefined;
|
|
144
|
+
}
|
|
145
|
+
track(id, socket, expires) {
|
|
146
|
+
let sockets = this.sockets.get(id);
|
|
147
|
+
if (!sockets) {
|
|
148
|
+
sockets = new Set();
|
|
149
|
+
this.sockets.set(id, sockets);
|
|
150
|
+
}
|
|
151
|
+
if (sockets.has(socket))
|
|
152
|
+
return;
|
|
153
|
+
sockets.add(socket);
|
|
154
|
+
const timer = setTimeout(() => socket.destroy(), Math.max(1, expires - Date.now()));
|
|
155
|
+
timer.unref();
|
|
156
|
+
socket.once("close", () => { clearTimeout(timer); sockets.delete(socket); if (!sockets.size)
|
|
157
|
+
this.sockets.delete(id); });
|
|
158
|
+
}
|
|
159
|
+
async handleShell(req, res, id) {
|
|
160
|
+
const origin = this.shellOrigin(id);
|
|
161
|
+
const nonce = randomBytes(16).toString("hex");
|
|
162
|
+
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
163
|
+
res.setHeader("Content-Security-Policy", `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}'; connect-src 'self'; frame-src ${this.origin(id)}; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`);
|
|
164
|
+
if (req.method === "GET" && this.styles.has(req.url ?? "")) {
|
|
165
|
+
res.setHeader("Content-Type", "text/css; charset=utf-8");
|
|
166
|
+
res.end(this.styles.get(req.url));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (req.url === OPEN_PATH && req.method === "GET") {
|
|
170
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
171
|
+
res.end(previewShell(nonce));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (req.url === "/__bivy/launch" && req.method === "POST") {
|
|
175
|
+
if (req.headers.origin !== origin) {
|
|
176
|
+
res.writeHead(403);
|
|
177
|
+
res.end();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
let ticket = "";
|
|
181
|
+
for await (const chunk of req) {
|
|
182
|
+
ticket += chunk.toString();
|
|
183
|
+
if (ticket.length > 128) {
|
|
184
|
+
res.writeHead(413);
|
|
185
|
+
res.end();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
this.sweep();
|
|
190
|
+
const grant = this.tickets.get(ticket);
|
|
191
|
+
const entry = this.registry.getView(id);
|
|
192
|
+
if (!grant || grant.appId !== id || !entry) {
|
|
193
|
+
res.writeHead(401);
|
|
194
|
+
res.end();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
res.setHeader("Content-Type", "application/json");
|
|
198
|
+
res.end(JSON.stringify({ name: entry.app.name, origin: this.origin(id), returnTo: grant.returnTo }));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
res.writeHead(404);
|
|
202
|
+
res.end("Preview shell route not found.");
|
|
203
|
+
}
|
|
204
|
+
async handle(req, res) {
|
|
205
|
+
const entry = this.entry(req);
|
|
206
|
+
res.setHeader("Cache-Control", "no-store");
|
|
207
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
208
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
209
|
+
const shellId = /^view-([a-f0-9]{32})\./.exec(req.headers.host ?? "")?.[1];
|
|
210
|
+
if (shellId && req.headers.host === new URL(this.shellOrigin(shellId)).host) {
|
|
211
|
+
await this.handleShell(req, res, shellId);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (!entry) {
|
|
215
|
+
res.writeHead(404);
|
|
216
|
+
res.end("App unavailable. Republish from your Bivy session.");
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const id = entry.view.id;
|
|
220
|
+
if (req.url === OPEN_PATH && req.method === "GET") {
|
|
221
|
+
const nonce = randomBytes(16).toString("hex");
|
|
222
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
223
|
+
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
224
|
+
res.setHeader("Content-Security-Policy", `default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors ${this.shellOrigin(id)}; base-uri 'none'`);
|
|
225
|
+
res.end(`<!doctype html><html lang="en"><meta name="viewport" content="width=device-width"><title>Open Bivy preview</title><p id="status" role="status">Opening preview…</p><script nonce="${nonce}">const ticket=location.hash.slice(1);history.replaceState(null,'',location.pathname);fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body:ticket}).then(r=>{if(!r.ok)throw Error();location.replace('/');}).catch(()=>{document.getElementById('status').textContent='Preview link expired or cookies are blocked. Open a new link from Bivy.';});</script></html>`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (req.url === REDEEM_PATH && req.method === "POST") {
|
|
229
|
+
if (req.headers.origin !== this.origin(id)) {
|
|
230
|
+
res.writeHead(403);
|
|
231
|
+
res.end();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
let body = "";
|
|
235
|
+
for await (const chunk of req) {
|
|
236
|
+
body += chunk.toString();
|
|
237
|
+
if (body.length > 128) {
|
|
238
|
+
res.writeHead(413);
|
|
239
|
+
res.end();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
this.sweep();
|
|
244
|
+
const grant = this.tickets.get(body);
|
|
245
|
+
if (!grant || grant.appId !== id || this.sessions.size >= 500) {
|
|
246
|
+
res.writeHead(401);
|
|
247
|
+
res.end();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
this.tickets.delete(body);
|
|
251
|
+
const token = randomBytes(32).toString("hex");
|
|
252
|
+
this.sessions.set(token, { appId: id, expires: Date.now() + HOUR });
|
|
253
|
+
res.setHeader("Set-Cookie", `${COOKIE}=${token}; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=3600`);
|
|
254
|
+
res.writeHead(204);
|
|
255
|
+
res.end();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const expires = this.authorize(req, id);
|
|
259
|
+
if (!expires) {
|
|
260
|
+
res.writeHead(401);
|
|
261
|
+
res.end("Preview access expired. Open a new preview link from Bivy.");
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
// Block cross-site mutations even if a client sends the preview cookie.
|
|
265
|
+
if (!["GET", "HEAD"].includes(req.method ?? "") && req.headers.origin !== this.origin(id)) {
|
|
266
|
+
res.writeHead(403);
|
|
267
|
+
res.end("Forbidden origin.");
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (req.headers["sec-fetch-dest"] === "serviceworker") {
|
|
271
|
+
res.writeHead(403);
|
|
272
|
+
res.end();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (!req.url?.startsWith("/") || req.url.startsWith("//")) {
|
|
276
|
+
res.writeHead(400);
|
|
277
|
+
res.end();
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
this.track(id, req.socket, expires);
|
|
281
|
+
if (entry.target.kind === "static") {
|
|
282
|
+
if (!["GET", "HEAD"].includes(req.method ?? "")) {
|
|
283
|
+
res.writeHead(405, { Allow: "GET, HEAD" });
|
|
284
|
+
res.end();
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
let file;
|
|
288
|
+
try {
|
|
289
|
+
file = decodeURIComponent(req.url.split("?")[0]);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
res.writeHead(400);
|
|
293
|
+
res.end();
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (file.endsWith("/"))
|
|
297
|
+
file += "index.html";
|
|
298
|
+
const data = entry.target.files.get(file);
|
|
299
|
+
if (!data) {
|
|
300
|
+
res.writeHead(404);
|
|
301
|
+
res.end("File not found.");
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const headers = responseHeaders({}, this.shellOrigin(id));
|
|
305
|
+
headers["content-type"] = MIME[path.extname(file).toLowerCase()] || "application/octet-stream";
|
|
306
|
+
res.writeHead(200, { ...headers, "content-length": data.length });
|
|
307
|
+
res.end(req.method === "HEAD" ? undefined : data);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (entry.target.kind !== "service") {
|
|
311
|
+
res.writeHead(404);
|
|
312
|
+
res.end();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const upstream = http.request({ hostname: "127.0.0.1", port: entry.target.port, path: req.url, method: req.method, headers: upstreamHeaders(req, this.origin(id)) }, (response) => {
|
|
316
|
+
res.writeHead(response.statusCode ?? 502, responseHeaders(response.headers, this.shellOrigin(id)));
|
|
317
|
+
response.on("error", () => res.destroy());
|
|
318
|
+
response.pipe(res);
|
|
319
|
+
});
|
|
320
|
+
upstream.setTimeout(60_000, () => upstream.destroy(new Error("Preview timed out")));
|
|
321
|
+
upstream.on("error", () => { if (!res.headersSent)
|
|
322
|
+
res.writeHead(502, { "content-type": "text/plain" }); res.end("App server unavailable. Start it on the registered port, then reload."); });
|
|
323
|
+
res.on("close", () => upstream.destroy());
|
|
324
|
+
req.pipe(upstream);
|
|
325
|
+
}
|
|
326
|
+
upgrade(req, socket, head) {
|
|
327
|
+
const entry = this.entry(req);
|
|
328
|
+
const expires = entry && this.authorize(req, entry.view.id);
|
|
329
|
+
if (!entry || !expires || entry.target.kind !== "service" || req.headers.origin !== this.origin(entry.view.id) || req.headers.upgrade?.toLowerCase() !== "websocket" || !req.url?.startsWith("/") || req.url.startsWith("//")) {
|
|
330
|
+
socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
this.track(entry.view.id, socket, expires);
|
|
334
|
+
const upstream = http.request({ hostname: "127.0.0.1", port: entry.target.port, path: req.url, headers: { ...upstreamHeaders(req, this.origin(entry.view.id)), connection: "Upgrade", upgrade: "websocket" } });
|
|
335
|
+
upstream.setTimeout(10_000, () => upstream.destroy());
|
|
336
|
+
upstream.on("upgrade", (response, peer, upstreamHead) => {
|
|
337
|
+
peer.setTimeout(0);
|
|
338
|
+
this.track(entry.view.id, peer, expires);
|
|
339
|
+
const headers = cleanHeaders(response.headers);
|
|
340
|
+
delete headers["set-cookie"];
|
|
341
|
+
socket.write(`HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n${Object.entries(headers).filter(([, value]) => value !== undefined).map(([key, value]) => `${key}: ${value}\r\n`).join("")}\r\n`);
|
|
342
|
+
if (upstreamHead.length)
|
|
343
|
+
socket.write(upstreamHead);
|
|
344
|
+
if (head.length)
|
|
345
|
+
peer.write(head);
|
|
346
|
+
socket.on("error", () => peer.destroy());
|
|
347
|
+
peer.on("error", () => socket.destroy());
|
|
348
|
+
socket.on("close", () => peer.destroy());
|
|
349
|
+
peer.on("close", () => socket.destroy());
|
|
350
|
+
socket.pipe(peer).pipe(socket);
|
|
351
|
+
});
|
|
352
|
+
upstream.on("response", (response) => { response.resume(); socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n"); });
|
|
353
|
+
upstream.on("error", () => socket.destroy());
|
|
354
|
+
socket.on("close", () => upstream.destroy());
|
|
355
|
+
upstream.end();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
/** This document lives on a different origin from app code. Never put a
|
|
4
|
+
* generated document on the shell host or let the frame navigate its parent. */
|
|
5
|
+
export function previewShell(nonce) {
|
|
6
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Bivy · App preview</title>
|
|
7
|
+
<link rel="stylesheet" href="/__bivy/tokens.css"><link rel="stylesheet" href="/__bivy/styles.css">
|
|
8
|
+
<style nonce="${nonce}">
|
|
9
|
+
html,body { margin:0; width:100%; height:100%; }
|
|
10
|
+
body { display:flex; flex-direction:column; background:var(--bg); color:var(--ink); font-family:var(--font-sans); }
|
|
11
|
+
nav { display:flex; align-items:center; gap:var(--space-2); padding:var(--space-2) var(--space-3); border-bottom:thin solid var(--line); background:var(--surface); flex-shrink:0; }
|
|
12
|
+
nav .btn { min-height:var(--space-7); flex-shrink:0; }
|
|
13
|
+
#title { flex:1; min-width:0; font-size:var(--text-sm); font-weight:var(--weight-semibold); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
14
|
+
#status { padding:var(--space-4); margin:0; font-size:var(--text-sm); }
|
|
15
|
+
iframe { flex:1; min-height:0; width:100%; border:0; background:var(--bg); }
|
|
16
|
+
[hidden] { display:none !important; }
|
|
17
|
+
</style></head><body>
|
|
18
|
+
<nav aria-label="Bivy preview controls"><button class="btn ghost" id="back">‹ Back to chat</button><span id="title">App preview</span><button class="btn ghost" id="reload" disabled>Reload</button></nav>
|
|
19
|
+
<p id="status" role="status">Opening app…</p>
|
|
20
|
+
<iframe id="app" title="App preview" hidden sandbox="allow-scripts allow-same-origin allow-forms allow-downloads allow-popups" referrerpolicy="no-referrer"></iframe>
|
|
21
|
+
<script nonce="${nonce}">
|
|
22
|
+
const frame=document.getElementById('app'),status=document.getElementById('status'),back=document.getElementById('back'),reload=document.getElementById('reload');
|
|
23
|
+
const ticket=location.hash.slice(1);history.replaceState(null,'',location.pathname);
|
|
24
|
+
let metadata;
|
|
25
|
+
const storageKey='bivy-preview';
|
|
26
|
+
function show(data,launch){
|
|
27
|
+
metadata=data;
|
|
28
|
+
document.getElementById('title').textContent=data.name;
|
|
29
|
+
document.getElementById('title').title=data.name;
|
|
30
|
+
document.title='Bivy · '+data.name;
|
|
31
|
+
back.title=data.returnTo?'Return to '+new URL(data.returnTo).host:'Close preview';
|
|
32
|
+
frame.title=data.name;
|
|
33
|
+
frame.hidden=false;
|
|
34
|
+
frame.src=data.origin+(launch?'/__bivy/open#'+launch:'/');
|
|
35
|
+
reload.disabled=false;
|
|
36
|
+
status.textContent='Loading app…';
|
|
37
|
+
frame.onload=()=>{status.hidden=true;};
|
|
38
|
+
// Store navigation metadata only, never tickets or cookies.
|
|
39
|
+
try{sessionStorage.setItem(storageKey,JSON.stringify(data));}catch{}
|
|
40
|
+
}
|
|
41
|
+
back.onclick=()=>{if(metadata?.returnTo){window.close();setTimeout(()=>location.replace(metadata.returnTo),100);}else{window.close();status.hidden=false;status.textContent='You can close this tab to return to Bivy.';}};
|
|
42
|
+
reload.onclick=()=>{if(metadata){status.hidden=false;status.textContent='Reloading app…';frame.src=metadata.origin+'/';}};
|
|
43
|
+
(async()=>{
|
|
44
|
+
if(ticket){
|
|
45
|
+
const response=await fetch('/__bivy/launch',{method:'POST',headers:{'Content-Type':'text/plain'},body:ticket});
|
|
46
|
+
if(!response.ok)throw Error();
|
|
47
|
+
show(await response.json(),ticket);
|
|
48
|
+
}else{
|
|
49
|
+
const stored=JSON.parse(sessionStorage.getItem(storageKey)||'null');
|
|
50
|
+
if(!stored)throw Error();
|
|
51
|
+
show(stored);
|
|
52
|
+
}
|
|
53
|
+
})().catch(()=>{status.hidden=false;status.textContent='This preview link expired or the app was removed. Open a fresh preview from your Bivy chat.';});
|
|
54
|
+
</script></body></html>`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
const MAX_BYTES = 25 * 1024 * 1024;
|
|
7
|
+
const MAX_TOTAL_BYTES = 100 * 1024 * 1024;
|
|
8
|
+
const MAX_FILES = 2000;
|
|
9
|
+
/** Snapshot, not a live filesystem server: symlinks and hidden files are never published. */
|
|
10
|
+
function snapshot(workspace, directory) {
|
|
11
|
+
const base = fs.realpathSync(workspace);
|
|
12
|
+
const root = fs.realpathSync(path.resolve(base, directory));
|
|
13
|
+
const relative = path.relative(base, root);
|
|
14
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
|
|
15
|
+
throw new Error("Static directory must be inside the session workspace.");
|
|
16
|
+
if (!fs.statSync(root).isDirectory())
|
|
17
|
+
throw new Error("Static entry must be a directory.");
|
|
18
|
+
const files = new Map();
|
|
19
|
+
let bytes = 0;
|
|
20
|
+
let entries = 0;
|
|
21
|
+
function walk(dir, prefix, depth) {
|
|
22
|
+
if (depth > 32)
|
|
23
|
+
throw new Error("Static directory is too deeply nested.");
|
|
24
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
25
|
+
if (++entries > MAX_FILES * 2)
|
|
26
|
+
throw new Error("Static directory contains too many entries.");
|
|
27
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules")
|
|
28
|
+
continue;
|
|
29
|
+
const full = path.join(dir, entry.name);
|
|
30
|
+
const key = `${prefix}/${entry.name}`;
|
|
31
|
+
if (entry.isSymbolicLink())
|
|
32
|
+
throw new Error("Static snapshots cannot contain symlinks.");
|
|
33
|
+
if (entry.isDirectory())
|
|
34
|
+
walk(full, key, depth + 1);
|
|
35
|
+
else if (entry.isFile()) {
|
|
36
|
+
if (files.size >= MAX_FILES)
|
|
37
|
+
throw new Error("Static snapshot exceeds 2,000 files.");
|
|
38
|
+
// O_NOFOLLOW closes the final-component symlink race. Verify the opened
|
|
39
|
+
// file's size, then bound the read even if another process grows it.
|
|
40
|
+
const fd = fs.openSync(full, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
41
|
+
try {
|
|
42
|
+
const stat = fs.fstatSync(fd);
|
|
43
|
+
const resolved = fs.realpathSync(full);
|
|
44
|
+
const rel = path.relative(root, resolved);
|
|
45
|
+
const current = fs.statSync(resolved);
|
|
46
|
+
if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel) || !stat.isFile() || stat.dev !== current.dev || stat.ino !== current.ino)
|
|
47
|
+
throw new Error("Static file changed or escaped its directory during publication.");
|
|
48
|
+
const size = stat.size;
|
|
49
|
+
if (bytes + size > MAX_BYTES)
|
|
50
|
+
throw new Error("Static snapshot exceeds 25 MiB.");
|
|
51
|
+
const data = Buffer.alloc(size);
|
|
52
|
+
let offset = 0;
|
|
53
|
+
while (offset < size) {
|
|
54
|
+
const n = fs.readSync(fd, data, offset, size - offset, offset);
|
|
55
|
+
if (!n)
|
|
56
|
+
break;
|
|
57
|
+
offset += n;
|
|
58
|
+
}
|
|
59
|
+
files.set(key, data.subarray(0, offset));
|
|
60
|
+
bytes += offset;
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
fs.closeSync(fd);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else
|
|
67
|
+
throw new Error("Static snapshots can only contain regular files.");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
walk(root, "", 0);
|
|
71
|
+
if (!files.has("/index.html"))
|
|
72
|
+
throw new Error("Static directory needs an index.html entry point.");
|
|
73
|
+
return { kind: "static", files, bytes };
|
|
74
|
+
}
|
|
75
|
+
/** Deliberately ephemeral: restart invalidates apps rather than trusting reused local ports. */
|
|
76
|
+
export class AppRegistry {
|
|
77
|
+
reservedPorts;
|
|
78
|
+
apps = new Map();
|
|
79
|
+
views = new Map();
|
|
80
|
+
constructor(reservedPorts = []) {
|
|
81
|
+
this.reservedPorts = reservedPorts;
|
|
82
|
+
}
|
|
83
|
+
publish(sessionId, workspace, input) {
|
|
84
|
+
const name = (value) => {
|
|
85
|
+
if (typeof value !== "string" || !value.trim() || value.length > 100)
|
|
86
|
+
throw new Error("App and view names must contain 1–100 characters.");
|
|
87
|
+
return value.trim();
|
|
88
|
+
};
|
|
89
|
+
if (!input || input.version !== 1 || !Array.isArray(input.views) || !input.views.length || input.views.length > 8)
|
|
90
|
+
throw new Error("Expected manifest version 1 with 1–8 views.");
|
|
91
|
+
if (this.apps.size >= 50)
|
|
92
|
+
throw new Error("Remove an app before publishing more (limit: 50).");
|
|
93
|
+
const app = { id: randomBytes(16).toString("hex"), sessionId, name: name(input.name), views: [], createdAt: Date.now() };
|
|
94
|
+
const entries = [];
|
|
95
|
+
let total = [...this.views.values()].reduce((sum, item) => sum + (item.target.kind === "static" ? item.target.bytes : 0), 0);
|
|
96
|
+
for (const spec of input.views) {
|
|
97
|
+
const base = { id: randomBytes(16).toString("hex"), name: name(spec?.name) };
|
|
98
|
+
let target;
|
|
99
|
+
let view;
|
|
100
|
+
if (spec.kind === "web" && spec.source?.kind === "service") {
|
|
101
|
+
const port = spec.source.port;
|
|
102
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535 || this.reservedPorts.includes(port))
|
|
103
|
+
throw new Error("Choose a non-reserved local service port between 1024 and 65535.");
|
|
104
|
+
target = { kind: "service", port };
|
|
105
|
+
view = { ...base, kind: "web", source: "service" };
|
|
106
|
+
}
|
|
107
|
+
else if (spec.kind === "web" && spec.source?.kind === "static") {
|
|
108
|
+
if (typeof spec.source.directory !== "string" || !spec.source.directory)
|
|
109
|
+
throw new Error("Static directory is required.");
|
|
110
|
+
target = snapshot(workspace, spec.source.directory);
|
|
111
|
+
if (target.kind === "static")
|
|
112
|
+
total += target.bytes;
|
|
113
|
+
if (total > MAX_TOTAL_BYTES)
|
|
114
|
+
throw new Error("Static apps exceed the node's 100 MiB snapshot budget.");
|
|
115
|
+
view = { ...base, kind: "web", source: "static" };
|
|
116
|
+
}
|
|
117
|
+
else if (spec.kind === "terminal") {
|
|
118
|
+
if (typeof spec.command !== "string" || !spec.command.trim() || spec.command.length > 1000 || spec.command.includes("\u0000"))
|
|
119
|
+
throw new Error("Terminal view requires an executable command.");
|
|
120
|
+
if (spec.args !== undefined && (!Array.isArray(spec.args) || spec.args.length > 100 || spec.args.some((arg) => typeof arg !== "string" || arg.length > 4000 || arg.includes("\u0000"))))
|
|
121
|
+
throw new Error("Invalid terminal arguments.");
|
|
122
|
+
const args = [...(spec.args ?? [])];
|
|
123
|
+
target = { kind: "terminal", command: spec.command, args, workspace: fs.realpathSync(workspace) };
|
|
124
|
+
view = { ...base, kind: "terminal", command: spec.command, args };
|
|
125
|
+
}
|
|
126
|
+
else
|
|
127
|
+
throw new Error("Unsupported app view. This machine supports web and terminal views.");
|
|
128
|
+
app.views.push(view);
|
|
129
|
+
entries.push({ app, view, target });
|
|
130
|
+
}
|
|
131
|
+
// Commit all views together: a bad later view cannot leave a partial app.
|
|
132
|
+
this.apps.set(app.id, app);
|
|
133
|
+
for (const entry of entries)
|
|
134
|
+
this.views.set(entry.view.id, entry);
|
|
135
|
+
return structuredClone(app);
|
|
136
|
+
}
|
|
137
|
+
list(sessionId) {
|
|
138
|
+
return structuredClone([...this.apps.values()].filter((app) => app.sessionId === sessionId));
|
|
139
|
+
}
|
|
140
|
+
getView(id) { return this.views.get(id); }
|
|
141
|
+
require(sessionId, id) {
|
|
142
|
+
const app = this.apps.get(id);
|
|
143
|
+
if (!app || app.sessionId !== sessionId)
|
|
144
|
+
throw new Error("App not found in this session. Republish after a machine restart.");
|
|
145
|
+
return app;
|
|
146
|
+
}
|
|
147
|
+
requireView(sessionId, appId, viewId) {
|
|
148
|
+
this.require(sessionId, appId);
|
|
149
|
+
const view = this.views.get(viewId);
|
|
150
|
+
if (!view || view.app.id !== appId)
|
|
151
|
+
throw new Error("View not found in this app.");
|
|
152
|
+
return view;
|
|
153
|
+
}
|
|
154
|
+
remove(sessionId, id) {
|
|
155
|
+
const app = this.require(sessionId, id);
|
|
156
|
+
for (const view of app.views)
|
|
157
|
+
this.views.delete(view.id);
|
|
158
|
+
this.apps.delete(id);
|
|
159
|
+
}
|
|
160
|
+
}
|