@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.
- package/README.md +4 -0
- package/bin/acp-shim.mjs +7 -0
- package/bin/agent-manifest.json +1 -1
- package/bin/bivy.mjs +55 -0
- package/bin/codex-app-server-shim.mjs +10 -0
- package/bin/patch-pi-dependencies.mjs +2 -2
- package/dist/agents/claude-code/integration.js +9 -1
- package/dist/agents/codex/integration.js +9 -1
- package/dist/agents/pi/integration.js +1 -1
- package/dist/agents/pi/runtime.js +27 -3
- package/dist/agents/profiles.js +13 -5
- package/dist/apps/gateway.js +357 -0
- package/dist/apps/preview-shell.js +55 -0
- package/dist/apps/preview-stream.js +58 -0
- package/dist/apps/registry.js +160 -0
- package/dist/apps/remote-preview.js +70 -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/automation-cli.js +26 -3
- package/dist/automation-config.js +14 -2
- package/dist/automation-filter-gate.js +32 -0
- package/dist/automation-filter.js +118 -0
- package/dist/automation-template.js +18 -4
- package/dist/certification/generated.js +4 -4
- package/dist/control-plane-tasks.js +14 -0
- package/dist/controllers/app-commands.js +28 -0
- package/dist/controllers/credential-commands.js +8 -3
- package/dist/credentials/document.js +35 -10
- package/dist/credentials/store.js +17 -0
- package/dist/hosted-custody.js +30 -0
- package/dist/protocol/client-command-routes.js +1 -0
- package/dist/protocol/client-command-schemas.js +4 -0
- package/dist/remote/relay-client.js +14 -0
- package/dist/runtime/cli-parsers.js +29 -2
- package/dist/runtime/index.js +30 -1
- package/dist/runtime/protocol.js +32 -2
- package/dist/server.js +114 -17
- package/dist/session/event-log.js +131 -10
- package/dist/session/fork-matrix.js +12 -0
- package/dist/session/transcript-persistence.js +8 -1
- package/dist/worktree.js +9 -1
- 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 "";
|
package/bin/agent-manifest.json
CHANGED
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.
|
|
41
|
-
{ name: "undici", version: "8.10.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
272
|
-
|
|
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
|
-
|
|
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
|
package/dist/agents/profiles.js
CHANGED
|
@@ -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.
|
|
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
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
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
|
+
}
|