@bivy/bivy 0.20.1-staging.5 → 0.20.1-staging.6

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 (136) hide show
  1. package/bin/bivy.mjs +120 -0
  2. package/bin/cli-commands.mjs +1 -0
  3. package/bin/tailscale.mjs +63 -0
  4. package/dist/auth.js +16 -3
  5. package/dist/pair-codes.js +41 -0
  6. package/dist/remote/direct.js +23 -0
  7. package/dist/remote/index.js +1 -0
  8. package/dist/server.js +91 -5
  9. package/dist/web-app.js +55 -0
  10. package/package.json +1 -1
  11. package/web/apple-touch-icon.png +0 -0
  12. package/web/assets/AutomationsView-C5FTAk5H.js +51 -0
  13. package/web/assets/ChangesCard-Bq3EQIem.js +4 -0
  14. package/web/assets/LibraryView-DrOvEcbv.js +2 -0
  15. package/web/assets/ProviderConnect-B5UJ6o1x.js +1 -0
  16. package/web/assets/ReadinessChecklist-Bbgq2GQ8.js +1 -0
  17. package/web/assets/Settings-BV1LvpeB.js +10 -0
  18. package/web/assets/Sheet-Db4Mndim.js +2 -0
  19. package/web/assets/StatusDot-dpbztK53.js +1 -0
  20. package/web/assets/Terminal-B1njb2Cr.css +1 -0
  21. package/web/assets/Terminal-CvIBOJzq.js +96 -0
  22. package/web/assets/VoiceSettings-96Bha24X.js +1 -0
  23. package/web/assets/abnfDiagram-VCTEODGH-D2Y5sPRp.js +1 -0
  24. package/web/assets/arc-Pry-7G1_.js +1 -0
  25. package/web/assets/architecture-7GRP2DOG-BgbAf7AC.js +1 -0
  26. package/web/assets/architectureDiagram-5GKGNRK7-BRYXZYo0.js +36 -0
  27. package/web/assets/array-BifhSqXX.js +1 -0
  28. package/web/assets/blockDiagram-I7D4REHJ-BbZ6-1wB.js +129 -0
  29. package/web/assets/c4Diagram-7LVT6UL2-CvOQdXuZ.js +38 -0
  30. package/web/assets/channel-Ccxgwn49.js +1 -0
  31. package/web/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  32. package/web/assets/chunk-4HAMMTFA-BI825_ZV.js +62 -0
  33. package/web/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  34. package/web/assets/chunk-75Z2AOVW-CxSd6Zxl.js +2 -0
  35. package/web/assets/chunk-DU6HZSFF-1-mK49JE.js +127 -0
  36. package/web/assets/chunk-F27PBJKO-jlR5FL7E.js +1 -0
  37. package/web/assets/chunk-FOHPRMQF-DzxwRta7.js +161 -0
  38. package/web/assets/chunk-GMAD6QVW-BBnwhF5K.js +72 -0
  39. package/web/assets/chunk-GVQU2GXP-CHUTVqWw.js +1 -0
  40. package/web/assets/chunk-IMKFNOWR-D0BbcDj6.js +231 -0
  41. package/web/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  42. package/web/assets/chunk-L3NEJ4N5-DvDZiWe-.js +1 -0
  43. package/web/assets/chunk-OSK3NFVY-JHuvasbP.js +10 -0
  44. package/web/assets/chunk-P2QGCYS3-wjuBF4Pd.js +1 -0
  45. package/web/assets/chunk-POPQ4Y6H-CngK4WUA.js +1 -0
  46. package/web/assets/chunk-PWAF6VOD-CTRtPw_P.js +1 -0
  47. package/web/assets/chunk-SHT3W25Y-ABe0cu9b.js +168 -0
  48. package/web/assets/chunk-SVP7TREG-cnVb9wRh.js +88 -0
  49. package/web/assets/chunk-TICWLB2K-DfKefWT6.js +206 -0
  50. package/web/assets/chunk-XXDRQBXY-DqAYeljN.js +1 -0
  51. package/web/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  52. package/web/assets/classDiagram-ZZMXUADV-B_nUbchH.js +1 -0
  53. package/web/assets/classDiagram-v2-VYDZK3BY-B_nUbchH.js +1 -0
  54. package/web/assets/common-DvIV9UK2.js +5 -0
  55. package/web/assets/cose-bilkent-JH36ORCC-BIqBF2fY.js +1 -0
  56. package/web/assets/cynefin-OW5HDTMX-6Eym2I8q.js +1 -0
  57. package/web/assets/cynefinDiagram-5FMLGOSQ-DMcNShU2.js +62 -0
  58. package/web/assets/cytoscape.esm-NgwPT4ez.js +321 -0
  59. package/web/assets/dagre-C-rnJpZF.js +1 -0
  60. package/web/assets/dagre-GXQ25YYZ-jyxO30Ra.js +4 -0
  61. package/web/assets/defaultLocale-BFoDCU3G.js +1 -0
  62. package/web/assets/diagram-S7CK7UJ4-DyJSgUWQ.js +30 -0
  63. package/web/assets/diagram-UQ7AKVKN-Bu-awgYc.js +41 -0
  64. package/web/assets/diagram-VSXAHHWV-CDG8WcFV.js +3 -0
  65. package/web/assets/diagram-VX7I27RA-eJK6IXRm.js +24 -0
  66. package/web/assets/diagram-Z3DM3KII-4bfg0YVc.js +24 -0
  67. package/web/assets/dist-v5Q1xZ2K.js +1 -0
  68. package/web/assets/ebnfDiagram-PWID7BFC-DLpKX0PA.js +1 -0
  69. package/web/assets/erDiagram-RLTQ6QDP-C9XoVCNz.js +99 -0
  70. package/web/assets/eventmodeling-NTZA5JFV-DB9bp7xg.js +1 -0
  71. package/web/assets/flowDiagram-HODETNUW-Bt84xHsk.js +1 -0
  72. package/web/assets/ganttDiagram-EL5Y4UJY-6GH5Yrgv.js +292 -0
  73. package/web/assets/gitGraph-4MIJSDKK-DFBb9ZSe.js +1 -0
  74. package/web/assets/gitGraphDiagram-WWUBYQGX-DVuclljM.js +106 -0
  75. package/web/assets/graphlib-DS17s2tU.js +1 -0
  76. package/web/assets/index-Dk7VU5lG.css +1 -0
  77. package/web/assets/index-H1S-0dr5.js +3 -0
  78. package/web/assets/info-A6RAGUB7-DS--l7eH.js +1 -0
  79. package/web/assets/infoDiagram-27XIBGKW-A9tXXovL.js +2 -0
  80. package/web/assets/init-C-OQMol4.js +1 -0
  81. package/web/assets/ishikawaDiagram-5VMMS53U-C3bulOe4.js +70 -0
  82. package/web/assets/journeyDiagram-3NMN7TZE-PFpzT1t0.js +139 -0
  83. package/web/assets/jsx-runtime-NZYk81nU.js +1 -0
  84. package/web/assets/kanban-definition-UXKFOSKX-C96gUxZE.js +89 -0
  85. package/web/assets/katex-ZlcWpGUi.js +257 -0
  86. package/web/assets/line-Bq-4Ddcs.js +1 -0
  87. package/web/assets/linear-SzVmGSw4.js +1 -0
  88. package/web/assets/mermaid-parser.core-BQCW99Ok.js +7 -0
  89. package/web/assets/mermaid.core-B30Wokvl.js +44 -0
  90. package/web/assets/mindmap-definition-YA3MSWOX-ClwtCWm0.js +96 -0
  91. package/web/assets/mount-B_ShSdyJ.js +26 -0
  92. package/web/assets/mount-BjlUftih.css +1 -0
  93. package/web/assets/ordinal-BDEzSJ7C.js +1 -0
  94. package/web/assets/packet-AYTQ26CC-YkeLgzv0.js +1 -0
  95. package/web/assets/path-fybaL0A-.js +1 -0
  96. package/web/assets/pegDiagram-XKGWAZYB-CaCibFki.js +1 -0
  97. package/web/assets/pie-WAS4IAKB-OyMK5OfZ.js +1 -0
  98. package/web/assets/pieDiagram-E7YTZNPT-DfGlDcKu.js +39 -0
  99. package/web/assets/quadrantDiagram-AXDQQJYC-C8ugAM99.js +7 -0
  100. package/web/assets/radar-RG4KPBEZ-DPu0k7LD.js +1 -0
  101. package/web/assets/railroad-74A4TZTK-Co59jOcB.js +1 -0
  102. package/web/assets/railroad-abnf-HS5TGJTU-Cw5NEYkY.js +1 -0
  103. package/web/assets/railroad-ebnf-LZEXJU2U-nB_gqNFI.js +1 -0
  104. package/web/assets/railroad-peg-WCYAUIDC-2XU5RRzN.js +1 -0
  105. package/web/assets/railroadDiagram-O6MQD6OU-COh170ve.js +1 -0
  106. package/web/assets/requirementDiagram-BXWQKSXE-Dh-wxNt2.js +84 -0
  107. package/web/assets/rolldown-runtime-hePW80VL.js +1 -0
  108. package/web/assets/rough.esm-Dy-Kn_BL.js +1 -0
  109. package/web/assets/sankeyDiagram-P5KCCOFB-BHOSWXWw.js +40 -0
  110. package/web/assets/sequenceDiagram-WJ2MYXX4-ik5jL4SR.js +162 -0
  111. package/web/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  112. package/web/assets/src-B6xuSHsQ.js +1 -0
  113. package/web/assets/stateDiagram-D77RDMKH-Be31rRv9.js +1 -0
  114. package/web/assets/stateDiagram-v2-MP3YSRHH-DV2_zzd4.js +1 -0
  115. package/web/assets/swimlanes-42K2YHIH-DBK3hVdk.js +1 -0
  116. package/web/assets/swimlanesDiagram-VR7AAH4N-D_QAhSyB.js +8 -0
  117. package/web/assets/timeline-definition-24CTP7MA-DlW5MrNZ.js +120 -0
  118. package/web/assets/treeView-Q6P3EWNA-CfAW_qLZ.js +1 -0
  119. package/web/assets/treemap-WGGIJYW6-QxQMH5Y8.js +1 -0
  120. package/web/assets/useStore-BxmpBglW.js +27 -0
  121. package/web/assets/vennDiagram-4TSXK5OY-D91iwoPB.js +34 -0
  122. package/web/assets/wardley-WFR3VGLG-hq1e1BaT.js +1 -0
  123. package/web/assets/wardleyDiagram-VM6X3IG4-BDiSY_RF.js +78 -0
  124. package/web/assets/workbox-window.prod.es5-Bd17z0YL.js +2 -0
  125. package/web/assets/xychartDiagram-S5SC5T6Z-DX1lbV1q.js +7 -0
  126. package/web/fonts/OFL.txt +90 -0
  127. package/web/fonts/newsreader-italic.woff2 +0 -0
  128. package/web/fonts/newsreader-roman.woff2 +0 -0
  129. package/web/icon-192.png +0 -0
  130. package/web/icon-512.png +0 -0
  131. package/web/icon.svg +16 -0
  132. package/web/index.html +36 -0
  133. package/web/manifest.webmanifest +1 -0
  134. package/web/runtime-config.js +3 -0
  135. package/web/sw.js +1 -0
  136. package/web/tent-logo.png +0 -0
