@bivy/bivy 0.16.25-staging.4 → 0.16.25-staging.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +4 -0
  2. package/bin/acp-shim.mjs +7 -0
  3. package/bin/agent-manifest.json +1 -1
  4. package/bin/bivy.mjs +55 -0
  5. package/bin/codex-app-server-shim.mjs +10 -0
  6. package/bin/patch-pi-dependencies.mjs +2 -2
  7. package/dist/agents/claude-code/integration.js +9 -1
  8. package/dist/agents/codex/integration.js +9 -1
  9. package/dist/agents/pi/integration.js +1 -1
  10. package/dist/agents/pi/runtime.js +27 -3
  11. package/dist/agents/profiles.js +13 -5
  12. package/dist/apps/gateway.js +357 -0
  13. package/dist/apps/preview-shell.js +55 -0
  14. package/dist/apps/preview-stream.js +58 -0
  15. package/dist/apps/registry.js +160 -0
  16. package/dist/apps/remote-preview.js +70 -0
  17. package/dist/apps/service.js +62 -0
  18. package/dist/apps/styles.css +3165 -0
  19. package/dist/apps/tokens.css +203 -0
  20. package/dist/apps/types.js +9 -0
  21. package/dist/automation-cli.js +26 -3
  22. package/dist/automation-config.js +14 -2
  23. package/dist/automation-filter-gate.js +32 -0
  24. package/dist/automation-filter.js +118 -0
  25. package/dist/automation-template.js +18 -4
  26. package/dist/certification/generated.js +4 -4
  27. package/dist/control-plane-tasks.js +14 -0
  28. package/dist/controllers/app-commands.js +28 -0
  29. package/dist/controllers/credential-commands.js +8 -3
  30. package/dist/credentials/document.js +35 -10
  31. package/dist/credentials/store.js +17 -0
  32. package/dist/hosted-custody.js +30 -0
  33. package/dist/protocol/client-command-routes.js +1 -0
  34. package/dist/protocol/client-command-schemas.js +4 -0
  35. package/dist/remote/relay-client.js +14 -0
  36. package/dist/runtime/cli-parsers.js +29 -2
  37. package/dist/runtime/index.js +30 -1
  38. package/dist/runtime/protocol.js +32 -2
  39. package/dist/server.js +114 -17
  40. package/dist/session/event-log.js +131 -10
  41. package/dist/session/fork-matrix.js +12 -0
  42. package/dist/session/transcript-persistence.js +8 -1
  43. package/dist/worktree.js +9 -1
  44. package/package.json +16 -15
