@openparachute/hub 0.5.14-rc.8 → 0.6.0

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 (87) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-ops.test.ts +45 -0
  8. package/src/__tests__/api-revoke-token.test.ts +384 -0
  9. package/src/__tests__/api-users.test.ts +7 -2
  10. package/src/__tests__/auth.test.ts +157 -30
  11. package/src/__tests__/cli.test.ts +44 -5
  12. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  13. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  14. package/src/__tests__/expose-cloudflare.test.ts +482 -14
  15. package/src/__tests__/expose.test.ts +52 -2
  16. package/src/__tests__/hub-server.test.ts +97 -0
  17. package/src/__tests__/hub.test.ts +85 -6
  18. package/src/__tests__/init.test.ts +102 -1
  19. package/src/__tests__/lifecycle.test.ts +464 -2
  20. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  21. package/src/__tests__/oauth-ui.test.ts +12 -1
  22. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  23. package/src/__tests__/resource-binding.test.ts +97 -0
  24. package/src/__tests__/scope-explanations.test.ts +77 -12
  25. package/src/__tests__/services-manifest.test.ts +122 -4
  26. package/src/__tests__/setup-wizard.test.ts +335 -15
  27. package/src/__tests__/status.test.ts +36 -0
  28. package/src/__tests__/two-factor-flow.test.ts +602 -0
  29. package/src/__tests__/two-factor.test.ts +183 -0
  30. package/src/__tests__/upgrade.test.ts +78 -1
  31. package/src/__tests__/users.test.ts +68 -0
  32. package/src/__tests__/vault-auth-status.test.ts +47 -6
  33. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  34. package/src/account-home-ui.ts +488 -38
  35. package/src/account-vault-token.ts +282 -0
  36. package/src/admin-handlers.ts +159 -4
  37. package/src/admin-login-ui.ts +49 -5
  38. package/src/admin-vaults.ts +48 -15
  39. package/src/api-account.ts +14 -0
  40. package/src/api-mint-token.ts +132 -24
  41. package/src/api-modules-ops.ts +49 -11
  42. package/src/api-revoke-token.ts +107 -21
  43. package/src/api-users.ts +29 -3
  44. package/src/cli.ts +26 -21
  45. package/src/clients.ts +18 -6
  46. package/src/cloudflare/config.ts +10 -4
  47. package/src/cloudflare/detect.ts +39 -44
  48. package/src/commands/auth.ts +165 -24
  49. package/src/commands/expose-2fa-warning.ts +34 -32
  50. package/src/commands/expose-auth-preflight.ts +89 -78
  51. package/src/commands/expose-cloudflare.ts +370 -12
  52. package/src/commands/expose.ts +8 -0
  53. package/src/commands/init.ts +33 -2
  54. package/src/commands/lifecycle.ts +386 -17
  55. package/src/commands/status.ts +22 -0
  56. package/src/commands/upgrade.ts +55 -11
  57. package/src/commands/wizard.ts +8 -4
  58. package/src/env-file.ts +10 -0
  59. package/src/help.ts +3 -1
  60. package/src/hub-db.ts +39 -1
  61. package/src/hub-server.ts +52 -0
  62. package/src/hub.ts +82 -14
  63. package/src/oauth-handlers.ts +298 -21
  64. package/src/oauth-ui.ts +10 -0
  65. package/src/operator-token.ts +151 -0
  66. package/src/pending-login.ts +116 -0
  67. package/src/rate-limit.ts +51 -0
  68. package/src/resource-binding.ts +134 -0
  69. package/src/scope-attenuation.ts +85 -0
  70. package/src/scope-explanations.ts +131 -14
  71. package/src/services-manifest.ts +112 -0
  72. package/src/setup-wizard.ts +77 -7
  73. package/src/tailscale/run.ts +28 -11
  74. package/src/totp.ts +201 -0
  75. package/src/two-factor-handlers.ts +287 -0
  76. package/src/two-factor-store.ts +181 -0
  77. package/src/two-factor-ui.ts +462 -0
  78. package/src/users.ts +58 -0
  79. package/src/vault/auth-status.ts +71 -19
  80. package/src/vault-hub-origin-env.ts +163 -0
  81. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  82. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  83. package/web/ui/dist/index.html +2 -2
  84. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  85. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  86. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  87. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -1,8 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { existsSync, mkdtempSync, rmSync } from "node:fs";
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { exposePublic, exposeTailnet } from "../commands/expose.ts";
6
+ import { readEnvFileValues } from "../env-file.ts";
6
7
  import { readExposeState, writeExposeState } from "../expose-state.ts";