package/bin/bivy.mjs CHANGED
@@ -40,6 +40,7 @@ import { nativeResumeRef, resolveSessionsLimit, truncateSavedSessions } from "./
40
40
  import { renderManagedBlock, upsertManagedBlock, removeManagedBlock, rcFileForShell } from "./shim-path.mjs";
41
41
  import { removeInstallAndState } from "./uninstall-paths.mjs";
42
42
  import { findAvailablePort, reconcilePort } from "./port-picker.mjs";
43
+ import { parseTailscaleStatus, serveConflict, serveFailureHint, serveOffArgs, serveOnArgs, serveState } from "./tailscale.mjs";
43
44
  import { resolveAttachSessionId } from "./attach-session-id.mjs";
44
45
  import { detectInstallKind as classifyInstallKind, npmGlobalPrefix } from "./install-kind.mjs";
45
46
  import { hasConfiguredService as configuredServiceExists } from "./service-state.mjs";
@@ -5673,6 +5674,122 @@ async function cmdLinkPhone(args = []) {
5673
5674
  }
5674
5675
  }
5675
5676
 
5677
+ const tailscaleConfigPath = path.join(appDir, "tailscale.json");
5678
+ const LOCAL_TOKEN_NAMES = ["Bivy CLI", "Local Remote UI"];
5679
+
5680
+ function runTailscale(args) {
5681
+ const res = spawnSync("tailscale", args, { encoding: "utf8" });
5682
+ if (res.error?.code === "ENOENT") return null;
5683
+ return { ok: res.status === 0, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
5684
+ }
5685
+
5686
+ async function printPairLink(config) {
5687
+ const data = await localApi(config, "/api/auth/pair-codes", { method: "POST", body: "{}" });
5688
+ if (!data?.url) throw new Error("The node did not return a pairing link.");
5689
+ const qr = terminalQr(data.url);
5690
+ if (qr) console.log(qr);
5691
+ console.log(`\nOpen on your phone (single use, 10 minutes):\n${c.cyan(data.url)}\n`);
5692
+ }
5693
+
5694
+ // `bivy tailscale` — reach this machine from anywhere on your tailnet with no
5695
+ // control plane or relay: `tailscale serve` puts HTTPS on the machine's ts.net
5696
+ // name and forwards it to the node's direct listener, which serves the web app.
5697
+ async function cmdTailscale(args = []) {
5698
+ const [verb = "on"] = args;
5699
+ if (args.includes("-h") || args.includes("--help") || !["on", "pair", "status", "devices", "revoke", "off"].includes(verb)) {
5700
+ console.log(`Usage: bivy tailscale [on|pair|status|devices|revoke <id>|off]
5701
+
5702
+ on Serve this machine's Bivy on https://<machine>.<tailnet>.ts.net (tailnet only) and show a pairing link
5703
+ pair Show a new one-time pairing link for another device
5704
+ status Show the Tailscale address and whether it is served
5705
+ devices List the devices holding a token for this node
5706
+ revoke <id> Revoke a device's token and disconnect it
5707
+ off Stop serving Bivy over Tailscale`);
5708
+ return;
5709
+ }
5710
+ const config = loadConfig();
5711
+ if (!(await isReachable(config))) {
5712
+ console.log(c.yellow(`The node is not reachable at ${url(config)}. Start it first: ${c.cyan(config.service ? "bivy restart" : "bivy start")}`));
5713
+ process.exitCode = EXIT.unavailable;
5714
+ return;
5715
+ }
5716
+ const saved = (() => { try { return JSON.parse(fs.readFileSync(tailscaleConfigPath, "utf8")); } catch { return null; } })();
5717
+
5718
+ if (verb === "pair") {
5719
+ if (!saved) { console.log(`Tailscale access is off. Turn it on with ${c.cyan("bivy tailscale")}.`); process.exitCode = EXIT.failed; return; }
5720
+ await printPairLink(config);
5721
+ return;
5722
+ }
5723
+ if (verb === "devices") {
5724
+ // The CLI and loopback web client mint their own tokens on this machine; list them as a count.
5725
+ const all = (await localApi(config, "/api/auth/devices")) ?? [];
5726
+ const devices = all.filter((device) => !LOCAL_TOKEN_NAMES.includes(device.name));
5727
+ for (const device of devices) console.log(`${c.cyan(device.id)} ${device.name}${device.createdAt ? c.dim(` paired ${device.createdAt}`) : ""}`);
5728
+ if (!devices.length) console.log("No paired devices.");
5729
+ if (all.length > devices.length) console.log(c.dim(`(+${all.length - devices.length} tokens used on this machine by the CLI)`));
5730
+ return;
5731
+ }
5732
+ if (verb === "revoke") {
5733
+ const id = args[1];
5734
+ if (!id) { console.log("Usage: bivy tailscale revoke <id> (ids from 'bivy tailscale devices')"); process.exitCode = EXIT.usage; return; }
5735
+ const res = await fetch(`${url(config)}/api/auth/devices/${encodeURIComponent(id)}`, { method: "DELETE", headers: { authorization: `Bearer ${await localDeviceToken(config)}` } });
5736
+ if (!res.ok) { console.log(c.yellow(`No device ${id}.`)); process.exitCode = EXIT.notFound; return; }
5737
+ console.log(`${c.green("✓")} Revoked ${id}.`);
5738
+ return;
5739
+ }
5740
+ if (verb === "status") {
5741
+ console.log(saved ? `${c.green("✓")} Served on ${c.cyan(`https://${saved.hostname}`)} (tailnet only) → 127.0.0.1:${saved.port}` : `Tailscale access is off. Turn it on with ${c.cyan("bivy tailscale")}.`);
5742
+ return;
5743
+ }
5744
+ if (verb === "off") {
5745
+ if (saved) {
5746
+ const state = serveState(runTailscale(["serve", "status", "--json"])?.stdout, saved.hostname);
5747
+ if (state.target === `http://127.0.0.1:${saved.port}`) runTailscale(serveOffArgs());
5748
+ }
5749
+ fs.rmSync(tailscaleConfigPath, { force: true });
5750
+ await localApi(config, "/api/direct/reload", { method: "POST", body: "{}" });
5751
+ console.log(`${c.green("✓")} Bivy is no longer served over Tailscale. Paired devices keep their tokens; see them with 'bivy tailscale devices' and revoke with 'bivy tailscale revoke <id>'.`);
5752
+ return;
5753
+ }
5754
+
5755
+ const status = runTailscale(["status", "--json"]);
5756
+ if (!status) {
5757
+ console.log(`${c.yellow("Tailscale isn't installed.")} Install it from ${c.cyan("https://tailscale.com/download")} (Arch: ${c.cyan("sudo pacman -S tailscale && sudo systemctl enable --now tailscaled")}), sign in with ${c.cyan("sudo tailscale up")}, then run ${c.cyan("bivy tailscale")} again.`);
5758
+ process.exitCode = EXIT.unavailable;
5759
+ return;
5760
+ }
5761
+ const { running, hostname } = parseTailscaleStatus(status.stdout);
5762
+ if (!running || !hostname) {
5763
+ console.log(`${c.yellow("Tailscale isn't connected.")} Sign in with ${c.cyan("sudo tailscale up")}, then run ${c.cyan("bivy tailscale")} again.`);
5764
+ process.exitCode = EXIT.unavailable;
5765
+ return;
5766
+ }
5767
+ const port = saved?.port ?? await findAvailablePort(config.port + 2, "127.0.0.1");
5768
+ const conflict = serveConflict(serveState(runTailscale(["serve", "status", "--json"])?.stdout, hostname), port);
5769
+ if (conflict) {
5770
+ console.log(c.yellow(conflict));
5771
+ process.exitCode = EXIT.conflict;
5772
+ return;
5773
+ }
5774
+ fs.writeFileSync(tailscaleConfigPath, `${JSON.stringify({ port, hostname }, null, 2)}\n`, { mode: 0o600 });
5775
+ const reload = await localApi(config, "/api/direct/reload", { method: "POST", body: "{}" });
5776
+ const served = runTailscale(serveOnArgs(port));
5777
+ if (!served?.ok) {
5778
+ fs.rmSync(tailscaleConfigPath, { force: true });
5779
+ await localApi(config, "/api/direct/reload", { method: "POST", body: "{}" }).catch(() => {});
5780
+ console.error(c.red(`tailscale serve failed: ${(served?.stderr || served?.stdout || "").trim()}`));
5781
+ const hint = serveFailureHint(served?.stderr);
5782
+ if (hint) console.log(hint);
5783
+ process.exitCode = EXIT.failed;
5784
+ return;
5785
+ }
5786
+ console.log(c.bold(c.green(`\n Bivy is on https://${hostname}\n`)));
5787
+ console.log(c.dim(" Only devices on your tailnet can reach it. Pair each one once with a link from 'bivy tailscale pair'."));
5788
+ if (!reload?.webApp) console.log(c.yellow(" This install has no built web app, so the address serves the API only. Run 'pnpm run build:web' in a source checkout."));
5789
+ console.log("");
5790
+ await printPairLink(config);
5791
+ }
5792
+
5676
5793
  function printUninstallHelp() {
5677
5794
  console.log(`
5678
5795
  ${c.bold("bivy uninstall")} — remove Bivy and all its data from this machine
@@ -6270,6 +6387,9 @@ Unlike 'bivy run', these commands operate on governed background Runs with check
6270
6387
  case "link":
6271
6388
  await cmdLinkPhone(args);
6272
6389
  break;
6390
+ case "tailscale":
6391
+ await cmdTailscale(args);
6392
+ break;
6273
6393
  case "relay:setup":
6274
6394
  await cmdRelaySetup(args);
6275
6395
  break;
@@ -103,6 +103,7 @@ export const COMMANDS = [
103
103
  { name: "token", group: "node", scope: "node", usage: "token", summary: "Print a device token for this node" },
104
104
  { name: "open", group: "node", scope: "node", usage: "open", summary: "Open the remote web app" },
105
105
  { name: "link", group: "node", scope: "node", usage: "link", summary: "Show a web app link as a QR code in the terminal" },
106
+ { name: "tailscale", group: "node", scope: "node", usage: "tailscale [on|pair|status|devices|revoke|off]", summary: "Reach this machine over Tailscale, with no server in between", subcommands: ["on", "pair", "status", "devices", "revoke", "off"] },
106
107
  { name: "relay:setup", group: "node", scope: "node", usage: "relay:setup", summary: "Enable secure remote web app access" },
107
108
  { name: "uninstall", group: "node", scope: "node", usage: "uninstall [--keep-sessions] [--dry-run]", summary: "Remove Bivy and all its data" },
108
109
 
@@ -0,0 +1,63 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Pure helpers for `bivy tailscale`: read `tailscale status --json` and
4
+ // `tailscale serve status --json`, and build the `tailscale serve` arguments.
5
+ // No process spawning here, so each decision is testable on its own.
6
+
7
+ /** The one HTTPS port Bivy claims on the machine's tailnet name. */
8
+ export const SERVE_HTTPS_PORT = 443;
9
+
10
+ /** `{ running, hostname }` from `tailscale status --json` output. */
11
+ export function parseTailscaleStatus(text) {
12
+ let status;
13
+ try { status = JSON.parse(text); } catch { return { running: false, hostname: null }; }
14
+ const hostname = String(status?.Self?.DNSName ?? "").replace(/\.$/, "") || null;
15
+ return { running: status?.BackendState === "Running", hostname };
16
+ }
17
+
18
+ /** Where `tailscale serve` should send the machine's HTTPS traffic. */
19
+ export function serveTarget(port) {
20
+ return `http://127.0.0.1:${port}`;
21
+ }
22
+
23
+ export function serveOnArgs(port) {
24
+ return ["serve", "--bg", `--https=${SERVE_HTTPS_PORT}`, serveTarget(port)];
25
+ }
26
+
27
+ export function serveOffArgs() {
28
+ return ["serve", `--https=${SERVE_HTTPS_PORT}`, "off"];
29
+ }
30
+
31
+ /**
32
+ * What already sits on `<hostname>:443`, from `tailscale serve status --json`:
33
+ * `funnel` when it is public (Tailscale Funnel), and the proxy target of `/`
34
+ * when something is served there. Bivy refuses to share a Funnel'd name, and
35
+ * refuses to replace a target that isn't its own.
36
+ */
37
+ export function serveState(text, hostname) {
38
+ let status;
39
+ try { status = JSON.parse(text || "{}"); } catch { status = {}; }
40
+ const key = `${hostname}:${SERVE_HTTPS_PORT}`;
41
+ const handlers = status?.Web?.[key]?.Handlers ?? {};
42
+ return {
43
+ funnel: status?.AllowFunnel?.[key] === true,
44
+ target: typeof handlers["/"]?.Proxy === "string" ? handlers["/"].Proxy : null,
45
+ otherPaths: Object.keys(handlers).filter((p) => p !== "/"),
46
+ };
47
+ }
48
+
49
+ /** Why `bivy tailscale` can't take `<hostname>:443`, or null when it can. */
50
+ export function serveConflict(state, port) {
51
+ if (state.funnel) return "Tailscale Funnel is on for this machine's HTTPS name, which would put Bivy on the public internet. Turn it off first: tailscale funnel reset";
52
+ if (state.target && state.target !== serveTarget(port)) return `tailscale serve already sends https:// on this machine to ${state.target}. Remove it first: tailscale serve --https=${SERVE_HTTPS_PORT} off`;
53
+ if (state.otherPaths.length) return `tailscale serve already has paths on this machine's HTTPS name (${state.otherPaths.join(", ")}). Remove them first: tailscale serve --https=${SERVE_HTTPS_PORT} off`;
54
+ return null;
55
+ }
56
+
57
+ /** A one-line hint for a failed `tailscale serve`, from its stderr. */
58
+ export function serveFailureHint(stderr) {
59
+ const text = String(stderr ?? "");
60
+ if (/access denied|permission denied|must be root|operator/i.test(text)) return "Let your user manage Tailscale once: sudo tailscale set --operator=$USER";
61
+ if (/https.*not enabled|enable https|certificates/i.test(text)) return "Turn on HTTPS certificates for your tailnet (admin console → DNS → HTTPS Certificates), then run this again.";
62
+ return null;
63
+ }
package/dist/auth.js CHANGED
@@ -112,6 +112,18 @@ export function isLoopbackAddress(address) {
112
112
  const addr = address.replace(/^::ffff:/, "");
113
113
  return addr === "127.0.0.1" || addr === "::1" || addr === "localhost";
114
114
  }
115
+ // Sockets accepted by a listener that fronts remote callers through a local
116
+ // proxy (`tailscale serve` forwards tailnet traffic from 127.0.0.1). Their peer
117
+ // address is loopback, but the caller is not, so they never get loopback trust.
118
+ const remoteSockets = new WeakSet();
119
+ /** Treat every connection `server` accepts as remote, whatever its peer address. */
120
+ export function markRemoteListener(server) {
121
+ server.on("connection", (socket) => remoteSockets.add(socket));
122
+ }
123
+ /** True when a request physically arrived over loopback from a local caller. */
124
+ export function isLoopbackRequest(req) {
125
+ return !!req.socket && !remoteSockets.has(req.socket) && isLoopbackAddress(req.socket.remoteAddress);
126
+ }
115
127
  export function extractToken(headerValue) {
116
128
  if (!headerValue)
117
129
  return null;
@@ -141,7 +153,7 @@ export function tokenFromRequest(req) {
141
153
  * `/api/auth/bootstrap`) can rely on it even when the general bypass in
142
154
  * `isAuthorized` is off. */
143
155
  export function resolveAuth(identity, req) {
144
- const loopback = isLoopbackAddress(req.socket?.remoteAddress);
156
+ const loopback = isLoopbackRequest(req);
145
157
  const deviceId = identity.verifyToken(tokenFromRequest(req));
146
158
  return { deviceId, loopback };
147
159
  }
@@ -160,7 +172,8 @@ export function isAuthorized(ctx) {
160
172
  * web client (?local=1), a LAN device browsing the node directly, or CLI/native
161
173
  * clients (no Origin).
162
174
  * Remote phones reach the node through the relay, which the node dials
163
- * *outbound* — those never arrive here as inbound upgrades. So we allow only
175
+ * *outbound* — those never arrive here as inbound upgrades — or over Tailscale
176
+ * on the `*.ts.net` name, which counts as local here. So we allow only
164
177
  * local/private hostnames and reject a public Host (rebinding) or public Origin
165
178
  * (cross-site). Escape hatches: BIVY_ALLOWED_HOSTS (comma-separated extra
166
179
  * hostnames, e.g. a reverse-proxy domain) and BIVY_ALLOW_ANY_ORIGIN=1.
@@ -272,7 +285,7 @@ export function authMiddleware(identity, sessionTokens) {
272
285
  return;
273
286
  }
274
287
  sessionTokens.used?.(sessionId, call);
275
- req.auth = { deviceId: null, loopback: isLoopbackAddress(req.socket?.remoteAddress), sessionId };
288
+ req.auth = { deviceId: null, loopback: isLoopbackRequest(req), sessionId };
276
289
  next();
277
290
  return;
278
291
  }
@@ -0,0 +1,41 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // One-time pairing codes for devices that reach the node directly (over
5
+ // Tailscale). The owner mints a code on the machine (`bivy tailscale pair`),
6
+ // the phone opens `https://<machine>.ts.net/#pair=<code>` and trades the code
7
+ // for a device token. Codes are single-use, short-lived and held only in
8
+ // memory, so a restart invalidates every outstanding one.
9
+ import { createHash, randomBytes } from "node:crypto";
10
+ export const PAIR_CODE_TTL_MS = 10 * 60 * 1000;
11
+ const digest = (code) => createHash("sha256").update(code).digest("hex");
12
+ export class PairCodes {
13
+ now;
14
+ // Keyed by the code's hash, so a lookup never compares secrets directly.
15
+ pending = new Map();
16
+ constructor(now = Date.now) {
17
+ this.now = now;
18
+ }
19
+ issue(ttlMs = PAIR_CODE_TTL_MS) {
20
+ this.sweep();
21
+ const code = randomBytes(24).toString("base64url");
22
+ const expiresAt = this.now() + ttlMs;
23
+ this.pending.set(digest(code), expiresAt);
24
+ return { code, expiresAt };
25
+ }
26
+ /** True once per valid, unexpired code; the code is spent either way. */
27
+ redeem(code) {
28
+ if (!code)
29
+ return false;
30
+ const key = digest(code);
31
+ const expiresAt = this.pending.get(key);
32
+ this.pending.delete(key);
33
+ return expiresAt !== undefined && expiresAt > this.now();
34
+ }
35
+ sweep() {
36
+ const now = this.now();
37
+ for (const [key, expiresAt] of this.pending)
38
+ if (expiresAt <= now)
39
+ this.pending.delete(key);
40
+ }
41
+ }
@@ -0,0 +1,23 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Direct access over Tailscale: no control plane, no relay. `bivy tailscale`
5
+ // writes `<data-dir>/tailscale.json` and points `tailscale serve` (HTTPS on the
6
+ // machine's ts.net name, tailnet only) at a loopback port; the node opens that
7
+ // port as its direct listener. Every connection on it is treated as remote, so
8
+ // callers need a device token, which a phone gets by redeeming a pairing code.
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ export const DIRECT_CONFIG_FILE = "tailscale.json";
12
+ export function loadDirectListenerConfig(appDir) {
13
+ try {
14
+ const raw = JSON.parse(fs.readFileSync(path.join(appDir, DIRECT_CONFIG_FILE), "utf8"));
15
+ const port = Number(raw.port);
16
+ if (!Number.isInteger(port) || port <= 0 || port > 65535)
17
+ return null;
18
+ return { port, hostname: typeof raw.hostname === "string" && raw.hostname ? raw.hostname : undefined };
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
@@ -12,3 +12,4 @@
12
12
  // promote to a standalone `@bivy/remote` npm package.
13
13
  export { RelayConnector, loadRelayConfig } from "./relay-client.js";
14
14
  export { soloCredentials, buildDialUrl } from "./solo.js";
15
+ export { loadDirectListenerConfig, DIRECT_CONFIG_FILE } from "./direct.js";
package/dist/server.js CHANGED
@@ -84,8 +84,11 @@ import { canOpenBrowser, openBrowser } from "./browser-open.js";
84
84
  import { openOAuthLoginOnNode } from "./runtime/oauth/oauth-node-open.js";
85
85
  import { collectNodeStats } from "./node-stats.js";
86
86
  import { SessionEventCoalescer } from "./session-event-coalescer.js";
87
- import { authMiddleware, resolveAuth, isAuthorized, requestOriginAllowed } from "./auth.js";
88
- import { RelayConnector, loadRelayConfig, soloCredentials } from "./remote/index.js";
87
+ import { authMiddleware, resolveAuth, isAuthorized, requestOriginAllowed, markRemoteListener } from "./auth.js";
88
+ import { PairCodes } from "./pair-codes.js";
89
+ import http from "node:http";
90
+ import { webAppDirs, webAppRouter } from "./web-app.js";
91
+ import { RelayConnector, loadRelayConfig, loadDirectListenerConfig, soloCredentials } from "./remote/index.js";
89
92
  import { readEphemeralTeardownConfig, shouldSelfTeardown, snapshotsDurableForTeardown, performSelfTeardown } from "./ephemeral-teardown.js";
90
93
  import { buildSessionSnapshot, applySessionSnapshot } from "./session/snapshot.js";
91
94
  import { clearTurnActivity } from "./session/turn-activity.js";
@@ -1070,9 +1073,16 @@ const accessDevices = createAccessDeviceController({
1070
1073
  create: (name) => identity.createDevice(name),
1071
1074
  revoke: (id) => identity.revokeDevice(id),
1072
1075
  onCreated: (device) => broadcast({ type: "device.created", device }),
1073
- onRevoked: (id) => broadcast({ type: "device.revoked", id }),
1076
+ onRevoked: (id) => {
1077
+ broadcast({ type: "device.revoked", id });
1078
+ // A revoked token loses its open sockets now, not at its next reconnect.
1079
+ for (const socket of clients)
1080
+ if (socketDeviceIds.get(socket) === id)
1081
+ socket.close(1008, "Unauthorized");
1082
+ },
1074
1083
  });
1075
1084
  const clients = new Set();
1085
+ const socketDeviceIds = new WeakMap();
1076
1086
  const commandProcesses = new Map();
1077
1087
  const oauthLogins = new Map();
1078
1088
  // A browser-initiated subscription login parks the node on `manualCodePromise`
@@ -9337,7 +9347,8 @@ app.use((_req, res, next) => {
9337
9347
  // (OAuth callback, GitHub App manifest). It does NOT host the web UI — the
9338
9348
  // React/Vite PWA (@bivy/web) is served exclusively by the hosted or self-hosted
9339
9349
  // control plane. `/` returns a small informational stub for anyone who points a
9340
- // browser straight at the node.
9350
+ // browser straight at the node. The one exception is the direct listener for
9351
+ // `bivy tailscale` (below), which has no control plane and serves the app itself.
9341
9352
  app.get("/", (_req, res) => {
9342
9353
  res.setHeader("Cache-Control", "no-store, max-age=0");
9343
9354
  res
@@ -9447,6 +9458,20 @@ app.post("/api/auth/bootstrap", sensitiveRateLimiter, (req, res) => {
9447
9458
  const { device, token } = accessDevices.create(name);
9448
9459
  res.json({ ok: true, device, token });
9449
9460
  });
9461
+ // Pairing a device that reaches the node directly (Tailscale): trade a one-time
9462
+ // code minted on this machine for a device token. Registered before the auth
9463
+ // middleware because the caller has no token yet; the code is the credential.
9464
+ const pairCodes = new PairCodes();
9465
+ app.post("/api/auth/pair", sensitiveRateLimiter, (req, res) => {
9466
+ if (!requestOriginAllowed(req))
9467
+ return res.status(403).json({ error: "Forbidden origin" });
9468
+ if (!pairCodes.redeem(String(req.body?.code ?? ""))) {
9469
+ return res.status(401).json({ error: "This pairing link is used or expired. Run 'bivy tailscale pair' on your machine for a new one." });
9470
+ }
9471
+ const name = String(req.body?.name ?? "").trim().slice(0, 80) || "Paired device";
9472
+ const { device, token } = accessDevices.create(name);
9473
+ res.json({ ok: true, device, token });
9474
+ });
9450
9475
  // All other /api routes require auth (loopback may bypass, per config and host
9451
9476
  // — see loopbackAllowed()/isMultiUserHost() in src/auth.ts).
9452
9477
  app.use("/api", authMiddleware(identity, {
@@ -9457,6 +9482,23 @@ app.use("/api", authMiddleware(identity, {
9457
9482
  used: (sessionId, call) => { if (call.method !== "GET")
9458
9483
  auditLog.record({ kind: "agent.call", session: sessionId, tool: `${call.method} ${call.path}` }); },
9459
9484
  }));
9485
+ // Mint a pairing code for a direct (Tailscale) device. Only callers already
9486
+ // trusted by this node get one: the owner's CLI, or an already-paired device.
9487
+ app.post("/api/auth/pair-codes", (_req, res) => {
9488
+ const direct = loadDirectListenerConfig(appDir);
9489
+ const { code, expiresAt } = pairCodes.issue();
9490
+ const url = direct?.hostname ? `https://${direct.hostname}/#pair=${code}` : undefined;
9491
+ res.json({ ok: true, code, expiresAt, url });
9492
+ });
9493
+ // Start, move or stop the direct listener after `bivy tailscale` changes it.
9494
+ app.post("/api/direct/reload", async (_req, res, next) => {
9495
+ try {
9496
+ res.json({ ok: true, ...(await reloadDirectListener()) });
9497
+ }
9498
+ catch (error) {
9499
+ next(error);
9500
+ }
9501
+ });
9460
9502
  // Reload relay.json without forcing the user to restart the whole node. This is
9461
9503
  // used by `bivy relay:setup` after it enrolls the node.
9462
9504
  app.post("/api/relay/reload", (_req, res) => {
@@ -12007,6 +12049,7 @@ const server = app.listen(port, host, async () => {
12007
12049
  console.log(`Agent data dir: ${piDir}`);
12008
12050
  console.log(`Workspace: ${defaultWorkspace}`);
12009
12051
  startRelayIfConfigured();
12052
+ void reloadDirectListener().catch((error) => console.error("[direct] Could not start the direct listener:", error instanceof Error ? error.message : error));
12010
12053
  void syncAgentBridges().catch((error) => console.warn("[bridges] Could not install agent bridges; they will install on first use:", error instanceof Error ? error.message : error));
12011
12054
  if (appGateway) {
12012
12055
  appGateway.server.on("error", (error) => { console.error("[apps] Preview gateway could not start:", error); shutdown("preview gateway failure"); });
@@ -12073,6 +12116,46 @@ server.on("error", (error) => {
12073
12116
  process.exit(1);
12074
12117
  });
12075
12118
  const wss = new WebSocketServer({ server, path: "/ws" });
12119
+ // Direct listener (`bivy tailscale`): the same API and WebSocket, plus the web
12120
+ // app, on a loopback port `tailscale serve` exposes to the tailnet over HTTPS.
12121
+ // markRemoteListener keeps its proxied, loopback-looking connections from ever
12122
+ // getting loopback trust; every caller needs a device token.
12123
+ const directWebApp = webAppRouter(webAppDirs(repoRoot));
12124
+ let directListener = null;
12125
+ async function reloadDirectListener() {
12126
+ const config = loadDirectListenerConfig(appDir);
12127
+ if (directListener && directListener.port !== config?.port) {
12128
+ const previous = directListener;
12129
+ directListener = null;
12130
+ for (const client of previous.wss.clients)
12131
+ client.terminate();
12132
+ previous.wss.close();
12133
+ previous.server.closeAllConnections();
12134
+ await new Promise((resolve) => previous.server.close(() => resolve()));
12135
+ }
12136
+ if (config && !directListener) {
12137
+ const directApp = express();
12138
+ directApp.disable("x-powered-by");
12139
+ if (directWebApp)
12140
+ directApp.use(directWebApp);
12141
+ directApp.use(app);
12142
+ const listener = http.createServer(directApp);
12143
+ markRemoteListener(listener);
12144
+ const directWss = new WebSocketServer({ server: listener, path: "/ws" });
12145
+ directWss.on("connection", (socket, req) => wss.emit("connection", socket, req));
12146
+ await new Promise((resolve, reject) => {
12147
+ listener.once("error", reject);
12148
+ listener.listen(config.port, "127.0.0.1", () => {
12149
+ listener.off("error", reject);
12150
+ resolve();
12151
+ });
12152
+ });
12153
+ listener.on("error", (error) => console.error("[direct] Listener error:", error));
12154
+ directListener = { port: config.port, server: listener, wss: directWss };
12155
+ console.log(`[direct] Listening on 127.0.0.1:${config.port}${config.hostname ? ` for https://${config.hostname}` : ""}${directWebApp ? "" : " (web app not built; API only)"}`);
12156
+ }
12157
+ return { enabled: Boolean(directListener), port: directListener?.port, webApp: Boolean(directWebApp) };
12158
+ }
12076
12159
  wss.on("connection", (socket, req) => {
12077
12160
  // Reject cross-origin / DNS-rebinding upgrades before auth: loopback bypasses
12078
12161
  // auth by default, so a page the user merely visited could otherwise open a
@@ -12081,11 +12164,14 @@ wss.on("connection", (socket, req) => {
12081
12164
  socket.close(1008, "Forbidden origin");
12082
12165
  return;
12083
12166
  }
12084
- if (!isAuthorized(resolveAuth(identity, req))) {
12167
+ const socketAuth = resolveAuth(identity, req);
12168
+ if (!isAuthorized(socketAuth)) {
12085
12169
  socket.close(1008, "Unauthorized");
12086
12170
  return;
12087
12171
  }
12088
12172
  clients.add(socket);
12173
+ if (socketAuth.deviceId)
12174
+ socketDeviceIds.set(socket, socketAuth.deviceId);
12089
12175
  // Terminals opened on this socket; closed when the socket disconnects so a
12090
12176
  // dropped browser tab doesn't leak shells. Output is unicast to this socket.
12091
12177
  const ownedTerminals = new Set();
@@ -0,0 +1,55 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // The web app, served by the node itself on its direct listener (Tailscale).
5
+ // Elsewhere the control plane serves it and the node stays a pure data plane;
6
+ // here there is no control plane, so the node hands out the same build and
7
+ // tells it, through /runtime-config.js, to talk to this node directly.
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import express from "express";
11
+ /** Where a packaged install and a source checkout keep the built app and its icons. */
12
+ export function webAppDirs(packageRoot) {
13
+ return [
14
+ path.join(packageRoot, "web"),
15
+ path.join(packageRoot, "packages", "web", "dist"),
16
+ path.join(packageRoot, "services", "control-plane", "public"),
17
+ ].filter((dir) => fs.existsSync(dir));
18
+ }
19
+ /** Paths the node itself answers, never the app shell. */
20
+ const NODE_ROUTES = ["/api", "/ws", "/healthz", "/github"];
21
+ function noStore(res) {
22
+ res.setHeader("Cache-Control", "no-store, max-age=0");
23
+ }
24
+ /** Serves the app shell, assets and a direct-mode runtime config; passes on anything else. */
25
+ export function webAppRouter(dirs) {
26
+ const indexDir = dirs.find((dir) => fs.existsSync(path.join(dir, "index.html")));
27
+ if (!indexDir)
28
+ return null;
29
+ // Read once: the shell is served from memory, with no file access per request.
30
+ const indexHtml = fs.readFileSync(path.join(indexDir, "index.html"));
31
+ const router = express.Router();
32
+ router.get("/runtime-config.js", (_req, res) => {
33
+ noStore(res);
34
+ res.type("application/javascript").send(`globalThis.__BIVY_RUNTIME_CONFIG__ = ${JSON.stringify({ directNode: true })};\n`);
35
+ });
36
+ for (const dir of dirs) {
37
+ router.use(express.static(dir, {
38
+ index: false,
39
+ setHeaders(res, filePath) {
40
+ const name = path.basename(filePath);
41
+ if (name === "index.html" || name === "sw.js" || name.startsWith("workbox-"))
42
+ noStore(res);
43
+ },
44
+ }));
45
+ }
46
+ // Client-side routes (`/sessions/…`, `/settings`, `/share`, …) load the shell;
47
+ // the node's own routes pass through.
48
+ router.get(/.*/, (req, res, next) => {
49
+ if (NODE_ROUTES.some((prefix) => req.path === prefix || req.path.startsWith(`${prefix}/`)) || !req.accepts("html"))
50
+ return next();
51
+ noStore(res);
52
+ res.type("html").send(indexHtml);
53
+ });
54
+ return router;
55
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.20.1-staging.5",
3
+ "version": "0.20.1-staging.6",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",
Binary file