@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.
- package/README.md +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +482 -14
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +97 -0
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +102 -1
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +335 -15
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +47 -6
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +26 -21
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +39 -44
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +370 -12
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +8 -4
- package/src/env-file.ts +10 -0
- package/src/help.ts +3 -1
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +52 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +77 -7
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +71 -19
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
expect(
|
|
167
|
-
expect(
|
|
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(
|
|
170
|
-
expect(
|
|
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();
|