package/README.md CHANGED
@@ -206,6 +206,10 @@ 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`. Linked machines automatically discover web
211
+ preview delivery through Bivy's relay—no per-machine domains, certificates or
212
+ public ports. Terminal views use the existing connection.
209
213
  - Use voice input and read-aloud where supported; provider-backed voice may
210
214
  send audio or text to the selected provider.
211
215
  - Keep a native terminal workflow or use structured chat, depending on the agent.
package/bin/acp-shim.mjs CHANGED
@@ -313,6 +313,13 @@ function onSessionUpdate(params) {
313
313
  if (!content) return "";
314
314
  if (typeof content === "string") return content;
315
315
  if (Array.isArray(content)) return content.map(textOf).join("");
316
+ // ACP's ToolCallContent wraps a ContentBlock: `{ type: "content", content:
317
+ // { type: "text", text } }`. Unwrap it (recursively) so a tool's real output
318
+ // — a command's stdout, a fetched document, a read's text — isn't dropped,
319
+ // which collapsed the tool_result to the bare status ("completed"). opencode
320
+ // and most spec-compliant ACP agents deliver tool output in this wrapped
321
+ // shape, so reading only the bare `{type:"text"}` form lost it in production.
322
+ if (content.type === "content" && content.content != null) return textOf(content.content);
316
323
  if (content.type === "text" && typeof content.text === "string") return content.text;
317
324
  if (typeof content.text === "string") return content.text;
318
325
  return "";
@@ -22,7 +22,7 @@
22
22
  "hidden": false,
23
23
  "supportTier": "supported",
24
24
  "certification": "release-tested",
25
- "testedVersion": "1.18.30",
25
+ "testedVersion": "1.18.31",
26
26
  "headlessFlags": [
27
27
  "run",
28
28
  "-s"
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;
@@ -415,6 +415,16 @@ function onNotification(m) {
415
415
  bivy({ type: "session.status", status: "working" });
416
416
  return;
417
417
  case "turn/completed": {
418
+ // The completion event is authoritative about a terminal failure: a turn
419
+ // that hit a usage limit (or any provider error) arrives here as
420
+ // `status: "failed"` carrying `turn.error`. Capture it directly instead of
421
+ // depending on a separate `error` notification landing first — otherwise a
422
+ // completion that omits that notification seals as session.done and the
423
+ // user sees a silent empty turn (fail loud, never swallow the error).
424
+ const turnError =
425
+ params?.turn?.error?.message ||
426
+ (params?.turn?.status === "failed" ? params?.error?.message || params?.message || "Codex turn failed" : undefined);
427
+ if (turnError && !sawError) sawError = turnError;
418
428
  // Codex 0.147 can publish turn/completed just before the final
419
429
  // collabAgentToolCall item/completed notification. Sealing immediately
420
430
  // leaves the sub-agent card running forever and drops its persisted
@@ -37,8 +37,8 @@ const piPackage = findDependency(path.join("@earendil-works", "pi-coding-agent")
37
37
  if (!piPackage) process.exit(0);
38
38
 
39
39
  const patches = [
40
- { name: "brace-expansion", version: "5.0.9" },
41
- { name: "undici", version: "8.10.0" },
40
+ { name: "brace-expansion", version: "5.0.12" },
41
+ { name: "undici", version: "8.10.2" },
42
42
  ];
43
43
 
44
44
  for (const patch of patches) {
@@ -4,13 +4,21 @@ import { defineAgentIntegration } from "../definition.js";
4
4
  import { withExactCapabilitySurface } from "../../runtime/types.js";
5
5
  import { createCredentialStore } from "../../runtime/credentials.js";
6
6
  import { ClaudeCodeRuntime, claudeRuntimeFromEnv, claudeSdkInstalled, } from "./runtime.js";
7
- export const CLAUDE_TESTED_VERSION = "0.3.270";
7
+ export const CLAUDE_TESTED_VERSION = "0.3.278";
8
8
  const CLAUDE_CAPABILITIES = withExactCapabilitySurface({
9
9
  toolInterception: true,
10
10
  modelSelection: true,
11
11
  packages: false,
12
12
  resume: true,
13
13
  fork: true,
14
+ // Must mirror ClaudeCodeRuntime.capabilities (runtime.ts): the catalog is a
15
+ // consumed source of truth (the fork-fidelity matrix and any fork-target
16
+ // fidelity preview read these), so under-reporting here makes Claude look
17
+ // like a seeded-only fork target when its runtime actually delivers a
18
+ // byte-exact same-runtime fork (forkTransport) and a full cross-runtime
19
+ // transcript replay INTO it (forkHistoryImport, via importHistoryForFork).
20
+ forkTransport: true,
21
+ forkHistoryImport: true,
14
22
  usageReporting: true,
15
23
  nativeSessionDiscovery: true,
16
24
  nativeSessionAdoption: true,
@@ -9,7 +9,7 @@ import { codexCredentialPreflight } from "../../runtime/codex-preflight.js";
9
9
  import { deleteCodexSession, discoverNativeCodexSessions, loadCodexTranscript, writeCodexRollout, exportCodexRollout, importCodexRollout, } from "../../runtime/codex-sessions.js";
10
10
  import { ProtocolRuntime } from "../../runtime/protocol.js";
11
11
  import { codexSlashCommands } from "../../runtime/slash-commands.js";
12
- export const CODEX_TESTED_VERSION = "0.154.0";
12
+ export const CODEX_TESTED_VERSION = "0.155.1";
13
13
  const CODEX_AVAILABLE_CACHE = new Map();
14
14
  function codexCommand() {
15
15
  return process.env.BIVY_CODEX_BIN?.trim() || "codex";
@@ -146,6 +146,14 @@ export function codexIntegration(origin) {
146
146
  resume: true,
147
147
  packages: false,
148
148
  fork: true,
149
+ // Mirror the ProtocolRuntime this integration builds (createCodexRuntime
150
+ // above wires exportForFork+importForFork and writeHistory, which flip
151
+ // capabilities.forkTransport/forkHistoryImport on). The catalog is a
152
+ // consumed source of truth (fork-fidelity matrix / fork-target preview),
153
+ // so omitting these made Codex look seeded-only when its runtime does a
154
+ // byte-exact codex→codex fork and a full transcript replay INTO it.
155
+ forkTransport: true,
156
+ forkHistoryImport: true,
149
157
  usageReporting: true,
150
158
  interactiveTui: installed,
151
159
  nativeSessionDiscovery: true,
@@ -5,7 +5,7 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { defineAgentIntegration } from "../definition.js";
7
7
  import { PI_CAPABILITIES } from "./capabilities.js";
8
- export const PI_TESTED_VERSION = "0.85.1";
8
+ export const PI_TESTED_VERSION = "0.86.1";
9
9
  export function piAgentDir() {
10
10
  return process.env.PI_CODING_AGENT_DIR?.trim() || path.join(os.homedir(), ".pi", "agent");
11
11
  }
@@ -268,8 +268,29 @@ class PiSession {
268
268
  * reload. Async because that resolution reads the store.
269
269
  */
270
270
  async getModels() {
271
- const models = await this.session.modelRuntime.getAvailable();
272
- return models.map((model) => toModelInfo(model, true));
271
+ const runtime = this.session.modelRuntime;
272
+ try {
273
+ const models = await runtime.getAvailable();
274
+ return models.map((model) => toModelInfo(model, true));
275
+ }
276
+ catch (error) {
277
+ // The runtime's availability refresh is a Promise.all across every
278
+ // provider's auth check, so one broken provider (an unreachable custom
279
+ // endpoint, a single failing credential read) rejects the whole catalog
280
+ // and the picker shows nothing. Degrade to per-provider listing and
281
+ // skip only the broken ones; rethrow only when truly nothing survives.
282
+ const models = [];
283
+ for (const provider of runtime.getProviders()) {
284
+ try {
285
+ for (const model of await runtime.getAvailable(provider.id))
286
+ models.push(toModelInfo(model, true));
287
+ }
288
+ catch { /* this provider is broken — the rest still list */ }
289
+ }
290
+ if (models.length)
291
+ return models;
292
+ throw error instanceof Error ? error : new Error(String(error));
293
+ }
273
294
  }
274
295
  /**
275
296
  * Every model Pi knows about — built-in and custom, regardless of whether
@@ -281,7 +302,10 @@ class PiSession {
281
302
  async getAllModels() {
282
303
  const runtime = this.session.modelRuntime;
283
304
  // Refresh availability first so `hasConfiguredAuth` reflects current creds.
284
- await runtime.getAvailable();
305
+ // Best-effort: a failed refresh (one broken provider rejects the shared
306
+ // Promise.all) must not blank the full catalog — the flags then reflect
307
+ // the last successful refresh, which the picker can still act on.
308
+ await runtime.getAvailable().catch(() => { });
285
309
  return runtime.getModels().map((model) => toModelInfo(model, runtime.hasConfiguredAuth(model.provider)));
286
310
  }
287
311
  /** Reload the projected models.json in-place so a live session immediately
@@ -43,7 +43,7 @@ export const AGENT_PROFILES = {
43
43
  // Approve/Deny + session/load resume + a real model picker), the same bar Pi,
44
44
  // Claude Code, and Codex clear. See `acp` below for the version fallback.
45
45
  supportTier: "supported",
46
- testedVersion: "1.18.30",
46
+ testedVersion: "1.18.31",
47
47
  blurb: "The most widely used open-source coding harness (OpenCode CLI).",
48
48
  // `opencode run -s <id> "<prompt>"` continues a prior session by its own id
49
49
  // (`-s, --session session id to continue`, per `opencode run --help`).
@@ -398,13 +398,21 @@ export const AGENT_PROFILES = {
398
398
  flag: "-m",
399
399
  models: [
400
400
  // Official Grok CLI (1.x) advertises grok-4.6 as the default subscription
401
- // model (verified against `grok models`); the older grok-4.5 / grok-4-latest
402
- // / grok-code-fast-1 ids now return "unknown model id". Keep the list
403
- // honest; operators can override with BIVY_GROK_MODELS if their install
404
- // exposes more.
401
+ // model (verified against the authenticated `~/.grok/models_cache.json`,
402
+ // origin cli-chat-proxy.grok.com/v1/models); the older grok-4.5 /
403
+ // grok-4-latest / grok-code-fast-1 ids now return "unknown model id".
404
+ // Keep the list honest; operators can override with BIVY_GROK_MODELS if
405
+ // their install exposes more.
405
406
  { id: "grok-4.6", name: "Grok 4.6", provider: "xai" },
406
407
  ],
407
408
  },
409
+ // Grok's reasoning models expose a `--reasoning-effort <EFFORT>` flag
410
+ // (verified against `grok --help`, 1.0.0); the authenticated model catalog
411
+ // lists low/medium/high/xhigh with `high` as the default. `{level}` fills
412
+ // the value; insertAt: 0 keeps the trailing `-p` prompt flag last so the
413
+ // prompt still lands as its value. Threads through the resume path too, so a
414
+ // continued turn keeps the chosen effort. Override with BIVY_GROK_THINKING.
415
+ thinking: { levels: ["low", "medium", "high", "xhigh"], default: "high", template: ["--reasoning-effort", "{level}"], insertAt: 0 },
408
416
  promptMode: "argv",
409
417
  install: {
410
418
  kind: "curl",
@@ -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 deployment's
21
+ * preview ingress. Delivery reaches only this gateway, never the node API. */
22
+ export function previewOriginTemplate(raw) {
23
+ const parsed = new URL(raw.replace("{app}", "a"));
24
+ if (!/^https:\/\/\{app\}(?:-[a-f0-9]{24})?\.[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 = /^([a-f0-9]{32})(?:[.-])/.exec(host)?.[1];
134
+ if (!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
+ }