@bivy/bivy 0.20.1-staging.8 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/bin/access-view.mjs +43 -0
  2. package/bin/bivy.mjs +111 -14
  3. package/bin/cli-commands.mjs +1 -0
  4. package/bin/tailscale.mjs +10 -3
  5. package/dist/access.js +72 -0
  6. package/dist/apps/listeners.js +31 -3
  7. package/dist/apps/service.js +8 -1
  8. package/dist/apps/styles.css +4 -0
  9. package/dist/auth.js +18 -4
  10. package/dist/controllers/app-commands.js +7 -2
  11. package/dist/protocol/client-command-routes.js +2 -0
  12. package/dist/protocol/client-command-schemas.js +3 -1
  13. package/dist/remote/direct.js +2 -1
  14. package/dist/remote/index.js +1 -0
  15. package/dist/remote/tailnet.js +44 -0
  16. package/dist/server.js +40 -2
  17. package/dist/session/run-terminal.js +6 -2
  18. package/package.json +1 -1
  19. package/web/assets/AutomationsView-fnzmthnq.js +51 -0
  20. package/web/assets/{ChangesCard-sGQlkM2d.js → ChangesCard-BWUgQ40Y.js} +1 -1
  21. package/web/assets/{LibraryView-DYUo2624.js → LibraryView-JlSOji08.js} +1 -1
  22. package/web/assets/{ProviderConnect-DB-gwOwe.js → ProviderConnect-BFFZWHsW.js} +1 -1
  23. package/web/assets/Settings-BAHWxYDH.js +10 -0
  24. package/web/assets/{Sheet-BmAB1Fu_.js → Sheet-cVfLQVwf.js} +1 -1
  25. package/web/assets/{Terminal-Dkr504wc.js → Terminal-C3QFXkbI.js} +1 -1
  26. package/web/assets/{VoiceSettings-DaR6ba3A.js → VoiceSettings-CWOtAlAV.js} +1 -1
  27. package/web/assets/{abnfDiagram-VCTEODGH-CZTMfHPQ.js → abnfDiagram-VCTEODGH-7cQ-bgry.js} +1 -1
  28. package/web/assets/architecture-7GRP2DOG-DVlvlW8H.js +1 -0
  29. package/web/assets/{architectureDiagram-5GKGNRK7-BBR7u-Nc.js → architectureDiagram-5GKGNRK7-BWOpNQNn.js} +1 -1
  30. package/web/assets/{blockDiagram-I7D4REHJ-DU1iTe41.js → blockDiagram-I7D4REHJ-8eE7JD9I.js} +1 -1
  31. package/web/assets/{c4Diagram-7LVT6UL2-DoY25VIT.js → c4Diagram-7LVT6UL2-DgiPr6K1.js} +1 -1
  32. package/web/assets/channel-Coq89lsO.js +1 -0
  33. package/web/assets/{chunk-4HAMMTFA-CwLS5ngk.js → chunk-4HAMMTFA-C8GTT_rj.js} +1 -1
  34. package/web/assets/{chunk-75Z2AOVW-FHeNbdLy.js → chunk-75Z2AOVW-BRMKvs-_.js} +1 -1
  35. package/web/assets/{chunk-DU6HZSFF-CUNfwm6b.js → chunk-DU6HZSFF-A-GuRJzV.js} +1 -1
  36. package/web/assets/{chunk-F27PBJKO-BT9LzzRz.js → chunk-F27PBJKO-D34kNJfE.js} +1 -1
  37. package/web/assets/{chunk-GMAD6QVW-BUr3PoNi.js → chunk-GMAD6QVW-CzLW6Gbl.js} +1 -1
  38. package/web/assets/{chunk-GVQU2GXP-Dwa-Ue8e.js → chunk-GVQU2GXP-CqTFATNd.js} +1 -1
  39. package/web/assets/{chunk-IMKFNOWR-CBemkwVh.js → chunk-IMKFNOWR-BLo_6uPl.js} +1 -1
  40. package/web/assets/{chunk-L3NEJ4N5-AGhZr4q5.js → chunk-L3NEJ4N5-CFSYYra2.js} +1 -1
  41. package/web/assets/{chunk-OSK3NFVY-CxTtDDij.js → chunk-OSK3NFVY-DJ3F-6TB.js} +1 -1
  42. package/web/assets/{chunk-P2QGCYS3-DGmss7hg.js → chunk-P2QGCYS3-B3qbBF6d.js} +1 -1
  43. package/web/assets/{chunk-POPQ4Y6H-nsxQTUJd.js → chunk-POPQ4Y6H-DbN8xQ-f.js} +1 -1
  44. package/web/assets/{chunk-PWAF6VOD-CFEkzIZ2.js → chunk-PWAF6VOD-C7BBYtAa.js} +1 -1
  45. package/web/assets/{chunk-SHT3W25Y-BArb8KoG.js → chunk-SHT3W25Y-B3wT37BE.js} +1 -1
  46. package/web/assets/{chunk-SVP7TREG-CEFJfpYl.js → chunk-SVP7TREG-OXvICZat.js} +1 -1
  47. package/web/assets/{chunk-TICWLB2K-ByqSjpyA.js → chunk-TICWLB2K-CrmJ4sWX.js} +1 -1
  48. package/web/assets/classDiagram-ZZMXUADV-rFDDoTN3.js +1 -0
  49. package/web/assets/classDiagram-v2-VYDZK3BY-rFDDoTN3.js +1 -0
  50. package/web/assets/{cynefin-OW5HDTMX-BPd4eJmp.js → cynefin-OW5HDTMX-BFhdG87z.js} +1 -1
  51. package/web/assets/{cynefinDiagram-5FMLGOSQ-rm271vYO.js → cynefinDiagram-5FMLGOSQ-BlBRhJR5.js} +1 -1
  52. package/web/assets/{dagre-GXQ25YYZ-a4DVMh7o.js → dagre-GXQ25YYZ-COxHf3g-.js} +1 -1
  53. package/web/assets/{diagram-S7CK7UJ4-CR4kj8JQ.js → diagram-S7CK7UJ4-mWtNPBS9.js} +1 -1
  54. package/web/assets/{diagram-UQ7AKVKN-BVAidz_h.js → diagram-UQ7AKVKN-CAvoNcsc.js} +1 -1
  55. package/web/assets/{diagram-VSXAHHWV-P_KshIhr.js → diagram-VSXAHHWV-DA9LJqgE.js} +1 -1
  56. package/web/assets/{diagram-VX7I27RA-CfR4HucM.js → diagram-VX7I27RA-7RswVDku.js} +1 -1
  57. package/web/assets/{diagram-Z3DM3KII-CnxFHDS4.js → diagram-Z3DM3KII-DknRBMRd.js} +1 -1
  58. package/web/assets/{ebnfDiagram-PWID7BFC-0lvDIevQ.js → ebnfDiagram-PWID7BFC-hAmbemf1.js} +1 -1
  59. package/web/assets/{erDiagram-RLTQ6QDP-B5onTaVV.js → erDiagram-RLTQ6QDP-DxXSxQWj.js} +1 -1
  60. package/web/assets/eventmodeling-NTZA5JFV-CMDfKRrP.js +1 -0
  61. package/web/assets/flowDiagram-HODETNUW-BQ27YGHa.js +1 -0
  62. package/web/assets/{ganttDiagram-EL5Y4UJY-DcP58ot2.js → ganttDiagram-EL5Y4UJY-BV-zpuyt.js} +1 -1
  63. package/web/assets/{gitGraph-4MIJSDKK-DMgkg1Fa.js → gitGraph-4MIJSDKK-BfZe7_3E.js} +1 -1
  64. package/web/assets/{gitGraphDiagram-WWUBYQGX-ChZQlU2S.js → gitGraphDiagram-WWUBYQGX-3_hHUbBU.js} +1 -1
  65. package/web/assets/index-D36pVYmX.css +1 -0
  66. package/web/assets/{index-emCm7_bF.js → index-x_iImoSf.js} +2 -2
  67. package/web/assets/{info-A6RAGUB7-Bwm7-KTz.js → info-A6RAGUB7-DUWO2H1L.js} +1 -1
  68. package/web/assets/{infoDiagram-27XIBGKW-PpNSZSrV.js → infoDiagram-27XIBGKW-BKxWRZYw.js} +1 -1
  69. package/web/assets/{ishikawaDiagram-5VMMS53U-DXh6yXna.js → ishikawaDiagram-5VMMS53U-CxQdy8l3.js} +1 -1
  70. package/web/assets/{journeyDiagram-3NMN7TZE-DFEj5jkB.js → journeyDiagram-3NMN7TZE-DplhU31X.js} +1 -1
  71. package/web/assets/{kanban-definition-UXKFOSKX-C5Mvo17h.js → kanban-definition-UXKFOSKX-Cmls5FZv.js} +1 -1
  72. package/web/assets/{line-CqUbcJrv.js → line-NoU3PhOc.js} +1 -1
  73. package/web/assets/{mermaid-parser.core-BzUSMP_7.js → mermaid-parser.core-CyOqjoOF.js} +3 -3
  74. package/web/assets/{mermaid.core-CooqV3Py.js → mermaid.core-BbpKlZWj.js} +4 -4
  75. package/web/assets/{mindmap-definition-YA3MSWOX-CvsfS7bG.js → mindmap-definition-YA3MSWOX-CfDDhyG9.js} +1 -1
  76. package/web/assets/mount-GHVLdZcl.js +27 -0
  77. package/web/assets/{packet-AYTQ26CC-BzXGhTo4.js → packet-AYTQ26CC-CEyZqXlC.js} +1 -1
  78. package/web/assets/{pegDiagram-XKGWAZYB-BvdN2RIR.js → pegDiagram-XKGWAZYB-BsQ551jM.js} +1 -1
  79. package/web/assets/{pie-WAS4IAKB-CNH8JQby.js → pie-WAS4IAKB-Cy_8_ris.js} +1 -1
  80. package/web/assets/{pieDiagram-E7YTZNPT-C6LABPqT.js → pieDiagram-E7YTZNPT-DQHtyFkg.js} +1 -1
  81. package/web/assets/{quadrantDiagram-AXDQQJYC-D2ebcGIP.js → quadrantDiagram-AXDQQJYC-iSVEwT1y.js} +1 -1
  82. package/web/assets/{radar-RG4KPBEZ-B7uFPzC9.js → radar-RG4KPBEZ-BO-qdlYd.js} +1 -1
  83. package/web/assets/{railroad-74A4TZTK-CE9pdZ9o.js → railroad-74A4TZTK-BEuDeDCt.js} +1 -1
  84. package/web/assets/railroad-abnf-HS5TGJTU-BbGdsa6v.js +1 -0
  85. package/web/assets/railroad-ebnf-LZEXJU2U-qaRxJkxs.js +1 -0
  86. package/web/assets/railroad-peg-WCYAUIDC-C6hTOfSB.js +1 -0
  87. package/web/assets/{railroadDiagram-O6MQD6OU-vJc9tRJP.js → railroadDiagram-O6MQD6OU-Bmp5czol.js} +1 -1
  88. package/web/assets/{requirementDiagram-BXWQKSXE-CVLUnEfL.js → requirementDiagram-BXWQKSXE-CflpYj7d.js} +1 -1
  89. package/web/assets/{sankeyDiagram-P5KCCOFB-DkHI3ZIh.js → sankeyDiagram-P5KCCOFB-D3xDfaLR.js} +1 -1
  90. package/web/assets/{sequenceDiagram-WJ2MYXX4-BgosmpaZ.js → sequenceDiagram-WJ2MYXX4-C6ZWIJwX.js} +1 -1
  91. package/web/assets/settingsRoute-COWp-9BH.js +1 -0
  92. package/web/assets/{stateDiagram-D77RDMKH-BZ5Zqme7.js → stateDiagram-D77RDMKH-BXjL8DPv.js} +1 -1
  93. package/web/assets/stateDiagram-v2-MP3YSRHH-BCGm337k.js +1 -0
  94. package/web/assets/{swimlanes-42K2YHIH-rkMg8KEw.js → swimlanes-42K2YHIH-CkyaLI3E.js} +1 -1
  95. package/web/assets/swimlanesDiagram-VR7AAH4N-RZPHF_4s.js +8 -0
  96. package/web/assets/{timeline-definition-24CTP7MA-_z_HJLWC.js → timeline-definition-24CTP7MA-BdMWCl3_.js} +1 -1
  97. package/web/assets/{treeView-Q6P3EWNA-jR5Z1Fmf.js → treeView-Q6P3EWNA-C4MMyFq1.js} +1 -1
  98. package/web/assets/{treemap-WGGIJYW6-OtJhfdUS.js → treemap-WGGIJYW6-BUER7WW0.js} +1 -1
  99. package/web/assets/{useStore-CaPvUEwc.js → useStore-B5xeFBSp.js} +3 -3
  100. package/web/assets/{vennDiagram-4TSXK5OY-BFTbjE-l.js → vennDiagram-4TSXK5OY-CmcfuybF.js} +1 -1
  101. package/web/assets/{wardley-WFR3VGLG-BV0ly0l7.js → wardley-WFR3VGLG-CbuHqcZs.js} +1 -1
  102. package/web/assets/{wardleyDiagram-VM6X3IG4-DYvCqNRg.js → wardleyDiagram-VM6X3IG4-B-dD7NPY.js} +1 -1
  103. package/web/assets/{xychartDiagram-S5SC5T6Z-CaYy8yTC.js → xychartDiagram-S5SC5T6Z-DkJf1OlB.js} +1 -1
  104. package/web/index.html +2 -2
  105. package/web/sw.js +1 -1
  106. package/web/assets/AutomationsView-BnHdmp7P.js +0 -51
  107. package/web/assets/Settings-VYk0Et0A.js +0 -10
  108. package/web/assets/architecture-7GRP2DOG-MiVMMOHz.js +0 -1
  109. package/web/assets/channel-m_2Ky5Co.js +0 -1
  110. package/web/assets/classDiagram-ZZMXUADV-BM6g8SUg.js +0 -1
  111. package/web/assets/classDiagram-v2-VYDZK3BY-BM6g8SUg.js +0 -1
  112. package/web/assets/eventmodeling-NTZA5JFV-DviokIMu.js +0 -1
  113. package/web/assets/flowDiagram-HODETNUW-BtFCDXEP.js +0 -1
  114. package/web/assets/index-CsC63U16.css +0 -1
  115. package/web/assets/mount-BD-g3eYn.js +0 -26
  116. package/web/assets/railroad-abnf-HS5TGJTU-C_bKzcjL.js +0 -1
  117. package/web/assets/railroad-ebnf-LZEXJU2U-BzPEt-Wn.js +0 -1
  118. package/web/assets/railroad-peg-WCYAUIDC-CeL6qyBT.js +0 -1
  119. package/web/assets/stateDiagram-v2-MP3YSRHH-DtyGmcgM.js +0 -1
  120. package/web/assets/swimlanesDiagram-VR7AAH4N-CbnaKO-d.js +0 -8