7
8
  import type { EnsureHubOpts, HubSpawner, StopHubOpts } from "../hub-control.ts";
8
9
  import { writePid } from "../process-state.ts";
@@ -696,6 +697,53 @@ describe("expose tailnet off", () => {
696
697
  }
697
698
  });
698
699
 
700
+ test("clears the persisted PARACHUTE_HUB_ORIGIN from vault/.env on teardown", async () => {
701
+ // OAuth issuer-mismatch fix: `expose up` persisted the public origin into
702
+ // vault/.env so the daemon validates `iss` against it. With exposure gone,
703
+ // a local-only hub mints loopback-`iss` tokens, so a stale public origin
704
+ // left in `.env` would itself cause the mismatch on the next daemon boot.
705
+ // Tearing it down reverts vault to its loopback default.
706
+ const h = makeHarness();
707
+ try {
708
+ writeExposeState(
709
+ {
710
+ version: 1,
711
+ layer: "tailnet",
712
+ mode: "path",
713
+ canonicalFqdn: "parachute.taildf9ce2.ts.net",
714
+ port: 443,
715
+ funnel: false,
716
+ entries: [{ kind: "proxy", mount: "/", target: "http://127.0.0.1:1939", service: "hub" }],
717
+ hubOrigin: "https://parachute.taildf9ce2.ts.net",
718
+ },
719
+ h.statePath,
720
+ );
721
+ mkdirSync(join(h.configDir, "vault"), { recursive: true });
722
+ writeFileSync(
723
+ join(h.configDir, "vault", ".env"),
724
+ "SCRIBE_AUTH_TOKEN=secret\nPARACHUTE_HUB_ORIGIN=https://parachute.taildf9ce2.ts.net\n",
725
+ );
726
+ const { runner } = makeRunner();
727
+ const code = await exposeTailnet("off", {
728
+ runner,
729
+ statePath: h.statePath,
730
+ wellKnownPath: h.wellKnownPath,
731
+ hubPath: h.hubPath,
732
+ wellKnownDir: h.wellKnownDir,
733
+ configDir: h.configDir,
734
+ skipHub: true,
735
+ log: () => {},
736
+ });
737
+ expect(code).toBe(0);
738
+ const values = readEnvFileValues(join(h.configDir, "vault", ".env"));
739
+ expect(values.PARACHUTE_HUB_ORIGIN).toBeUndefined();
740
+ // Sibling keys preserved.
741
+ expect(values.SCRIBE_AUTH_TOKEN).toBe("secret");
742
+ } finally {
743
+ h.cleanup();
744
+ }
745
+ });
746
+
699
747
  test("leaves state in place on teardown failure", async () => {
700
748
  const h = makeHarness();
701
749
  try {
@@ -961,7 +1009,9 @@ describe("expose public up", () => {
961
1009
  });
962
1010
  expect(code).toBe(0);
963
1011
  const joined = logs.join("\n");
964
- expect(joined).toContain("2FA is not enrolled");
1012
+ // hub#473: real hub-login 2FA. The warning now recommends the real
1013
+ // `parachute auth 2fa enroll` path.
1014
+ expect(joined).toContain("/login is now reachable on the public internet");
965
1015
  expect(joined).toContain("parachute auth 2fa enroll");
966
1016
  // /login pointer uses the canonical https://<fqdn> origin.
967
1017
  expect(joined).toContain("https://parachute.taildf9ce2.ts.net/login");
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
6
7
  import { HUB_SVC, hubPortPath } from "../hub-control.ts";
7
8
  import { hubDbPath, openHubDb } from "../hub-db.ts";
8
9
  import {
@@ -16,6 +17,7 @@ import { setNotesRedirectDisabled } from "../hub-settings.ts";
16
17
  import { clearNotesRedirectLogState } from "../notes-redirect.ts";
17
18
  import { pidPath } from "../process-state.ts";
18
19
  import { type ServiceEntry, writeManifest } from "../services-manifest.ts";
20
+ import { buildSessionCookie, createSession } from "../sessions.ts";
19
21
  import { rotateSigningKey } from "../signing-keys.ts";
20
22
  import type { ModuleState, Supervisor } from "../supervisor.ts";
21
23
  import { createUser } from "../users.ts";
@@ -4171,3 +4173,98 @@ describe("hubFetch persistent chrome strip injection (workstream G)", () => {
4171
4173
  }
4172
4174
  });
4173
4175
  });
4176
+
4177
+ describe("POST /account/vault-token/<name> — friend scoped mint (routed end-to-end)", () => {
4178
+ // Drive the real dispatch (`hubFetch`) so the route wiring + precedence
4179
+ // (the `/account/vault-token/` prefix must win over `/account/` and the
4180
+ // SPA catch-all) is exercised, not just the handler in isolation.
4181
+ async function seed(h: Harness, assignedVaults: string[]) {
4182
+ const db = openHubDb(hubDbPath(h.dir));
4183
+ rotateSigningKey(db); // mint needs an active signing key
4184
+ await createUser(db, "operator", "operator-password-123");
4185
+ const friend = await createUser(db, "friend", "friend-password-123", {
4186
+ assignedVaults,
4187
+ allowMulti: true,
4188
+ });
4189
+ const session = createSession(db, { userId: friend.id });
4190
+ const csrf = generateCsrfToken();
4191
+ const cookie = `${buildSessionCookie(session.id, 3600, { secure: false })}; ${
4192
+ buildCsrfCookie(csrf, { secure: false }).split(";")[0]
4193
+ }`;
4194
+ return { db, friendId: friend.id, cookie, csrf };
4195
+ }
4196
+
4197
+ function postBody(csrf: string, verb: string): string {
4198
+ const b = new URLSearchParams();
4199
+ b.set("__csrf", csrf);
4200
+ b.set("verb", verb);
4201
+ return b.toString();
4202
+ }
4203
+
4204
+ test("assigned vault → 200 with a token banner, routed through hubFetch", async () => {
4205
+ const h = makeHarness();
4206
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4207
+ const { db, cookie, csrf } = await seed(h, ["work"]);
4208
+ try {
4209
+ const res = await hubFetch(h.dir, {
4210
+ getDb: () => db,
4211
+ manifestPath: h.manifestPath,
4212
+ issuer: "https://hub.test",
4213
+ })(
4214
+ req("/account/vault-token/work", {
4215
+ method: "POST",
4216
+ headers: { cookie, "content-type": "application/x-www-form-urlencoded" },
4217
+ body: postBody(csrf, "read"),
4218
+ }),
4219
+ );
4220
+ expect(res.status).toBe(200);
4221
+ const html = await res.text();
4222
+ expect(html).toContain('data-testid="minted-token-banner"');
4223
+ expect(html).toContain("vault:work:read");
4224
+ } finally {
4225
+ db.close();
4226
+ h.cleanup();
4227
+ }
4228
+ });
4229
+
4230
+ test("unassigned vault → 403, no token, routed through hubFetch", async () => {
4231
+ const h = makeHarness();
4232
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4233
+ const { db, cookie, csrf } = await seed(h, ["work"]);
4234
+ try {
4235
+ const res = await hubFetch(h.dir, {
4236
+ getDb: () => db,
4237
+ manifestPath: h.manifestPath,
4238
+ issuer: "https://hub.test",
4239
+ })(
4240
+ req("/account/vault-token/other", {
4241
+ method: "POST",
4242
+ headers: { cookie, "content-type": "application/x-www-form-urlencoded" },
4243
+ body: postBody(csrf, "read"),
4244
+ }),
4245
+ );
4246
+ expect(res.status).toBe(403);
4247
+ const html = await res.text();
4248
+ expect(html).not.toContain('data-testid="minted-token-banner"');
4249
+ } finally {
4250
+ db.close();
4251
+ h.cleanup();
4252
+ }
4253
+ });
4254
+
4255
+ test("GET on the mint path → 405 (POST-only)", async () => {
4256
+ const h = makeHarness();
4257
+ writeManifest({ services: [vaultEntry("work")] }, h.manifestPath);
4258
+ const { db } = await seed(h, ["work"]);
4259
+ try {
4260
+ const res = await hubFetch(h.dir, {
4261
+ getDb: () => db,
4262
+ manifestPath: h.manifestPath,
4263
+ })(req("/account/vault-token/work"));
4264
+ expect(res.status).toBe(405);
4265
+ } finally {
4266
+ db.close();
4267
+ h.cleanup();
4268
+ }
4269
+ });
4270
+ });
@@ -5,7 +5,15 @@ import { join } from "node:path";
5
5
  import { renderHub, writeHubFile } from "../hub.ts";
6
6
 
7
7
  describe("renderHub", () => {
8
- const html = renderHub();
8
+ // The verbose discovery body (Get started / Services / Admin) + its
9
+ // data-loading script render only for a signed-in visitor (the signed-out
10
+ // landing is slimmed — see the "signed-out slimming" describe block below).
11
+ // Assertions about that verbose body therefore run against a signed-in
12
+ // render; assertions about the page shell (doctype, styles, brand) hold for
13
+ // both and use whichever render is convenient.
14
+ const html = renderHub({
15
+ session: { displayName: "operator", csrfToken: "csrf-shell" },
16
+ });
9
17
 
10
18
  test("is a self-contained HTML document with inline styles and script", () => {
11
19
  expect(html).toStartWith("<!doctype html>");
@@ -162,12 +170,72 @@ describe("renderHub", () => {
162
170
  });
163
171
 
164
172
  test("default render (no session) emits the 'Sign in' affordance", () => {
165
- expect(html).toContain('class="auth-indicator"');
166
- expect(html).toContain("Sign in");
167
- expect(html).toContain('href="/login?next=/"');
173
+ const out = renderHub();
174
+ expect(out).toContain('class="auth-indicator"');
175
+ expect(out).toContain("Sign in");
176
+ expect(out).toContain('href="/login?next=/"');
168
177
  // No POST form, no CSRF input — those only appear when signed in.
169
- expect(html).not.toContain('action="/logout"');
170
- expect(html).not.toContain("__csrf");
178
+ expect(out).not.toContain('action="/logout"');
179
+ expect(out).not.toContain("__csrf");
180
+ });
181
+ });
182
+
183
+ describe("renderHub — signed-out slimming (operator feedback)", () => {
184
+ // A signed-out visitor should see a clean, minimal landing: brand +
185
+ // tagline (in the header) + a single clear "Sign in" call. The hub's
186
+ // internal detail — the service catalog, vault listings, admin surfaces,
187
+ // and the well-known-driven loading script — must NOT render until the
188
+ // visitor authenticates. The signed-in render is unchanged.
189
+ const signedOut = renderHub();
190
+ const signedIn = renderHub({
191
+ session: { displayName: "operator", csrfToken: "csrf-xyz" },
192
+ });
193
+
194
+ test("signed-out: brand wordmark + tagline still render (the slim landing keeps the brand)", () => {
195
+ expect(signedOut).toContain("<h1>Parachute</h1>");
196
+ expect(signedOut).toContain("Truly personal computing. Your knowledge belongs with you.");
197
+ });
198
+
199
+ test("signed-out: a clear 'Sign in' call is the primary affordance", () => {
200
+ expect(signedOut).toContain('data-testid="signed-out-signin"');
201
+ expect(signedOut).toContain('href="/login?next=/"');
202
+ expect(signedOut).toContain("Sign in");
203
+ });
204
+
205
+ test("signed-out: the verbose Services / Admin / Get started sections are absent", () => {
206
+ expect(signedOut).not.toContain('id="services-section"');
207
+ expect(signedOut).not.toContain('id="admin-section"');
208
+ expect(signedOut).not.toContain('id="get-started-section"');
209
+ expect(signedOut).not.toContain("<h2>Services</h2>");
210
+ expect(signedOut).not.toContain("<h2>Admin</h2>");
211
+ // Admin links / token surface must not be exposed pre-auth.
212
+ expect(signedOut).not.toContain("/admin/vaults");
213
+ expect(signedOut).not.toContain("/admin/tokens");
214
+ });
215
+
216
+ test("signed-out: the well-known service-catalog loading script is not emitted", () => {
217
+ // No data-driven discovery body to populate when signed out → no script.
218
+ // (The brand mark is an inline SVG, not a <script>; assert on the IIFE's
219
+ // load function rather than a blanket "no <script>".) The footer's
220
+ // public "discovery" anchor → /.well-known/parachute.json stays — it's a
221
+ // plain link, not the catalog-fetching script — so assert on the fetch
222
+ // call + the loader function, not the URL string.
223
+ expect(signedOut).not.toContain("loadServices");
224
+ expect(signedOut).not.toContain("renderServices");
225
+ expect(signedOut).not.toContain("fetch('/.well-known/parachute.json'");
226
+ expect(signedOut).not.toContain("<script>");
227
+ });
228
+
229
+ test("signed-in: the verbose sections + loading script DO render (signed-in view unchanged)", () => {
230
+ expect(signedIn).toContain('id="services-section"');
231
+ expect(signedIn).toContain('id="admin-section"');
232
+ expect(signedIn).toContain('id="get-started-section"');
233
+ expect(signedIn).toContain("/admin/vaults");
234
+ expect(signedIn).toContain("/.well-known/parachute.json");
235
+ expect(signedIn).toContain("loadServices");
236
+ // And the signed-out lede / standalone Sign-in CTA is gone (the
237
+ // auth-indicator carries sign-out instead).
238
+ expect(signedIn).not.toContain('data-testid="signed-out-signin"');
171
239
  });
172
240
  });
173
241
 
@@ -189,6 +257,17 @@ describe("renderHub — signed-in indicator (rc.13)", () => {
189
257
  expect(html).not.toContain('href="/login?next=/"');
190
258
  });
191
259
 
260
+ test("signed-in indicator carries an Account breadcrumb to /account/", () => {
261
+ // Onboarding discoverability: a signed-in friend needs a single link
262
+ // to the self-service /account/ home (change password, their vault).
263
+ // Applies to admins too — harmless for them.
264
+ const html = renderHub({
265
+ session: { displayName: "aaron", csrfToken: "csrf-token-xyz" },
266
+ });
267
+ expect(html).toContain('href="/account/"');
268
+ expect(html).toContain("Account");
269
+ });
270
+
192
271
  test("displayName with HTML special chars is escaped", () => {
193
272
  // Username field allows alphanumerics historically, but the
194
273
  // displayName field on the wire is forward-compatible with profile
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { init, looksLikeServer, resolveAdminUrl } from "../commands/init.ts";
5
+ import { hasNoDisplay, init, looksLikeServer, resolveAdminUrl } from "../commands/init.ts";
6
6
  import type { ExposeState } from "../expose-state.ts";
7
7
  import { writeHubPort } from "../hub-control.ts";
8
8
  import { writePid } from "../process-state.ts";
@@ -345,6 +345,76 @@ describe("init", () => {
345
345
  }
346
346
  });
347
347
 
348
+ test("linux SSH (no display): prints the link, does NOT spawn a browser (Fix 2)", async () => {
349
+ // A TTY isn't enough — an SSH session is a TTY with no display, so
350
+ // `xdg-open` fails/blocks. Aaron hit this on EC2: init tried to open a
351
+ // browser and failed with "Couldn't launch a browser." We now skip the
352
+ // spawn on a server-shaped box and just print the URL.
353
+ const h = makeHarness();
354
+ try {
355
+ writeHubPort(1939, h.configDir);
356
+ const opened: string[] = [];
357
+ const logs: string[] = [];
358
+ const code = await init({
359
+ configDir: h.configDir,
360
+ manifestPath: h.manifestPath,
361
+ log: (l) => logs.push(l),
362
+ alive: () => false,
363
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
364
+ readExposeStateFn: () => undefined,
365
+ isTty: true,
366
+ platform: "linux",
367
+ env: { SSH_CONNECTION: "1.2.3.4 22 5.6.7.8 22" },
368
+ // Pre-pick the browser wizard so a real desktop would spawn — proves
369
+ // the display guard (not the prompt) is what suppresses the spawn.
370
+ wizardChoice: "browser",
371
+ prompt: async () => "y",
372
+ openBrowser: (url) => {
373
+ opened.push(url);
374
+ return true;
375
+ },
376
+ noExposePrompt: true,
377
+ installVaultModuleImpl: noopVaultInstall,
378
+ });
379
+ expect(code).toBe(0);
380
+ expect(opened).toEqual([]); // never spawned
381
+ expect(logs.join("\n")).toContain("No display detected");
382
+ } finally {
383
+ h.cleanup();
384
+ }
385
+ });
386
+
387
+ test("linux WITH a display still spawns the browser (desktop unchanged)", async () => {
388
+ const h = makeHarness();
389
+ try {
390
+ writeHubPort(1939, h.configDir);
391
+ const opened: string[] = [];
392
+ const code = await init({
393
+ configDir: h.configDir,
394
+ manifestPath: h.manifestPath,
395
+ log: () => {},
396
+ alive: () => false,
397
+ ensureHub: async () => ({ pid: 7, port: 1939, started: true }),
398
+ readExposeStateFn: () => undefined,
399
+ isTty: true,
400
+ platform: "linux",
401
+ env: { DISPLAY: ":0" },
402
+ wizardChoice: "browser",
403
+ prompt: async () => "y",
404
+ openBrowser: (url) => {
405
+ opened.push(url);
406
+ return true;
407
+ },
408
+ noExposePrompt: true,
409
+ installVaultModuleImpl: noopVaultInstall,
410
+ });
411
+ expect(code).toBe(0);
412
+ expect(opened).toEqual(["http://127.0.0.1:1939/admin/"]);
413
+ } finally {
414
+ h.cleanup();
415
+ }
416
+ });
417
+
348
418
  test("ensureHub failure exits 1 with an actionable hint", async () => {
349
419
  const h = makeHarness();
350
420
  try {
@@ -398,6 +468,37 @@ describe("looksLikeServer heuristic", () => {
398
468
  });
399
469
  });
400
470
 
471
+ describe("hasNoDisplay heuristic (Fix 2 — headless browser-open guard)", () => {
472
+ test("macOS / Windows always have a display", () => {
473
+ expect(hasNoDisplay("darwin", {})).toBe(false);
474
+ expect(hasNoDisplay("darwin", { SSH_CONNECTION: "1.2.3.4 22 5.6.7.8 22" })).toBe(false);
475
+ expect(hasNoDisplay("win32", {})).toBe(false);
476
+ });
477
+
478
+ test("linux SSH session (a TTY, but no display) → no display", () => {
479
+ expect(hasNoDisplay("linux", { SSH_CONNECTION: "1.2.3.4 22 5.6.7.8 22" })).toBe(true);
480
+ expect(hasNoDisplay("linux", { SSH_TTY: "/dev/pts/0" })).toBe(true);
481
+ });
482
+
483
+ test("linux headless console (no SSH, no DISPLAY) → no display", () => {
484
+ expect(hasNoDisplay("linux", {})).toBe(true);
485
+ });
486
+
487
+ test("linux desktop with DISPLAY / WAYLAND_DISPLAY → has a display", () => {
488
+ expect(hasNoDisplay("linux", { DISPLAY: ":0" })).toBe(false);
489
+ expect(hasNoDisplay("linux", { WAYLAND_DISPLAY: "wayland-0" })).toBe(false);
490
+ });
491
+
492
+ test("WSL (linux + DISPLAY-less but a dev laptop) is treated as having a display via looksLikeServer exclusion", () => {
493
+ // WSL with no DISPLAY would otherwise look headless; looksLikeServer
494
+ // excludes it, but the bare no-DISPLAY fallback still trips. This documents
495
+ // that a WSL user without an X server set won't auto-spawn — acceptable,
496
+ // since xdg-open would fail there anyway. WSL WITH an X server (DISPLAY set)
497
+ // correctly resolves to has-a-display.
498
+ expect(hasNoDisplay("linux", { WSL_DISTRO_NAME: "Ubuntu", DISPLAY: ":0" })).toBe(false);
499
+ });
500
+ });
501
+
401
502
  describe("init exposure chain", () => {
402
503
  test("TTY + no exposure + no flags → prompt is shown", async () => {
403
504
  const h = makeHarness();