@@ -0,0 +1,43 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Terminal views of the node's access report (src/access.ts): the full
4
+ // `bivy access` screen and the one-line summary `bivy status` shows.
5
+
6
+ const REACH_WORD = ["", "on your tailnet", "from anywhere"];
7
+
8
+ /** "Tailscale + Bivy hosted", or "This machine only". */
9
+ export function activeLabel(report) {
10
+ const on = report.setups.filter((s) => s.active && s.id !== "local");
11
+ return on.length ? on.map((s) => s.label).join(" + ") : "This machine only";
12
+ }
13
+
14
+ function labelOf(report, id) {
15
+ return report.setups.find((s) => s.id === id)?.label ?? id;
16
+ }
17
+
18
+ /** `access: Tailscale — next: Bivy hosted` for `bivy status`. */
19
+ export function accessLine(report) {
20
+ const next = report.next.filter((n) => n.id !== "server").map((n) => labelOf(report, n.id));
21
+ return `${activeLabel(report)}${next.length ? ` — next: ${next.join(" or ")} ('bivy access')` : ""}`;
22
+ }
23
+
24
+ /** The `bivy access` screen as lines; `paint` supplies colors. */
25
+ export function accessScreen(report, paint) {
26
+ const lines = [`${paint.bold("Access:")} ${activeLabel(report)}`];
27
+ if (report.tailscaleUrl) lines.push(` ${paint.dim("tailnet")} ${report.tailscaleUrl}`);
28
+ if (report.controlPlaneUrl) lines.push(` ${paint.dim("app")} ${report.controlPlaneUrl}`);
29
+ lines.push("");
30
+ for (const feature of report.features) {
31
+ const reach = report.reach[feature.id];
32
+ lines.push(` ${reach ? paint.green("✓") : paint.dim("✗")} ${feature.label}${reach ? paint.dim(` ${REACH_WORD[reach]}`.trimEnd()) : ""}`);
33
+ }
34
+ if (report.next.length) {
35
+ lines.push("", paint.bold("Next:"));
36
+ for (const step of report.next) {
37
+ const setup = report.setups.find((s) => s.id === step.id);
38
+ lines.push(` ${paint.cyan((setup?.command ?? "").padEnd(26))} ${setup?.label}`, ` ${" ".repeat(26)} ${paint.dim(`adds ${step.addsText}`)}`);
39
+ }
40
+ }
41
+ lines.push("", paint.dim("Setups stack: adding one never takes away another. 'bivy access local' turns remote access off."));
42
+ return lines;
43
+ }
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 { accessLine, accessScreen } from "./access-view.mjs";
43
44
  import { parseTailscaleStatus, serveConflict, serveFailureHint, serveOffArgs, serveOnArgs, serveState } from "./tailscale.mjs";
44
45
  import { resolveAttachSessionId } from "./attach-session-id.mjs";
45
46
  import { detectInstallKind as classifyInstallKind, managedInstall, npmGlobalPrefix } from "./install-kind.mjs";
@@ -4527,6 +4528,8 @@ async function cmdTokenSetup() {
4527
4528
  }
4528
4529
 
4529
4530
  async function cmdSetup(args = []) {
4531
+ // Set by the access question; `bivy tailscale` runs once the node is up.
4532
+ let wantTailscale = false;
4530
4533
  if (args.includes("-h") || args.includes("--help")) {
4531
4534
  console.log("Usage: bivy setup\n\nFirst-run wizard: agent choice, model login, remote access + sign-in, and background service. Workspace and port get safe defaults. Re-run later to change the default agent or remote access.");
4532
4535
  return;
@@ -4634,18 +4637,22 @@ async function cmdSetup(args = []) {
4634
4637
  // (BIVY_CONTROL_PLANE_URL / BIVY_RELAY_URL then pre-fill the URL prompts below).
4635
4638
  const nodeClaimCode = process.env.BIVY_NODE_CLAIM_CODE?.trim();
4636
4639
  const selfHostEnv = !nodeClaimCode && Boolean((process.env.BIVY_CONTROL_PLANE_URL || "").trim() || (process.env.BIVY_RELAY_URL || "").trim());
4640
+ // Tailscale is offered only where it's installed; it needs no account.
4641
+ const tailscaleInstalled = runTailscale(["version"]) !== null;
4637
4642
  const syncChoice = nodeClaimCode ? "h" : await askChoice(
4638
- "Remote access",
4643
+ "How will you reach this machine?",
4639
4644
  [
4640
- { key: "h", label: "hosted (recommended — sign in with GitHub or email; nothing caps your local usage)" },
4641
- { key: "s", label: "self-hosted (your own control plane + relay)" },
4642
- { key: "l", label: "local only for now (terminal CLI only; run 'bivy relay:setup' later)" },
4645
+ { key: "h", label: "Bivy hosted (recommended — sign in with GitHub or email; every machine, from anywhere)" },
4646
+ ...(tailscaleInstalled ? [{ key: "t", label: "Tailscale (your devices over your tailnet; no account, nothing in between)" }] : []),
4647
+ { key: "s", label: "your own server (a Bivy server you run)" },
4648
+ { key: "l", label: "this machine only for now (add more any time with 'bivy access')" },
4643
4649
  ],
4644
4650
  selfHostEnv ? "s" : "h",
4645
4651
  );
4652
+ wantTailscale = syncChoice === "t";
4646
4653
  const relayArgs = [];
4647
- if (syncChoice === "l") {
4648
- console.log(c.dim("\nSkipping remote access. Enable it any time with 'bivy relay:setup'."));
4654
+ if (syncChoice === "l" || syncChoice === "t") {
4655
+ console.log(c.dim(`\n${syncChoice === "t" ? "Tailscale is turned on once the node is running." : "Skipping remote access."} 'bivy access' shows every option later.`));
4649
4656
  }
4650
4657
  if (syncChoice === "s") {
4651
4658
  const endpoints = await getHostedEndpoints();
@@ -4655,7 +4662,7 @@ async function cmdSetup(args = []) {
4655
4662
  if (relayWs.trim()) relayArgs.push("--relay", relayWs.trim());
4656
4663
  }
4657
4664
 
4658
- if (syncChoice !== "l") {
4665
+ if (syncChoice !== "l" && syncChoice !== "t") {
4659
4666
  if (!nodeClaimCode) {
4660
4667
  const loginChoice = await askChoice(
4661
4668
  "Remote login",
@@ -4704,7 +4711,7 @@ async function cmdSetup(args = []) {
4704
4711
  setupSession = consumeSetupSession();
4705
4712
  }
4706
4713
  } else {
4707
- console.log(c.dim("\nRemote access already configured. Re-run 'bivy relay:setup' to change sync or sign-in."));
4714
+ console.log(c.dim("\nRemote access already configured. 'bivy access' shows it and the other options."));
4708
4715
  }
4709
4716
 
4710
4717
  // GitHub App — connect it from the web app (Settings → GitHub App) or with
@@ -4743,6 +4750,19 @@ async function cmdSetup(args = []) {
4743
4750
  }
4744
4751
  }
4745
4752
 
4753
+ // The terminal path to the same loop: the agent's own command starts a Bivy
4754
+ // session, so work begun in a terminal shows up in the app and reaches the
4755
+ // phone. Offered after sign-in, which runs the real agent.
4756
+ const shimAgent = setupAgent?.command;
4757
+ if (agentReady && shimAgent && AGENT_INTEGRATIONS.has(shimAgent) && !loadShims().shims[shimAgent]) {
4758
+ console.log(`\nWhen you type '${shimAgent}' in a terminal, Bivy can run it as a session: it shows up in the app, and your phone hears when it finishes or needs you.`);
4759
+ if (await askYesNo(`Start a Bivy session whenever you run '${shimAgent}'?`, true)) {
4760
+ rl.pause();
4761
+ await run(nodeBin, [selfScript, "shim", "install", shimAgent], { env: process.env });
4762
+ rl.resume();
4763
+ }
4764
+ }
4765
+
4746
4766
  // Materialize canonical typed config before starting/restarting the daemon,
4747
4767
  // so this setup run's workspace/port/agent choices are effective immediately.
4748
4768
  // Existing config keeps every advanced field.
@@ -4784,6 +4804,7 @@ async function cmdSetup(args = []) {
4784
4804
  ? Boolean(liveReadiness?.credential?.ok ?? hasModelConfig(finalConfig))
4785
4805
  : agentAuthReady;
4786
4806
  const repoReady = Boolean(liveReadiness?.repository?.ok);
4807
+ if (wantTailscale) await cmdTailscale(["on"]).catch((error) => console.log(c.yellow(`Tailscale setup didn't finish: ${error?.message || error}. Run 'bivy access tailscale' to retry.`)));
4787
4808
  console.log(c.bold(c.green("\n ✓ Node running. Check first-task readiness below.\n")));
4788
4809
  console.log(` ${c.green("✓")} node reachable at ${url(finalConfig)}`);
4789
4810
  console.log(` ${agentReady ? c.green("✓") : c.yellow("!")} runtime ${agentReady ? `${setupAgent?.label || "Pi"} available` : "not installed — run 'bivy agents:install'"}`);
@@ -4792,7 +4813,7 @@ async function cmdSetup(args = []) {
4792
4813
  const ghReady = githubConnected(finalConfig);
4793
4814
  console.log(` ${ghReady ? c.green("✓") : c.dim("○")} GitHub ${ghReady ? "connected — your repos will list in the app" : c.dim("optional — connect later in the app under Settings → GitHub App")}`);
4794
4815
  console.log(` ${agentReady && modelReady && repoReady ? c.green("✓") : c.yellow("!")} first task ${agentReady && modelReady && repoReady ? "ready to try" : "blocked by the stage above"}`);
4795
- console.log(` ${fs.existsSync(relayConfigPath) ? c.green("✓") : c.yellow("!")} remote ${fs.existsSync(relayConfigPath) ? "configured" : "not configured — run 'bivy relay:setup'"}\n`);
4816
+ console.log(` ${fs.existsSync(relayConfigPath) || fs.existsSync(tailscaleConfigPath) ? c.green("✓") : c.dim("○")} access ${fs.existsSync(relayConfigPath) || fs.existsSync(tailscaleConfigPath) ? "set up — 'bivy access' shows what it gives you" : "this machine only — 'bivy access' shows the options"}\n`);
4796
4817
  // Get the user into the product immediately; terminal commands are the
4797
4818
  // fallback/next-step checklist after the remote app has been opened or linked.
4798
4819
  await finishSetupRemote(finalConfig, setupSession);
@@ -4979,8 +5000,10 @@ async function cmdStatus(args = []) {
4979
5000
  const relay = loadRelayConfig();
4980
5001
  const relaySt = status?.relay;
4981
5002
  const relayConfigured = Boolean(relaySt?.configured || relay);
5003
+ const access = reachable ? await fetchAccess(config).catch(() => null) : null;
5004
+ if (access) console.log(` access: ${accessLine(access)}`);
4982
5005
  if (!relayConfigured) {
4983
- console.log(` remote: ${c.dim("local only")} ${c.dim("('bivy relay:setup' to enable remote access)")}`);
5006
+ if (!access) console.log(` remote: ${c.dim("this machine only")} ${c.dim("('bivy access' to see the options)")}`);
4984
5007
  } else {
4985
5008
  // The live link state is only knowable when the node is running; if it's
4986
5009
  // down we can say it's configured but not whether it's currently connected.
@@ -5175,7 +5198,7 @@ async function cmdDoctor(args = []) {
5175
5198
  const relayApp = status?.relay?.controlPlaneUrl;
5176
5199
  const relayErr = status?.relay?.lastError;
5177
5200
  const relayLine = !relayConfigured
5178
- ? c.dim("local only — 'bivy relay:setup' to enable")
5201
+ ? c.dim("this machine only — 'bivy access' to see the options")
5179
5202
  : relayConnected
5180
5203
  ? c.green("relay connected") + (relayApp ? c.dim(` ${relayApp}`) : "")
5181
5204
  : c.yellow("configured, not connected") + (relayErr ? c.dim(` (${relayErr})`) : "");
@@ -5815,7 +5838,7 @@ async function cmdTailscale(args = []) {
5815
5838
  process.exitCode = EXIT.unavailable;
5816
5839
  return;
5817
5840
  }
5818
- const { running, hostname } = parseTailscaleStatus(status.stdout);
5841
+ const { running, hostname, owner } = parseTailscaleStatus(status.stdout);
5819
5842
  if (!running || !hostname) {
5820
5843
  console.log(`${c.yellow("Tailscale isn't connected.")} Sign in with ${c.cyan("sudo tailscale up")}, then run ${c.cyan("bivy tailscale")} again.`);
5821
5844
  process.exitCode = EXIT.unavailable;
@@ -5828,7 +5851,7 @@ async function cmdTailscale(args = []) {
5828
5851
  process.exitCode = EXIT.conflict;
5829
5852
  return;
5830
5853
  }
5831
- fs.writeFileSync(tailscaleConfigPath, `${JSON.stringify({ port, hostname }, null, 2)}\n`, { mode: 0o600 });
5854
+ fs.writeFileSync(tailscaleConfigPath, `${JSON.stringify({ port, hostname, ...(owner ? { owner } : {}) }, null, 2)}\n`, { mode: 0o600 });
5832
5855
  const reload = await localApi(config, "/api/direct/reload", { method: "POST", body: "{}" });
5833
5856
  const served = runTailscale(serveOnArgs(port));
5834
5857
  if (!served?.ok) {
@@ -5841,12 +5864,83 @@ async function cmdTailscale(args = []) {
5841
5864
  return;
5842
5865
  }
5843
5866
  console.log(c.bold(c.green(`\n Bivy is on https://${hostname}\n`)));
5844
- console.log(c.dim(" Only devices on your tailnet can reach it. Pair each one once with a link from 'bivy tailscale pair'."));
5867
+ console.log(c.dim(owner
5868
+ ? ` Only devices on your tailnet can reach it. Yours (signed in as ${owner}) are let in without pairing;\n anyone else's pairs once with a link from 'bivy tailscale pair'.`
5869
+ : " Only devices on your tailnet can reach it. This machine is tagged, so each device pairs once with a link from 'bivy tailscale pair'."));
5845
5870
  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."));
5846
5871
  console.log("");
5872
+ if (owner) {
5873
+ console.log(`Open ${c.cyan(`https://${hostname}`)} on any of your devices.\n`);
5874
+ return;
5875
+ }
5847
5876
  await printPairLink(config);
5848
5877
  }
5849
5878
 
5879
+ async function fetchAccess(config) {
5880
+ return localApi(config, "/api/access/get", { method: "POST", body: "{}" });
5881
+ }
5882
+
5883
+ // `bivy access` — how this machine can be reached (this machine only,
5884
+ // Tailscale, Bivy hosted, your own server), what that gives you, and the next
5885
+ // step. The node owns the table (src/access.ts); this only renders and routes.
5886
+ async function cmdAccess(args = []) {
5887
+ const [verb, target] = args.filter((a) => !a.startsWith("-"));
5888
+ if (args.includes("-h") || args.includes("--help") || (verb && !["local", "tailscale", "hosted", "server"].includes(verb))) {
5889
+ console.log(`Usage: bivy access [--json]
5890
+ bivy access tailscale reach this machine from your devices over Tailscale
5891
+ bivy access hosted sign in to Bivy hosted: every machine, from anywhere
5892
+ bivy access server <url> [--relay <wss-url>] the same, through your own server
5893
+ bivy access local turn remote access off
5894
+
5895
+ Setups stack: Tailscale can stay on alongside hosted or your own server.`);
5896
+ if (verb && !["local", "tailscale", "hosted", "server"].includes(verb)) process.exitCode = EXIT.usage;
5897
+ return;
5898
+ }
5899
+ const config = loadConfig();
5900
+ if (!(await ensureNodeRunning(config))) {
5901
+ console.error(c.red(`Could not start the Bivy node at ${url(config)}.`));
5902
+ process.exitCode = EXIT.unavailable;
5903
+ return;
5904
+ }
5905
+ const report = await fetchAccess(config);
5906
+
5907
+ if (!verb) {
5908
+ if (wantsJson(args)) { console.log(JSON.stringify(report, null, 2)); return; }
5909
+ console.log(`\n${accessScreen(report, c).join("\n")}\n`);
5910
+ return;
5911
+ }
5912
+ if (verb === "tailscale") return cmdTailscale(["on"]);
5913
+ if (verb === "hosted" || verb === "server") {
5914
+ const linked = report.setups.find((s) => s.active && (s.id === "hosted" || s.id === "server"));
5915
+ if (linked && linked.id === verb && verb === "hosted") {
5916
+ console.log(`Already on ${linked.label}${report.controlPlaneUrl ? ` (${report.controlPlaneUrl})` : ""}.`);
5917
+ return;
5918
+ }
5919
+ if (linked) console.log(c.yellow(`This machine is on ${linked.label}${report.controlPlaneUrl ? ` (${report.controlPlaneUrl})` : ""}. Switching keeps everything on this machine; your devices sign in again at the new address.`));
5920
+ if (verb === "hosted") return cmdRelaySetup([]);
5921
+ if (!target) {
5922
+ console.log(`Usage: bivy access server <url> [--relay <wss-url>]\n\nThe address of your Bivy server, e.g. https://bivy.example.com. Set one up with docs/self-host-quickstart.md.`);
5923
+ process.exitCode = EXIT.usage;
5924
+ return;
5925
+ }
5926
+ let appUrl;
5927
+ try { appUrl = new URL(/^https?:\/\//.test(target) ? target : `https://${target}`); } catch { appUrl = null; }
5928
+ if (!appUrl) { console.error(c.red(`Not a URL: ${target}`)); process.exitCode = EXIT.usage; return; }
5929
+ // Bivy's server installs serve the relay at /relay on the app's own domain.
5930
+ const relayUrl = argValue(args, "relay") || `${appUrl.protocol === "http:" ? "ws" : "wss"}://${appUrl.host}/relay`;
5931
+ return cmdRelaySetup(["--control-plane", appUrl.origin, "--relay", relayUrl]);
5932
+ }
5933
+ // local: turn off every remote setup that is on.
5934
+ const on = report.setups.filter((s) => s.active && s.id !== "local");
5935
+ if (!on.length) { console.log("Already this machine only."); return; }
5936
+ const rl = createPrompter();
5937
+ const yes = await rl.askYesNo(`Turn off ${on.map((s) => s.label).join(" and ")}? Sessions and credentials on this machine stay.`, false);
5938
+ rl.close();
5939
+ if (!yes) return;
5940
+ if (on.some((s) => s.id === "tailscale")) await cmdTailscale(["off"]);
5941
+ if (on.some((s) => s.id === "hosted" || s.id === "server")) await cmdAccountLogout([]);
5942
+ }
5943
+
5850
5944
  function printUninstallHelp() {
5851
5945
  console.log(`
5852
5946
  ${c.bold("bivy uninstall")} — remove Bivy and all its data from this machine
@@ -6453,6 +6547,9 @@ Unlike 'bivy run', these commands operate on governed background Runs with check
6453
6547
  case "tailscale":
6454
6548
  await cmdTailscale(args);
6455
6549
  break;
6550
+ case "access":
6551
+ await cmdAccess(args);
6552
+ break;
6456
6553
  case "relay:setup":
6457
6554
  await cmdRelaySetup(args);
6458
6555
  break;
@@ -106,6 +106,7 @@ export const COMMANDS = [
106
106
  { name: "token", group: "node", scope: "node", usage: "token", summary: "Print a device token for this node" },
107
107
  { name: "open", group: "node", scope: "node", usage: "open", summary: "Open the remote web app" },
108
108
  { name: "link", group: "node", scope: "node", usage: "link", summary: "Show a web app link as a QR code in the terminal" },
109
+ { name: "access", group: "node", scope: "node", json: true, usage: "access [local|tailscale|hosted|server <url>]", summary: "How this machine can be reached, what that gives you, and the next step", subcommands: ["local", "tailscale", "hosted", "server"] },
109
110
  { 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"] },
110
111
  { name: "relay:setup", group: "node", scope: "node", usage: "relay:setup", summary: "Enable secure remote web app access" },
111
112
  { name: "uninstall", group: "node", scope: "node", usage: "uninstall [--keep-sessions] [--dry-run]", summary: "Remove Bivy and all its data" },
package/bin/tailscale.mjs CHANGED
@@ -7,12 +7,19 @@
7
7
  /** The one HTTPS port Bivy claims on the machine's tailnet name. */
8
8
  export const SERVE_HTTPS_PORT = 443;
9
9
 
10
- /** `{ running, hostname }` from `tailscale status --json` output. */
10
+ /**
11
+ * `{ running, hostname, owner }` from `tailscale status --json` output. `owner`
12
+ * is the login of the user this machine belongs to, or null for a tagged
13
+ * machine (owned by tags, not a person).
14
+ */
11
15
  export function parseTailscaleStatus(text) {
12
16
  let status;
13
- try { status = JSON.parse(text); } catch { return { running: false, hostname: null }; }
17
+ try { status = JSON.parse(text); } catch { return { running: false, hostname: null, owner: null }; }
14
18
  const hostname = String(status?.Self?.DNSName ?? "").replace(/\.$/, "") || null;
15
- return { running: status?.BackendState === "Running", hostname };
19
+ const tagged = Array.isArray(status?.Self?.Tags) && status.Self.Tags.length > 0;
20
+ const login = status?.User?.[String(status?.Self?.UserID)]?.LoginName;
21
+ const owner = !tagged && typeof login === "string" && login.includes("@") ? login : null;
22
+ return { running: status?.BackendState === "Running", hostname, owner };
16
23
  }
17
24
 
18
25
  /** Where `tailscale serve` should send the machine's HTTPS traffic. */
package/dist/access.js ADDED
@@ -0,0 +1,72 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // How this machine can be reached, as one table: the setups (this machine only,
5
+ // Tailscale, Bivy hosted, your own server), what each gives you, and which are
6
+ // active here. `bivy access`, `bivy status`, setup and the app all render this
7
+ // report, so they always agree. Setups stack: each only adds to the ones before.
8
+ export const ACCESS_FEATURES = [
9
+ { id: "devices", label: "Your phone and other devices" },
10
+ { id: "machines", label: "All your machines in one app" },
11
+ { id: "push", label: "Push notifications" },
12
+ { id: "sharing", label: "Shareable app previews" },
13
+ ];
14
+ export const ACCESS_SETUPS = [
15
+ { id: "local", label: "This machine only", summary: "The terminal and a browser on this machine.", gives: { devices: 0, machines: 0, push: 0, sharing: 0 } },
16
+ { id: "tailscale", label: "Tailscale", summary: "Your devices reach your machines over your tailnet. No account, nothing in between.", command: "bivy access tailscale", gives: { devices: 1, machines: 1, push: 0, sharing: 0 } },
17
+ { id: "hosted", label: "Bivy hosted", summary: "Sign in once. Sessions stay end-to-end encrypted.", command: "bivy access hosted", gives: { devices: 2, machines: 2, push: 2, sharing: 2 } },
18
+ { id: "server", label: "Your own server", summary: "Like hosted, on a server you control.", command: "bivy access server <url>", gives: { devices: 2, machines: 2, push: 2, sharing: 2 } },
19
+ ];
20
+ /** "a, b and c" (or "a and b"). */
21
+ function and(items) {
22
+ return items.length < 2 ? items[0] ?? "" : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
23
+ }
24
+ const origin = (url) => {
25
+ try {
26
+ return url ? new URL(url).origin : "";
27
+ }
28
+ catch {
29
+ return "";
30
+ }
31
+ };
32
+ /** Which setups are active, from what the node has on disk. */
33
+ export function activeSetups(inputs) {
34
+ const active = ["local"];
35
+ if (inputs.tailscaleHostname)
36
+ active.push("tailscale");
37
+ const relay = inputs.relay;
38
+ if (relay?.room && relay.roomToken)
39
+ active.push("server");
40
+ else if (relay?.controlPlaneUrl)
41
+ active.push(origin(relay.controlPlaneUrl) === origin(inputs.hostedControlPlane) ? "hosted" : "server");
42
+ return active;
43
+ }
44
+ export function accessReport(inputs) {
45
+ const active = activeSetups(inputs);
46
+ const reach = {};
47
+ for (const { id } of ACCESS_FEATURES) {
48
+ reach[id] = Math.max(...ACCESS_SETUPS.filter((s) => active.includes(s.id)).map((s) => s.gives[id]));
49
+ }
50
+ // Hosted and your own server are alternatives: once one is on, neither is a next step.
51
+ const linked = active.includes("hosted") || active.includes("server");
52
+ const next = ACCESS_SETUPS
53
+ .filter((s) => !active.includes(s.id) && !(linked && (s.id === "hosted" || s.id === "server")))
54
+ .map((s) => {
55
+ const adds = ACCESS_FEATURES.filter((f) => s.gives[f.id] > reach[f.id]);
56
+ // What you already have on your tailnet only widens: say "from anywhere" once for those.
57
+ const widened = adds.filter((f) => reach[f.id] === 1).map((f) => f.label.toLowerCase());
58
+ const fresh = adds.filter((f) => reach[f.id] === 0).map((f) => f.label.toLowerCase());
59
+ const phrases = [...(widened.length ? [`${and(widened)} from anywhere`] : []), ...fresh];
60
+ return { id: s.id, adds: adds.map((f) => f.id), addsText: and(phrases) };
61
+ })
62
+ .filter((s) => s.adds.length > 0);
63
+ return {
64
+ features: ACCESS_FEATURES,
65
+ setups: ACCESS_SETUPS.map((s) => ({ ...s, active: active.includes(s.id) })),
66
+ active,
67
+ reach,
68
+ next,
69
+ ...(inputs.tailscaleHostname ? { tailscaleUrl: `https://${inputs.tailscaleHostname}` } : {}),
70
+ ...(inputs.relay?.controlPlaneUrl ? { controlPlaneUrl: inputs.relay.controlPlaneUrl } : {}),
71
+ };
72
+ }
@@ -19,11 +19,39 @@ export async function scanListeners(workspace, platform = process.platform) {
19
19
  }
20
20
  const found = platform === "linux" ? await scanProc(root) : platform === "darwin" ? await scanLsof(root) : [];
21
21
  const byPort = new Map();
22
- for (const offer of found)
22
+ for (const { cwd: _cwd, ...offer } of found)
23
23
  if (offer.pid !== process.pid && offer.port >= 1024 && !byPort.has(offer.port))
24
24
  byPort.set(offer.port, offer);
25
25
  return [...byPort.values()].sort((a, b) => a.port - b.port);
26
26
  }
27
+ /** Servers running anywhere under `root` (the owner's home), each with the
28
+ * project it runs in: what a first session can open before it has a folder.
29
+ * Processes running in `root` itself or in Bivy's own data folders aren't apps. */
30
+ export async function scanMachineListeners(root, bivyDirs = [path.join(root, ".bivy")], platform = process.platform) {
31
+ let top;
32
+ try {
33
+ top = await fs.realpath(root);
34
+ }
35
+ catch {
36
+ return [];
37
+ }
38
+ const found = platform === "linux" ? await scanProc(top) : platform === "darwin" ? await scanLsof(top) : [];
39
+ const byPort = new Map();
40
+ for (const { cwd, ...offer } of found) {
41
+ if (offer.pid === process.pid || offer.port < 1024 || byPort.has(offer.port) || cwd === top || bivyDirs.includes(cwd))
42
+ continue;
43
+ byPort.set(offer.port, { ...offer, project: await projectOf(cwd, top) });
44
+ }
45
+ return [...byPort.values()].sort((a, b) => a.port - b.port);
46
+ }
47
+ /** The nearest folder at or above `dir` (below `top`) with a .git, else `dir`. */
48
+ async function projectOf(dir, top) {
49
+ for (let at = dir; inside(top, at) && at !== top; at = path.dirname(at)) {
50
+ if (await fs.stat(path.join(at, ".git")).then(() => true, () => false))
51
+ return at;
52
+ }
53
+ return dir;
54
+ }
27
55
  const inside = (root, dir) => dir === root || dir.startsWith(root + path.sep);
28
56
  /** `local_address` in /proc/net/tcp{,6}: hex address in little-endian 32-bit words. */
29
57
  export function listenPort(line) {
@@ -73,7 +101,7 @@ async function scanProc(root) {
73
101
  return;
74
102
  const argv = (await fs.readFile(`/proc/${pid}/cmdline`, "utf8").catch(() => "")).split("\0").filter(Boolean);
75
103
  for (const port of ports)
76
- offers.push({ port, pid: Number(pid), command: commandLabel(argv) });
104
+ offers.push({ port, pid: Number(pid), command: commandLabel(argv), cwd });
77
105
  }));
78
106
  return offers;
79
107
  }
@@ -103,7 +131,7 @@ async function scanLsof(root) {
103
131
  for (const name of values) {
104
132
  const match = /^(\*|127\.[\d.]+|\[::1\]|\[::\]|localhost):(\d+)$/.exec(name);
105
133
  if (match)
106
- offers.push({ port: Number(match[2]), pid, command: commandLabel(args.split(/\s+/)) });
134
+ offers.push({ port: Number(match[2]), pid, command: commandLabel(args.split(/\s+/)), cwd });
107
135
  }
108
136
  }
109
137
  return offers;
@@ -6,7 +6,7 @@ import { PIN_CHANGE, REVIEW_MODES, regionChange, shouldReview, visualChange } fr
6
6
  import fs from "node:fs";
7
7
  import os from "node:os";
8
8
  import path from "node:path";
9
- import { scanListeners } from "./listeners.js";
9
+ import { scanListeners, scanMachineListeners } from "./listeners.js";
10
10
  import { takeShots } from "./screenshot.js";
11
11
  import { approximate, composite, crop, noteAnchor, readNotes, readStrokes, readElementScrolls } from "./annotate.js";
12
12
  import { captureFrame, encodePng, sendInput } from "./rfb.js";
@@ -44,6 +44,7 @@ export class AppService {
44
44
  terminalStarts = new Map();
45
45
  servers = new Map();
46
46
  scan;
47
+ scanMachine;
47
48
  serverWatchMs;
48
49
  screenshots;
49
50
  displays;
@@ -68,6 +69,7 @@ export class AppService {
68
69
  this.gateway = gateway;
69
70
  this.terminals = terminals;
70
71
  this.scan = options.scan ?? scanListeners;
72
+ this.scanMachine = options.scanMachine ?? scanMachineListeners;
71
73
  this.serverWatchMs = options.serverWatchMs ?? 5_000;
72
74
  this.screenshots = { enabled: options.screenshots?.enabled ?? (() => false), take: options.screenshots?.take ?? takeShots };
73
75
  this.displays = options.displays ?? NO_DISPLAYS;
@@ -330,6 +332,11 @@ export class AppService {
330
332
  const claimed = this.registry.claimedPorts(sessionId);
331
333
  return { offers: (await this.scan(workspace)).filter((offer) => !claimed.has(offer.port)) };
332
334
  }
335
+ /** Servers running anywhere under `root`, with their projects: what a first
336
+ * session can start in and preview before it has a workspace of its own. */
337
+ async machineOffers(root) {
338
+ return { offers: await this.scanMachine(root) };
339
+ }
333
340
  /** Publish a detected server. Re-scanned so a client can't adopt an arbitrary port. */
334
341
  async adopt(sessionId, workspace, port) {
335
342
  const offer = (await this.offers(sessionId, workspace)).offers.find((item) => item.port === port);
@@ -116,6 +116,7 @@ input, textarea, select { font-family: inherit; font-size: 16px; }
116
116
  padding: 9px var(--space-3); border: 0; border-radius: var(--radius-sm);
117
117
  background: transparent; color: var(--ink); font: inherit;
118
118
  font-size: var(--text-sm); text-align: left; cursor: pointer;
119
+ text-decoration: none; /* a menu item can be a link to another machine */
119
120
  }
120
121
  .menu-item:hover, .menu-item[aria-checked="true"], .menu-item.active { background: var(--surface-2); }
121
122
  .menu-item.danger { color: var(--danger); }
@@ -3288,6 +3289,9 @@ p.setup-note .btn.link { min-height: 44px; margin-block: calc((1lh - 44px) / 2);
3288
3289
  .readiness .card-title { margin: 0 0 var(--space-1); }
3289
3290
  .readiness .card-sub { margin: 0; }
3290
3291
  .get-started-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-3); margin-top: var(--space-3); }
3292
+ .get-started-scan { display: flex; align-items: center; gap: var(--space-2); }
3293
+ .get-started-app { margin: var(--space-1) 0 0; overflow-wrap: anywhere; }
3294
+ .get-started-more { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-1) var(--space-2); margin-top: var(--space-2); }
3291
3295
  .readiness-checks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
3292
3296
  /* Summarize completed checks so the next setup action stays visible. */
3293
3297
  .readiness-check.state-passed { display: none; }
package/dist/auth.js CHANGED
@@ -115,10 +115,24 @@ export function isLoopbackAddress(address) {
115
115
  // Sockets accepted by a listener that fronts remote callers through a local
116
116
  // proxy (`tailscale serve` forwards tailnet traffic from 127.0.0.1). Their peer
117
117
  // address is loopback, but the caller is not, so they never get loopback trust.
118
- const remoteSockets = new WeakSet();
118
+ const remoteSockets = new WeakMap();
119
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));
120
+ export function markRemoteListener(server, options = {}) {
121
+ server.on("connection", (socket) => remoteSockets.set(socket, options));
122
+ }
123
+ /**
124
+ * `tailnet:<login>` when a request reached a remote listener from a device
125
+ * signed in to the tailnet as the machine's owner. The listener binds loopback,
126
+ * so another local account could forge the header; honor it only where local
127
+ * callers are trusted anyway (the same rule as the loopback bypass).
128
+ */
129
+ function tailnetDevice(req) {
130
+ const options = req.socket ? remoteSockets.get(req.socket) : undefined;
131
+ const owner = options?.trustedLogin?.()?.toLowerCase();
132
+ const login = req.headers["tailscale-user-login"];
133
+ if (!owner || typeof login !== "string" || login.toLowerCase() !== owner)
134
+ return null;
135
+ return loopbackAllowed() ? `tailnet:${owner}` : null;
122
136
  }
123
137
  /** True when a request physically arrived over loopback from a local caller. */
124
138
  export function isLoopbackRequest(req) {
@@ -154,7 +168,7 @@ export function tokenFromRequest(req) {
154
168
  * `isAuthorized` is off. */
155
169
  export function resolveAuth(identity, req) {
156
170
  const loopback = isLoopbackRequest(req);
157
- const deviceId = identity.verifyToken(tokenFromRequest(req));
171
+ const deviceId = identity.verifyToken(tokenFromRequest(req)) ?? tailnetDevice(req);
158
172
  return { deviceId, loopback };
159
173
  }
160
174
  export function isAuthorized(ctx) {
@@ -1,4 +1,8 @@
1
- export function createAppCommands(service, workspaceFor, published = () => { }) {
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import os from "node:os";
4
+ /** `machineRoot`: where servers are looked for before a session exists (the owner's home). */
5
+ export function createAppCommands(service, workspaceFor, published = () => { }, machineRoot = os.homedir) {
2
6
  const workspaceOf = (sessionId) => {
3
7
  const workspace = workspaceFor(sessionId);
4
8
  if (!workspace)
@@ -13,7 +17,8 @@ export function createAppCommands(service, workspaceFor, published = () => { })
13
17
  published(app);
14
18
  return { app };
15
19
  },
16
- "apps.offers": (msg) => service.offers(String(msg.sessionId), workspaceOf(String(msg.sessionId))),
20
+ // Without a session: servers anywhere in the owner's home, for a first session to open.
21
+ "apps.offers": (msg) => typeof msg.sessionId === "string" ? service.offers(msg.sessionId, workspaceOf(msg.sessionId)) : service.machineOffers(machineRoot()),
17
22
  "apps.adopt": async (msg) => {
18
23
  const sessionId = String(msg.sessionId);
19
24
  const app = await service.adopt(sessionId, workspaceOf(sessionId), Number(msg.port));
@@ -5,6 +5,8 @@
5
5
  export const CLIENT_COMMAND_ROUTES = [
6
6
  ...["list", "publish", "offers", "adopt", "open", "logs", "shot", "input", "menu", "present", "showMe", "mute", "reviewMode", "annotate", "clearNotes", "agentNotes", "notes", "share", "unshare", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
7
7
  { method: "post", path: "/api/artifacts/list", kind: "artifacts.list" },
8
+ { method: "post", path: "/api/access/get", kind: "access.get" },
9
+ { method: "post", path: "/api/tailnet/machines", kind: "tailnet.machines" },
8
10
  { method: "post", path: "/api/auth/credentials/native-preview", kind: "credentials.native.preview" },
9
11
  { method: "post", path: "/api/auth/credentials/native-import", kind: "credentials.native.import" },
10
12
  { method: "post", path: "/api/session/pause", kind: "session.pause" },
@@ -11,9 +11,11 @@ export const CLIENT_COMMAND_SCHEMAS = {
11
11
  ping: Type.Object(request),
12
12
  "apps.list": Type.Object({ ...request, ...optionalSession }),
13
13
  "artifacts.list": Type.Object(request),
14
+ "access.get": Type.Object(request),
15
+ "tailnet.machines": Type.Object(request),
14
16
  "session.fork.local": Type.Object({ ...request, ...session, model: Type.Optional(Type.Object({ provider: Type.Optional(Type.String({ maxLength: 200 })), id: Type.String({ maxLength: 200 }) })) }),
15
17
  "apps.publish": Type.Object({ ...request, ...session, manifest: Type.Unknown() }),
16
- "apps.offers": Type.Object({ ...request, ...session }),
18
+ "apps.offers": Type.Object({ ...request, ...optionalSession }),
17
19
  "apps.adopt": Type.Object({ ...request, ...session, port: Type.Integer({ minimum: 1024, maximum: 65535 }) }),
18
20
  "apps.open": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String(), returnTo: Type.Optional(Type.String({ maxLength: 2048 })), direct: Type.Optional(Type.Boolean()), scale: Type.Optional(Type.Integer({ minimum: 1, maximum: 2 })), path: Type.Optional(Type.String({ maxLength: 2048 })) }),
19
21
  "apps.shot": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String()), widths: Type.Optional(Type.Array(Type.Integer(), { maxItems: 4 })), themes: Type.Optional(Type.Array(Type.Union([Type.Literal("light"), Type.Literal("dark")]), { maxItems: 2 })), path: Type.Optional(Type.String({ maxLength: 2048 })) }),
@@ -15,7 +15,8 @@ export function loadDirectListenerConfig(appDir) {
15
15
  const port = Number(raw.port);
16
16
  if (!Number.isInteger(port) || port <= 0 || port > 65535)
17
17
  return null;
18
- return { port, hostname: typeof raw.hostname === "string" && raw.hostname ? raw.hostname : undefined };
18
+ const text = (value) => (typeof value === "string" && value ? value : undefined);
19
+ return { port, hostname: text(raw.hostname), owner: text(raw.owner) };
19
20
  }
20
21
  catch {
21
22
  return null;
@@ -13,3 +13,4 @@
13
13
  export { RelayConnector, loadRelayConfig } from "./relay-client.js";
14
14
  export { soloCredentials, buildDialUrl } from "./solo.js";
15
15
  export { loadDirectListenerConfig, DIRECT_CONFIG_FILE } from "./direct.js";
16
+ export { discoverTailnetMachines, parseTailnetPeers } from "./tailnet.js";