@openparachute/hub 0.7.4-rc.3 → 0.7.4-rc.5
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/package.json +1 -1
- package/src/__tests__/api-hub-upgrade.test.ts +59 -3
- package/src/__tests__/clients.test.ts +28 -8
- package/src/__tests__/cloudflare-connector-service.test.ts +3 -1
- package/src/__tests__/hub-server.test.ts +127 -5
- package/src/__tests__/managed-unit.test.ts +62 -0
- package/src/__tests__/setup-wizard.test.ts +117 -0
- package/src/__tests__/supervisor.test.ts +25 -0
- package/src/api-hub-upgrade.ts +38 -3
- package/src/clients.ts +14 -0
- package/src/hub-server.ts +50 -6
- package/src/managed-unit.ts +30 -1
- package/src/setup-wizard.ts +27 -8
- package/src/supervisor.ts +46 -2
package/package.json
CHANGED
|
@@ -323,8 +323,15 @@ describe("POST /api/hub/upgrade — redeploy-required short-circuit (§5.3)", ()
|
|
|
323
323
|
});
|
|
324
324
|
|
|
325
325
|
describe("POST /api/hub/upgrade — 409 in-flight guard (concurrent-upgrade)", () => {
|
|
326
|
-
/** Seed the status file with a prior op in the given phase.
|
|
327
|
-
|
|
326
|
+
/** Seed the status file with a prior op in the given phase. `startedAt`
|
|
327
|
+
* defaults to now (a FRESH in-flight slot); pass an old ISO string to seed a
|
|
328
|
+
* stale / abandoned slot for the #506 TTL tests. */
|
|
329
|
+
function seedStatus(
|
|
330
|
+
dir: string,
|
|
331
|
+
phase: HubUpgradeStatus["phase"],
|
|
332
|
+
opId = "prior-op",
|
|
333
|
+
startedAt: string = new Date().toISOString(),
|
|
334
|
+
): void {
|
|
328
335
|
writeHubUpgradeStatus(dir, {
|
|
329
336
|
operation_id: opId,
|
|
330
337
|
phase,
|
|
@@ -333,7 +340,7 @@ describe("POST /api/hub/upgrade — 409 in-flight guard (concurrent-upgrade)", (
|
|
|
333
340
|
target_version: "0.6.3-rc.2",
|
|
334
341
|
channel: "rc",
|
|
335
342
|
log: [],
|
|
336
|
-
started_at:
|
|
343
|
+
started_at: startedAt,
|
|
337
344
|
});
|
|
338
345
|
}
|
|
339
346
|
|
|
@@ -385,6 +392,55 @@ describe("POST /api/hub/upgrade — 409 in-flight guard (concurrent-upgrade)", (
|
|
|
385
392
|
expect(res.status).toBe(202);
|
|
386
393
|
expect(spawned.length).toBe(1);
|
|
387
394
|
});
|
|
395
|
+
|
|
396
|
+
// #506: a crashed helper leaves an in-flight slot stuck forever — without a
|
|
397
|
+
// TTL it 409-deadlocks every future upgrade. A STALE in-flight slot must be
|
|
398
|
+
// treated as abandoned so the new request proceeds.
|
|
399
|
+
for (const phase of ["pending", "running", "restarting"] as const) {
|
|
400
|
+
test(`#506: STALE in-flight slot (phase=${phase}, started 30m ago) → proceeds, not 409`, async () => {
|
|
401
|
+
const bearer = await mintBearer(harness, ["parachute:host:admin"]);
|
|
402
|
+
const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString();
|
|
403
|
+
seedStatus(harness.dir, phase, "crashed-op", thirtyMinAgo);
|
|
404
|
+
const { deps, spawned } = baseDeps(harness);
|
|
405
|
+
const res = await handleHubUpgrade(
|
|
406
|
+
postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
|
|
407
|
+
deps,
|
|
408
|
+
);
|
|
409
|
+
// Abandoned slot freed: a fresh op took over + spawned its helper.
|
|
410
|
+
expect(res.status).toBe(202);
|
|
411
|
+
expect(spawned.length).toBe(1);
|
|
412
|
+
const status = readHubUpgradeStatus(harness.dir);
|
|
413
|
+
expect(status?.operation_id).not.toBe("crashed-op");
|
|
414
|
+
expect(spawned[0]?.operationId).toBe(status?.operation_id);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
test("#506: FRESH in-flight slot (started just now) → still 409", async () => {
|
|
419
|
+
const bearer = await mintBearer(harness, ["parachute:host:admin"]);
|
|
420
|
+
// Just-started (well within the 15m TTL) → a real, live upgrade → 409.
|
|
421
|
+
seedStatus(harness.dir, "running", "live-op", new Date().toISOString());
|
|
422
|
+
const { deps, spawned } = baseDeps(harness);
|
|
423
|
+
const res = await handleHubUpgrade(
|
|
424
|
+
postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
|
|
425
|
+
deps,
|
|
426
|
+
);
|
|
427
|
+
expect(res.status).toBe(409);
|
|
428
|
+
expect(spawned.length).toBe(0);
|
|
429
|
+
expect(readHubUpgradeStatus(harness.dir)?.operation_id).toBe("live-op");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("#506: in-flight slot with a malformed started_at → treated as stale, proceeds", async () => {
|
|
433
|
+
const bearer = await mintBearer(harness, ["parachute:host:admin"]);
|
|
434
|
+
seedStatus(harness.dir, "running", "garbage-op", "not-a-date");
|
|
435
|
+
const { deps, spawned } = baseDeps(harness);
|
|
436
|
+
const res = await handleHubUpgrade(
|
|
437
|
+
postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
|
|
438
|
+
deps,
|
|
439
|
+
);
|
|
440
|
+
// An unparseable timestamp must not deadlock — treat as abandoned.
|
|
441
|
+
expect(res.status).toBe(202);
|
|
442
|
+
expect(spawned.length).toBe(1);
|
|
443
|
+
});
|
|
388
444
|
});
|
|
389
445
|
|
|
390
446
|
describe("appendHubUpgradeStatus — operation_id guard (stale-helper isolation)", () => {
|
|
@@ -151,7 +151,10 @@ describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expa
|
|
|
151
151
|
const hubOrigins = [LOOPBACK, "http://localhost:1939", PUBLIC];
|
|
152
152
|
|
|
153
153
|
test("expands a loopback-rooted URI onto every other hub origin", () => {
|
|
154
|
-
const out = expandRedirectUrisForHubOrigins(
|
|
154
|
+
const out = expandRedirectUrisForHubOrigins(
|
|
155
|
+
[`${LOOPBACK}/surface/notes/oauth/callback`],
|
|
156
|
+
hubOrigins,
|
|
157
|
+
);
|
|
155
158
|
// Original is preserved + the public + localhost variants are added.
|
|
156
159
|
expect(out).toContain(`${LOOPBACK}/surface/notes/oauth/callback`);
|
|
157
160
|
expect(out).toContain(`${PUBLIC}/surface/notes/oauth/callback`);
|
|
@@ -188,10 +191,7 @@ describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expa
|
|
|
188
191
|
});
|
|
189
192
|
|
|
190
193
|
test("single known hub origin → no expansion (submitted set returned as-is)", () => {
|
|
191
|
-
const out = expandRedirectUrisForHubOrigins(
|
|
192
|
-
[`${LOOPBACK}/surface/notes/`],
|
|
193
|
-
[LOOPBACK],
|
|
194
|
-
);
|
|
194
|
+
const out = expandRedirectUrisForHubOrigins([`${LOOPBACK}/surface/notes/`], [LOOPBACK]);
|
|
195
195
|
expect(out).toEqual([`${LOOPBACK}/surface/notes/`]);
|
|
196
196
|
});
|
|
197
197
|
|
|
@@ -222,9 +222,9 @@ describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expa
|
|
|
222
222
|
const r = registerClient(db, { redirectUris: expanded });
|
|
223
223
|
// The public-origin variant now matches exactly at authorize time — the
|
|
224
224
|
// off-localhost sign-in that surface#118 broke.
|
|
225
|
-
expect(
|
|
226
|
-
|
|
227
|
-
)
|
|
225
|
+
expect(requireRegisteredRedirectUri(r.client, `${PUBLIC}/surface/notes/oauth/callback`)).toBe(
|
|
226
|
+
`${PUBLIC}/surface/notes/oauth/callback`,
|
|
227
|
+
);
|
|
228
228
|
// A truly-unregistered URI is still rejected — strict match unchanged.
|
|
229
229
|
expect(() =>
|
|
230
230
|
requireRegisteredRedirectUri(r.client, "https://evil.example/surface/notes/oauth/callback"),
|
|
@@ -352,4 +352,24 @@ describe("isValidRedirectUri", () => {
|
|
|
352
352
|
expect(isValidRedirectUri("/relative")).toBe(false);
|
|
353
353
|
expect(isValidRedirectUri("not a url")).toBe(false);
|
|
354
354
|
});
|
|
355
|
+
// hub#663: spec-forbidden shapes that the protocol allowlist alone passed.
|
|
356
|
+
test("rejects userinfo-bearing redirect URIs (hub#663)", () => {
|
|
357
|
+
expect(isValidRedirectUri("https://x@evil.com/cb")).toBe(false);
|
|
358
|
+
expect(isValidRedirectUri("https://user:pass@evil.com/cb")).toBe(false);
|
|
359
|
+
expect(isValidRedirectUri("http://attacker@127.0.0.1:3000/cb")).toBe(false);
|
|
360
|
+
});
|
|
361
|
+
test("rejects control chars in the raw input (hub#663)", () => {
|
|
362
|
+
// Control chars must be caught on the RAW string — URL parsing would
|
|
363
|
+
// otherwise strip a trailing \r\n and the smuggled value would pass.
|
|
364
|
+
expect(isValidRedirectUri("https://example.com/cb\r\nSet-Cookie: x")).toBe(false);
|
|
365
|
+
expect(isValidRedirectUri("https://example.com/\x00cb")).toBe(false);
|
|
366
|
+
expect(isValidRedirectUri("https://example.com/cb\x7f")).toBe(false);
|
|
367
|
+
});
|
|
368
|
+
test("still accepts clean http(s) with ports, paths, and queries (regression guard)", () => {
|
|
369
|
+
// Legitimate clients (hub modules, self-built surfaces, Notes, Claude DCR)
|
|
370
|
+
// all register clean URIs — these must keep passing.
|
|
371
|
+
expect(isValidRedirectUri("https://claude.ai/api/mcp/auth_callback")).toBe(true);
|
|
372
|
+
expect(isValidRedirectUri("http://localhost:1939/admin/oauth/callback")).toBe(true);
|
|
373
|
+
expect(isValidRedirectUri("https://my-surface.github.io/cb?x=1")).toBe(true);
|
|
374
|
+
});
|
|
355
375
|
});
|
|
@@ -258,8 +258,10 @@ describe("installConnectorService — Linux systemd", () => {
|
|
|
258
258
|
platform: "linux",
|
|
259
259
|
getuid: () => 1000,
|
|
260
260
|
userName: () => "op",
|
|
261
|
-
//
|
|
261
|
+
// #528 probe: show-user → Linger=no (off, so we proceed to enable);
|
|
262
|
+
// then enable-linger FAIL, daemon-reload OK, enable --now OK.
|
|
262
263
|
runResults: [
|
|
264
|
+
{ code: 0, stdout: "Linger=no\n", stderr: "" },
|
|
263
265
|
{ code: 1, stdout: "", stderr: "Failed to enable linger" },
|
|
264
266
|
{ code: 0, stdout: "", stderr: "" },
|
|
265
267
|
{ code: 0, stdout: "", stderr: "" },
|
|
@@ -3,10 +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 {
|
|
7
|
-
_resetBootstrapTokenForTests,
|
|
8
|
-
generateBootstrapToken,
|
|
9
|
-
} from "../bootstrap-token.ts";
|
|
6
|
+
import { _resetBootstrapTokenForTests, generateBootstrapToken } from "../bootstrap-token.ts";
|
|
10
7
|
import { buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
|
|
11
8
|
import { HUB_SVC, hubPortPath } from "../hub-control.ts";
|
|
12
9
|
import { createDbHolder } from "../hub-db-liveness.ts";
|
|
@@ -3715,7 +3712,9 @@ describe("layerOf — classify trust layer from proxy headers + peer (item E / #
|
|
|
3715
3712
|
// flipped an empty XFF back to loopback would re-open the Caddy-direct leak.
|
|
3716
3713
|
test("loopback peer + empty X-Forwarded-For → public (errs safe, not loopback) [#704]", () => {
|
|
3717
3714
|
expect(layerOf(req("/", { headers: { "X-Forwarded-For": "" } }), "127.0.0.1")).toBe("public");
|
|
3718
|
-
expect(layerOf(req("/", { headers: { "X-Forwarded-For": " " } }), "127.0.0.1")).toBe(
|
|
3715
|
+
expect(layerOf(req("/", { headers: { "X-Forwarded-For": " " } }), "127.0.0.1")).toBe(
|
|
3716
|
+
"public",
|
|
3717
|
+
);
|
|
3719
3718
|
});
|
|
3720
3719
|
|
|
3721
3720
|
// The genuine on-box caller (CLI, health probe, init bootstrap-token loopback
|
|
@@ -6090,3 +6089,126 @@ describe("GET /admin/setup bootstrap-token probe — loopback-gated (hub#576 + C
|
|
|
6090
6089
|
}
|
|
6091
6090
|
});
|
|
6092
6091
|
});
|
|
6092
|
+
|
|
6093
|
+
// hub#643 (Tier-1): non-script security headers on proxied module/surface
|
|
6094
|
+
// text/html pages. The vault content proxy and the generic services-mount
|
|
6095
|
+
// proxy both flow through `decorateWithChrome`, so the headers land on both.
|
|
6096
|
+
// DELIBERATELY no `script-src` — a strict script-src would white-screen
|
|
6097
|
+
// self-built GitHub-hosted surfaces + inline-script module pages (that's the
|
|
6098
|
+
// deferred Tier-2). Header-only: non-HTML proxied responses are NOT decorated.
|
|
6099
|
+
describe("hubFetch proxied-page security headers (hub#643 Tier-1)", () => {
|
|
6100
|
+
const TIER1_CSP = "frame-ancestors 'self'; object-src 'none'; base-uri 'self'";
|
|
6101
|
+
|
|
6102
|
+
// Live upstream that echoes a fixed content-type + body so the test can
|
|
6103
|
+
// exercise both the text/html (decorated) and JSON (untouched) branches.
|
|
6104
|
+
function startUpstream(contentType: string, body: string): { port: number; stop: () => void } {
|
|
6105
|
+
const server = Bun.serve({
|
|
6106
|
+
port: 0,
|
|
6107
|
+
hostname: "127.0.0.1",
|
|
6108
|
+
fetch: () => new Response(body, { status: 200, headers: { "content-type": contentType } }),
|
|
6109
|
+
});
|
|
6110
|
+
return { port: server.port as number, stop: () => server.stop(true) };
|
|
6111
|
+
}
|
|
6112
|
+
|
|
6113
|
+
test("decorates a proxied text/html generic-mount page with nosniff + the Tier-1 CSP", async () => {
|
|
6114
|
+
const h = makeHarness();
|
|
6115
|
+
const upstream = startUpstream(
|
|
6116
|
+
"text/html; charset=utf-8",
|
|
6117
|
+
"<html><body><h1>my surface</h1></body></html>",
|
|
6118
|
+
);
|
|
6119
|
+
try {
|
|
6120
|
+
writeManifest(
|
|
6121
|
+
{
|
|
6122
|
+
services: [
|
|
6123
|
+
{
|
|
6124
|
+
name: "parachute-surface",
|
|
6125
|
+
port: upstream.port,
|
|
6126
|
+
paths: ["/surface"],
|
|
6127
|
+
health: "/surface/health",
|
|
6128
|
+
version: "0.2.0",
|
|
6129
|
+
},
|
|
6130
|
+
],
|
|
6131
|
+
},
|
|
6132
|
+
h.manifestPath,
|
|
6133
|
+
);
|
|
6134
|
+
const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(req("/surface/foo"));
|
|
6135
|
+
expect(res.status).toBe(200);
|
|
6136
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
6137
|
+
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
6138
|
+
const csp = res.headers.get("content-security-policy");
|
|
6139
|
+
expect(csp).toBe(TIER1_CSP);
|
|
6140
|
+
// The critical Tier-1/Tier-2 boundary: NO script-src — self-built
|
|
6141
|
+
// GitHub-hosted surfaces + inline-script module pages must stay
|
|
6142
|
+
// unrestricted. A strict script-src is the deferred Tier-2.
|
|
6143
|
+
expect(csp).not.toContain("script-src");
|
|
6144
|
+
} finally {
|
|
6145
|
+
upstream.stop();
|
|
6146
|
+
h.cleanup();
|
|
6147
|
+
}
|
|
6148
|
+
});
|
|
6149
|
+
|
|
6150
|
+
test("decorates a proxied text/html per-vault page (the Notes-PWA path) with the same headers", async () => {
|
|
6151
|
+
const h = makeHarness();
|
|
6152
|
+
const upstream = startUpstream("text/html; charset=utf-8", "<html><body>notes</body></html>");
|
|
6153
|
+
try {
|
|
6154
|
+
writeManifest(
|
|
6155
|
+
{
|
|
6156
|
+
services: [
|
|
6157
|
+
{
|
|
6158
|
+
name: "parachute-vault",
|
|
6159
|
+
port: upstream.port,
|
|
6160
|
+
paths: ["/vault/default"],
|
|
6161
|
+
health: "/vault/default/health",
|
|
6162
|
+
version: "0.4.0",
|
|
6163
|
+
},
|
|
6164
|
+
],
|
|
6165
|
+
},
|
|
6166
|
+
h.manifestPath,
|
|
6167
|
+
);
|
|
6168
|
+
const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
|
|
6169
|
+
req("/vault/default/some-page"),
|
|
6170
|
+
);
|
|
6171
|
+
expect(res.status).toBe(200);
|
|
6172
|
+
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
6173
|
+
expect(res.headers.get("content-security-policy")).toBe(TIER1_CSP);
|
|
6174
|
+
expect(res.headers.get("content-security-policy")).not.toContain("script-src");
|
|
6175
|
+
} finally {
|
|
6176
|
+
upstream.stop();
|
|
6177
|
+
h.cleanup();
|
|
6178
|
+
}
|
|
6179
|
+
});
|
|
6180
|
+
|
|
6181
|
+
test("leaves a proxied NON-HTML response (JSON) undecorated", async () => {
|
|
6182
|
+
const h = makeHarness();
|
|
6183
|
+
const upstream = startUpstream("application/json", JSON.stringify({ ok: true }));
|
|
6184
|
+
try {
|
|
6185
|
+
writeManifest(
|
|
6186
|
+
{
|
|
6187
|
+
services: [
|
|
6188
|
+
{
|
|
6189
|
+
name: "parachute-surface",
|
|
6190
|
+
port: upstream.port,
|
|
6191
|
+
paths: ["/surface"],
|
|
6192
|
+
health: "/surface/health",
|
|
6193
|
+
version: "0.2.0",
|
|
6194
|
+
},
|
|
6195
|
+
],
|
|
6196
|
+
},
|
|
6197
|
+
h.manifestPath,
|
|
6198
|
+
);
|
|
6199
|
+
const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(req("/surface/api/data"));
|
|
6200
|
+
expect(res.status).toBe(200);
|
|
6201
|
+
expect(res.headers.get("content-type")).toContain("application/json");
|
|
6202
|
+
// No HTML CSP on a JSON API response (proves the header is gated on
|
|
6203
|
+
// content-type, so a `.js` asset proxied through the same path is also
|
|
6204
|
+
// left alone).
|
|
6205
|
+
expect(res.headers.get("content-security-policy")).toBeNull();
|
|
6206
|
+
expect(res.headers.get("x-content-type-options")).toBeNull();
|
|
6207
|
+
const body = (await res.json()) as { ok: boolean };
|
|
6208
|
+
expect(body.ok).toBe(true);
|
|
6209
|
+
} finally {
|
|
6210
|
+
upstream.stop();
|
|
6211
|
+
h.cleanup();
|
|
6212
|
+
}
|
|
6213
|
+
});
|
|
6214
|
+
});
|
|
@@ -398,6 +398,68 @@ describe("installManagedUnit — start:boolean (§7.1)", () => {
|
|
|
398
398
|
expect(f.calls).toContainEqual(["systemctl", "--user", "daemon-reload"]);
|
|
399
399
|
expect(f.calls.some((c) => c.includes("enable"))).toBe(false);
|
|
400
400
|
});
|
|
401
|
+
|
|
402
|
+
// #528: a per-command fake `run` so the linger probe + enable-linger can return
|
|
403
|
+
// distinct results. Non-linger commands (systemctl daemon-reload / enable) all
|
|
404
|
+
// succeed; only the linger sequence is scripted via `linger`.
|
|
405
|
+
function lingerDeps(linger: {
|
|
406
|
+
probe?: ServiceCommandResult;
|
|
407
|
+
enable?: ServiceCommandResult;
|
|
408
|
+
}): FakeDepsState {
|
|
409
|
+
const ok: ServiceCommandResult = { code: 0, stdout: "", stderr: "" };
|
|
410
|
+
return fakeDeps({
|
|
411
|
+
platform: "linux",
|
|
412
|
+
getuid: () => 1000,
|
|
413
|
+
userName: () => "op",
|
|
414
|
+
run: ((cmd: readonly string[]) => {
|
|
415
|
+
// `calls` is recorded by the default run; here we record into a closure
|
|
416
|
+
// list returned alongside via the returned FakeDepsState — but fakeDeps
|
|
417
|
+
// only records in its OWN default run. So push into a shared array.
|
|
418
|
+
recorded.push([...cmd]);
|
|
419
|
+
if (cmd[0] === "loginctl" && cmd[1] === "show-user") return linger.probe ?? ok;
|
|
420
|
+
if (cmd[0] === "loginctl" && cmd[1] === "enable-linger") return linger.enable ?? ok;
|
|
421
|
+
return ok;
|
|
422
|
+
}) as ManagedUnitDeps["run"],
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
// Shared recorder for the per-command run above (fakeDeps's own `calls` array
|
|
426
|
+
// isn't populated when we override `run`).
|
|
427
|
+
let recorded: string[][] = [];
|
|
428
|
+
|
|
429
|
+
test("#528: linger ALREADY on → no enable attempt, no warning (false-alarm fix)", () => {
|
|
430
|
+
recorded = [];
|
|
431
|
+
const f = lingerDeps({ probe: { code: 0, stdout: "Linger=yes\n", stderr: "" } });
|
|
432
|
+
const result = installManagedUnit({
|
|
433
|
+
unit: hubUnit(f.deps),
|
|
434
|
+
deps: f.deps,
|
|
435
|
+
messages: HUB_MESSAGES,
|
|
436
|
+
start: false,
|
|
437
|
+
});
|
|
438
|
+
// Probed current state...
|
|
439
|
+
expect(recorded).toContainEqual(["loginctl", "show-user", "op", "--property=Linger"]);
|
|
440
|
+
// ...and because it's already on, did NOT try to enable it.
|
|
441
|
+
expect(recorded.some((c) => c[0] === "loginctl" && c[1] === "enable-linger")).toBe(false);
|
|
442
|
+
// ...and emitted NO scary linger warning.
|
|
443
|
+
expect(result.messages).not.toContain(HUB_MESSAGES.lingerWarning);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("#528: linger OFF + enable-linger fails → warning surfaces", () => {
|
|
447
|
+
recorded = [];
|
|
448
|
+
const f = lingerDeps({
|
|
449
|
+
probe: { code: 0, stdout: "Linger=no\n", stderr: "" },
|
|
450
|
+
enable: { code: 1, stdout: "", stderr: "operation not permitted" },
|
|
451
|
+
});
|
|
452
|
+
const result = installManagedUnit({
|
|
453
|
+
unit: hubUnit(f.deps),
|
|
454
|
+
deps: f.deps,
|
|
455
|
+
messages: HUB_MESSAGES,
|
|
456
|
+
start: false,
|
|
457
|
+
});
|
|
458
|
+
// Off → did attempt to enable...
|
|
459
|
+
expect(recorded).toContainEqual(["loginctl", "enable-linger", "op"]);
|
|
460
|
+
// ...and the genuine failure warns.
|
|
461
|
+
expect(result.messages).toContain(HUB_MESSAGES.lingerWarning);
|
|
462
|
+
});
|
|
401
463
|
});
|
|
402
464
|
|
|
403
465
|
// ---------------------------------------------------------------------------
|
|
@@ -990,6 +990,123 @@ describe("handleSetupGet", () => {
|
|
|
990
990
|
db.close();
|
|
991
991
|
}
|
|
992
992
|
});
|
|
993
|
+
|
|
994
|
+
// hub#618: gate the JSON `?op=` op-snapshot once setup is complete.
|
|
995
|
+
// Mid-setup it stays OPEN (the unauth CLI wizard + brand-new-operator
|
|
996
|
+
// browser both poll it before any session exists); post-complete it
|
|
997
|
+
// requires a session or loopback (it's a post-setup admin surface, and
|
|
998
|
+
// `/admin/setup` is always lockout-exempt so it's otherwise unauth-reachable).
|
|
999
|
+
|
|
1000
|
+
test("mid-setup unauth ?op= still returns the op snapshot (hub#618 regression guard)", async () => {
|
|
1001
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1002
|
+
try {
|
|
1003
|
+
// No admin yet → setup INCOMPLETE → the surface stays open.
|
|
1004
|
+
const reg = getDefaultOperationsRegistry();
|
|
1005
|
+
const op = reg.create("install", "vault");
|
|
1006
|
+
reg.update(op.id, { status: "running" }, "running bun add -g @openparachute/vault@latest");
|
|
1007
|
+
const res = handleSetupGet(
|
|
1008
|
+
req(`/admin/setup?op=${op.id}`, { headers: { accept: "application/json" } }),
|
|
1009
|
+
{
|
|
1010
|
+
db,
|
|
1011
|
+
manifestPath: h.manifestPath,
|
|
1012
|
+
configDir: h.dir,
|
|
1013
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
1014
|
+
issuer: "https://hub.example",
|
|
1015
|
+
registry: reg,
|
|
1016
|
+
// No loopback flag, no session — the unauth first-boot poll.
|
|
1017
|
+
},
|
|
1018
|
+
);
|
|
1019
|
+
expect(res.status).toBe(200);
|
|
1020
|
+
const body = (await res.json()) as {
|
|
1021
|
+
hasAdmin: boolean;
|
|
1022
|
+
operation?: { id: string; status: string; log: readonly string[] };
|
|
1023
|
+
};
|
|
1024
|
+
expect(body.hasAdmin).toBe(false);
|
|
1025
|
+
expect(body.operation).toBeDefined();
|
|
1026
|
+
expect(body.operation?.id).toBe(op.id);
|
|
1027
|
+
expect(body.operation?.status).toBe("running");
|
|
1028
|
+
} finally {
|
|
1029
|
+
db.close();
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
test("post-complete unauth ?op= omits the op snapshot; session OR loopback restores it (hub#618)", async () => {
|
|
1034
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1035
|
+
try {
|
|
1036
|
+
// Drive state to COMPLETE: admin + vault + expose mode.
|
|
1037
|
+
const user = await createUser(db, "owner", "pw");
|
|
1038
|
+
writeManifest(
|
|
1039
|
+
{
|
|
1040
|
+
services: [
|
|
1041
|
+
{
|
|
1042
|
+
name: "parachute-vault",
|
|
1043
|
+
version: "0.1.0",
|
|
1044
|
+
port: 1940,
|
|
1045
|
+
paths: ["/vault/default"],
|
|
1046
|
+
health: "/health",
|
|
1047
|
+
},
|
|
1048
|
+
],
|
|
1049
|
+
},
|
|
1050
|
+
h.manifestPath,
|
|
1051
|
+
);
|
|
1052
|
+
setSetting(db, "setup_expose_mode", "localhost");
|
|
1053
|
+
const reg = getDefaultOperationsRegistry();
|
|
1054
|
+
const op = reg.create("install", "vault");
|
|
1055
|
+
reg.update(op.id, { status: "running" }, "still running");
|
|
1056
|
+
|
|
1057
|
+
const deps = {
|
|
1058
|
+
db,
|
|
1059
|
+
manifestPath: h.manifestPath,
|
|
1060
|
+
configDir: h.dir,
|
|
1061
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
1062
|
+
issuer: "https://hub.example",
|
|
1063
|
+
registry: reg,
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
// (a) Unauth, non-loopback → operation omitted.
|
|
1067
|
+
const unauth = handleSetupGet(
|
|
1068
|
+
req(`/admin/setup?op=${op.id}`, { headers: { accept: "application/json" } }),
|
|
1069
|
+
deps,
|
|
1070
|
+
);
|
|
1071
|
+
expect(unauth.status).toBe(200);
|
|
1072
|
+
const unauthBody = (await unauth.json()) as {
|
|
1073
|
+
hasAdmin: boolean;
|
|
1074
|
+
hasVault: boolean;
|
|
1075
|
+
hasExposeMode: boolean;
|
|
1076
|
+
operation?: unknown;
|
|
1077
|
+
};
|
|
1078
|
+
// Confirm setup actually derived as complete (else the gate is vacuous).
|
|
1079
|
+
expect(unauthBody.hasAdmin).toBe(true);
|
|
1080
|
+
expect(unauthBody.hasVault).toBe(true);
|
|
1081
|
+
expect(unauthBody.hasExposeMode).toBe(true);
|
|
1082
|
+
expect(unauthBody.operation).toBeUndefined();
|
|
1083
|
+
|
|
1084
|
+
// (b) Valid session → operation restored.
|
|
1085
|
+
const { createSession } = await import("../sessions.ts");
|
|
1086
|
+
const session = createSession(db, { userId: user.id });
|
|
1087
|
+
const authed = handleSetupGet(
|
|
1088
|
+
req(`/admin/setup?op=${op.id}`, {
|
|
1089
|
+
headers: {
|
|
1090
|
+
accept: "application/json",
|
|
1091
|
+
cookie: `${SESSION_COOKIE_NAME}=${session.id}`,
|
|
1092
|
+
},
|
|
1093
|
+
}),
|
|
1094
|
+
deps,
|
|
1095
|
+
);
|
|
1096
|
+
const authedBody = (await authed.json()) as { operation?: { id: string } };
|
|
1097
|
+
expect(authedBody.operation?.id).toBe(op.id);
|
|
1098
|
+
|
|
1099
|
+
// (c) Loopback (no session) → operation restored.
|
|
1100
|
+
const loopback = handleSetupGet(
|
|
1101
|
+
req(`/admin/setup?op=${op.id}`, { headers: { accept: "application/json" } }),
|
|
1102
|
+
{ ...deps, requestIsLoopback: true },
|
|
1103
|
+
);
|
|
1104
|
+
const loopbackBody = (await loopback.json()) as { operation?: { id: string } };
|
|
1105
|
+
expect(loopbackBody.operation?.id).toBe(op.id);
|
|
1106
|
+
} finally {
|
|
1107
|
+
db.close();
|
|
1108
|
+
}
|
|
1109
|
+
});
|
|
993
1110
|
});
|
|
994
1111
|
|
|
995
1112
|
// --- POST /admin/setup/account -------------------------------------------
|
|
@@ -1591,6 +1591,31 @@ describe("Supervisor port-readiness + structured start-error (§6.5)", () => {
|
|
|
1591
1591
|
expect(spawner.calls).toHaveLength(0);
|
|
1592
1592
|
});
|
|
1593
1593
|
|
|
1594
|
+
test("(#634) preflight non-executable binary → non_executable start-error, NO spawn", async () => {
|
|
1595
|
+
const spawner = makeQueueSpawner();
|
|
1596
|
+
const sup = new Supervisor({
|
|
1597
|
+
spawnFn: spawner.spawn,
|
|
1598
|
+
killFn: noopKill,
|
|
1599
|
+
// `which` requires X_OK so it returns null for a 100644 bin...
|
|
1600
|
+
which: () => null,
|
|
1601
|
+
// ...but the secondary probe finds it present-but-non-executable.
|
|
1602
|
+
findNonExecutable: () => "/x/vault/bin/parachute-vault",
|
|
1603
|
+
portListening: async () => true,
|
|
1604
|
+
startReadyMs: 50,
|
|
1605
|
+
sleep: () => Promise.resolve(),
|
|
1606
|
+
});
|
|
1607
|
+
const state = await sup.start(reqWithPort("vault", 1940));
|
|
1608
|
+
|
|
1609
|
+
expect(state.status).toBe("crashed");
|
|
1610
|
+
expect(state.startError?.error_type).toBe("non_executable");
|
|
1611
|
+
expect(state.startError?.error_description).toContain(
|
|
1612
|
+
"but is not executable — run chmod +x /x/vault/bin/parachute-vault",
|
|
1613
|
+
);
|
|
1614
|
+
// No misleading "not installed" install card, and never spawned.
|
|
1615
|
+
expect(state.startError?.binary).toBe("parachute-vault");
|
|
1616
|
+
expect(spawner.calls).toHaveLength(0);
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1594
1619
|
test("a clean re-start clears a prior started-but-unbound start-error", async () => {
|
|
1595
1620
|
const first = makeFakeProc(201);
|
|
1596
1621
|
const second = makeFakeProc(202);
|
package/src/api-hub-upgrade.ts
CHANGED
|
@@ -67,6 +67,34 @@ export const HUB_UPGRADE_REQUIRED_SCOPE = "parachute:host:admin";
|
|
|
67
67
|
*/
|
|
68
68
|
const IN_FLIGHT_PHASES = new Set<HubUpgradeStatus["phase"]>(["pending", "running", "restarting"]);
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* #506: TTL for the 409 in-flight guard. The status file is single-slot, and a
|
|
72
|
+
* helper that CRASHES (OOM, killed mid-rewrite, host reboot) never reaches a
|
|
73
|
+
* terminal phase — leaving the slot stuck in `pending`/`running`/`restarting`
|
|
74
|
+
* FOREVER and 409-deadlocking every future upgrade. So: an in-flight slot whose
|
|
75
|
+
* `started_at` is older than this bound is treated as ABANDONED and the new
|
|
76
|
+
* request proceeds (overwriting the stale slot).
|
|
77
|
+
*
|
|
78
|
+
* 15 minutes — comfortably past the longest expected in-place upgrade (an
|
|
79
|
+
* `npm view` + `bun add -g` rewrite + restart is seconds-to-low-minutes even on
|
|
80
|
+
* a slow box / cold cache). A live upgrade finishing under the bound is never
|
|
81
|
+
* mistaken for abandoned; a crashed one frees the slot within 15 min instead of
|
|
82
|
+
* never. (A missing/garbage `started_at` is treated as stale → not 409, so a
|
|
83
|
+
* malformed file can't deadlock either.)
|
|
84
|
+
*/
|
|
85
|
+
const IN_FLIGHT_TTL_MS = 15 * 60 * 1000;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Is an in-flight slot still FRESH (within the TTL), so a second POST must be
|
|
89
|
+
* rejected 409? An unparseable / missing `started_at` is treated as stale
|
|
90
|
+
* (not fresh) so a malformed file frees the slot rather than deadlocking it.
|
|
91
|
+
*/
|
|
92
|
+
function isInFlightFresh(existing: HubUpgradeStatus, now: Date): boolean {
|
|
93
|
+
const startedMs = Date.parse(existing.started_at);
|
|
94
|
+
if (Number.isNaN(startedMs)) return false;
|
|
95
|
+
return now.getTime() - startedMs < IN_FLIGHT_TTL_MS;
|
|
96
|
+
}
|
|
97
|
+
|
|
70
98
|
export interface SpawnHelperArgs {
|
|
71
99
|
operationId: string;
|
|
72
100
|
channel: "rc" | "latest";
|
|
@@ -213,7 +241,9 @@ export async function handleHubUpgrade(req: Request, deps: ApiHubUpgradeDeps): P
|
|
|
213
241
|
const parsed = await parseBody(req);
|
|
214
242
|
if (parsed instanceof Response) return parsed;
|
|
215
243
|
|
|
216
|
-
|
|
244
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
245
|
+
|
|
246
|
+
// ── 409 in-flight guard (TTL-bounded) ──────────────────────────────────────
|
|
217
247
|
// The status file is single-slot (one hub, one upgrade). If a prior upgrade
|
|
218
248
|
// is still in a non-terminal phase (pending/running/restarting), starting a
|
|
219
249
|
// SECOND would overwrite its operation_id — and a still-running first helper
|
|
@@ -222,9 +252,15 @@ export async function handleHubUpgrade(req: Request, deps: ApiHubUpgradeDeps): P
|
|
|
222
252
|
// server-side too (a second tab, a stale page, a scripted POST). Reject with
|
|
223
253
|
// 409 unless the slot is free (no file) or the prior op reached a terminal
|
|
224
254
|
// phase (failed / redeploy-required / succeeded).
|
|
255
|
+
//
|
|
256
|
+
// #506: BUT a non-terminal slot is only a real block while it's FRESH. A
|
|
257
|
+
// helper that crashed (OOM / killed / host reboot) leaves the slot stuck
|
|
258
|
+
// in-flight forever and would 409-deadlock every future upgrade. So an
|
|
259
|
+
// in-flight slot older than IN_FLIGHT_TTL_MS is treated as ABANDONED and the
|
|
260
|
+
// request proceeds (the seeded status below overwrites the stale slot).
|
|
225
261
|
const readStatus = deps.readStatus ?? readHubUpgradeStatus;
|
|
226
262
|
const existing = readStatus(deps.configDir);
|
|
227
|
-
if (existing && IN_FLIGHT_PHASES.has(existing.phase)) {
|
|
263
|
+
if (existing && IN_FLIGHT_PHASES.has(existing.phase) && isInFlightFresh(existing, now)) {
|
|
228
264
|
return jsonError(
|
|
229
265
|
409,
|
|
230
266
|
"upgrade_in_flight",
|
|
@@ -234,7 +270,6 @@ export async function handleHubUpgrade(req: Request, deps: ApiHubUpgradeDeps): P
|
|
|
234
270
|
|
|
235
271
|
const hubSrcDir = deps.hubSrcDir ?? dirname(fileURLToPath(import.meta.url));
|
|
236
272
|
const env = deps.env ?? process.env;
|
|
237
|
-
const now = (deps.now ?? (() => new Date()))();
|
|
238
273
|
|
|
239
274
|
const currentVersion = (deps.currentVersion ?? (() => defaultCurrentVersion(hubSrcDir)))();
|
|
240
275
|
// Auto-detect the channel from the current version when not explicitly set —
|
package/src/clients.ts
CHANGED
|
@@ -323,9 +323,23 @@ function timingSafeEqualHex(a: string, b: string): boolean {
|
|
|
323
323
|
* URIs). Doesn't try to match a registered URI; that's `requireRegisteredRedirectUri`.
|
|
324
324
|
*/
|
|
325
325
|
export function isValidRedirectUri(uri: string): boolean {
|
|
326
|
+
// hub#663: reject control chars (C0 0x00-0x1f + DEL 0x7f) in the RAW input
|
|
327
|
+
// BEFORE URL parsing normalizes/strips them. A `\r`/`\n`/NUL smuggled into a
|
|
328
|
+
// redirect_uri is a header/log-injection vector even though our exact-match +
|
|
329
|
+
// verbatim foreign-storage neutralize it downstream — spec-forbidden hygiene.
|
|
330
|
+
// (Charcode scan rather than a control-char regex literal, which biome's
|
|
331
|
+
// noControlCharactersInRegex rightly flags as an easy footgun.)
|
|
332
|
+
for (let i = 0; i < uri.length; i++) {
|
|
333
|
+
const c = uri.charCodeAt(i);
|
|
334
|
+
if (c <= 0x1f || c === 0x7f) return false;
|
|
335
|
+
}
|
|
326
336
|
try {
|
|
327
337
|
const u = new URL(uri);
|
|
328
338
|
if (u.protocol === "javascript:" || u.protocol === "data:") return false;
|
|
339
|
+
// hub#663: reject userinfo (`https://x@evil.com/cb`). A redirect target
|
|
340
|
+
// carrying credentials is spec-forbidden and an open-redirect / phishing
|
|
341
|
+
// shape; the protocol allowlist alone let it through.
|
|
342
|
+
if (u.username !== "" || u.password !== "") return false;
|
|
329
343
|
return u.protocol === "http:" || u.protocol === "https:";
|
|
330
344
|
} catch {
|
|
331
345
|
return false;
|
package/src/hub-server.ts
CHANGED
|
@@ -3824,13 +3824,57 @@ async function decorateWithChrome(
|
|
|
3824
3824
|
if (setCookie && out !== res) {
|
|
3825
3825
|
const headers = new Headers(out.headers);
|
|
3826
3826
|
headers.append("set-cookie", setCookie);
|
|
3827
|
-
return
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3827
|
+
return withProxySecurityHeaders(
|
|
3828
|
+
new Response(out.body, {
|
|
3829
|
+
status: out.status,
|
|
3830
|
+
statusText: out.statusText,
|
|
3831
|
+
headers,
|
|
3832
|
+
}),
|
|
3833
|
+
);
|
|
3832
3834
|
}
|
|
3833
|
-
|
|
3835
|
+
// hub#643: every exit runs through the security-header step, which self-
|
|
3836
|
+
// gates on content-type — so a non-HTML pass-through (`out === res`, e.g. a
|
|
3837
|
+
// 502 proxy error or a JSON/asset body) is returned unchanged, preserving
|
|
3838
|
+
// the pre-existing behavior for those responses.
|
|
3839
|
+
return withProxySecurityHeaders(out);
|
|
3840
|
+
}
|
|
3841
|
+
|
|
3842
|
+
/**
|
|
3843
|
+
* hub#643 (Tier-1): stamp non-script security headers on proxied `text/html`
|
|
3844
|
+
* pages — the per-vault `/vault/<name>/*` proxy and the generic
|
|
3845
|
+
* services-mount `/<mount>/*` proxy both flow through `decorateWithChrome`,
|
|
3846
|
+
* so this is the single chokepoint that covers a module / surface page.
|
|
3847
|
+
*
|
|
3848
|
+
* - `X-Content-Type-Options: nosniff` — stops content-type sniffing.
|
|
3849
|
+
* - `Content-Security-Policy: frame-ancestors 'self'; object-src 'none';
|
|
3850
|
+
* base-uri 'self'` — clickjacking (external framing) + plugin + base-tag
|
|
3851
|
+
* hardening.
|
|
3852
|
+
*
|
|
3853
|
+
* Deliberately NO `script-src`: a strict script-src would white-screen
|
|
3854
|
+
* self-built GitHub-hosted surfaces (the primary surface story) and
|
|
3855
|
+
* inline-script module pages. The opt-in strict script-src CSP is Tier-2,
|
|
3856
|
+
* explicitly deferred (hub#643 stays open).
|
|
3857
|
+
*
|
|
3858
|
+
* Header-only: we never buffer the body. Only `text/html` responses are
|
|
3859
|
+
* decorated, so JSON / `.js` / CSS / image assets proxied through the same
|
|
3860
|
+
* path are left untouched. Existing headers are preserved (a fresh Headers
|
|
3861
|
+
* copy is mutated); we set (not append) so a re-decorated response can't
|
|
3862
|
+
* accumulate duplicates.
|
|
3863
|
+
*/
|
|
3864
|
+
function withProxySecurityHeaders(res: Response): Response {
|
|
3865
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
3866
|
+
if (!contentType.toLowerCase().includes("text/html")) return res;
|
|
3867
|
+
const headers = new Headers(res.headers);
|
|
3868
|
+
headers.set("x-content-type-options", "nosniff");
|
|
3869
|
+
headers.set(
|
|
3870
|
+
"content-security-policy",
|
|
3871
|
+
"frame-ancestors 'self'; object-src 'none'; base-uri 'self'",
|
|
3872
|
+
);
|
|
3873
|
+
return new Response(res.body, {
|
|
3874
|
+
status: res.status,
|
|
3875
|
+
statusText: res.statusText,
|
|
3876
|
+
headers,
|
|
3877
|
+
});
|
|
3834
3878
|
}
|
|
3835
3879
|
|
|
3836
3880
|
if (import.meta.main) {
|
package/src/managed-unit.ts
CHANGED
|
@@ -454,6 +454,25 @@ function installLaunchdUnit(opts: InstallManagedUnitOpts): ManagedUnitInstallRes
|
|
|
454
454
|
};
|
|
455
455
|
}
|
|
456
456
|
|
|
457
|
+
/**
|
|
458
|
+
* #528: is `loginctl` linger already enabled for `userName`? Best-effort probe:
|
|
459
|
+
* `loginctl show-user <user> --property=Linger` prints `Linger=yes` / `Linger=no`.
|
|
460
|
+
* Returns true ONLY on a clear `Linger=yes`; ANY ambiguity (non-zero exit, a
|
|
461
|
+
* throw, or unparseable output) returns false so the caller falls through to the
|
|
462
|
+
* enable attempt — we never SKIP enabling on a guess, only when linger is
|
|
463
|
+
* provably already on. (`show-user` of a user with no session can itself exit
|
|
464
|
+
* non-zero; treat that as "unknown → try to enable".)
|
|
465
|
+
*/
|
|
466
|
+
function lingerAlreadyOn(deps: ManagedUnitDeps, userName: string): boolean {
|
|
467
|
+
try {
|
|
468
|
+
const probe = deps.run(["loginctl", "show-user", userName, "--property=Linger"]);
|
|
469
|
+
if (probe.code !== 0) return false;
|
|
470
|
+
return /(^|\n)\s*Linger=yes\s*(\n|$)/i.test(probe.stdout);
|
|
471
|
+
} catch {
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
457
476
|
function installSystemdUnit(opts: InstallManagedUnitOpts): ManagedUnitInstallResult {
|
|
458
477
|
const { unit, deps, messages } = opts;
|
|
459
478
|
const start = opts.start ?? true;
|
|
@@ -490,10 +509,20 @@ function installSystemdUnit(opts: InstallManagedUnitOpts): ManagedUnitInstallRes
|
|
|
490
509
|
// systemctl but not loginctl would propagate the spawn error out and hard-fail
|
|
491
510
|
// the calling command. (Run on both start + install-without-start: linger is a
|
|
492
511
|
// boot-survival nicety independent of whether we start the unit now.)
|
|
512
|
+
//
|
|
513
|
+
// #528: pre-check the CURRENT linger state before trying to enable it. When
|
|
514
|
+
// linger is ALREADY on (the common re-install / re-migrate case on a box
|
|
515
|
+
// whose owner already enabled it), `enable-linger` is a no-op we don't need —
|
|
516
|
+
// and on some systemd builds it can return non-zero even though linger is
|
|
517
|
+
// genuinely on, raising a scary "couldn't enable lingering, your hub won't
|
|
518
|
+
// survive reboot" warning that is a FALSE ALARM. So: probe first; if linger
|
|
519
|
+
// is on, skip both the enable AND the warning. Only when linger is genuinely
|
|
520
|
+
// OFF and the enable attempt then fails do we warn. This is the single-owner
|
|
521
|
+
// self-host reboot-survival happy path — keep it quiet when it's already good.
|
|
493
522
|
if (!root && userName) {
|
|
494
523
|
if (deps.which("loginctl") === null) {
|
|
495
524
|
outMessages.push(messages.lingerWarning);
|
|
496
|
-
} else {
|
|
525
|
+
} else if (!lingerAlreadyOn(deps, userName)) {
|
|
497
526
|
try {
|
|
498
527
|
const linger = deps.run(["loginctl", "enable-linger", userName]);
|
|
499
528
|
if (linger.code !== 0) outMessages.push(messages.lingerWarning);
|
package/src/setup-wizard.ts
CHANGED
|
@@ -1592,14 +1592,33 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
|
|
|
1592
1592
|
// poll on the auth the wizard already carries.
|
|
1593
1593
|
const opId = url.searchParams.get("op");
|
|
1594
1594
|
if (opId) {
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1595
|
+
// hub#618: post-setup this JSON `?op=` surface is unauth-reachable —
|
|
1596
|
+
// `/admin/setup` is always lockout-exempt (the dispatcher's
|
|
1597
|
+
// `shouldGateForSetup` lets it through so a stale bookmark resolves), and
|
|
1598
|
+
// the snapshot is read BEFORE any session check. The leak is small (an
|
|
1599
|
+
// in-memory op's status + install-progress log lines, behind an
|
|
1600
|
+
// unguessable UUID), but it's still a post-setup admin surface, so gate
|
|
1601
|
+
// it once setup is COMPLETE. During setup (no admin yet) the surface
|
|
1602
|
+
// stays OPEN: the unauth CLI wizard (`parachute init`) AND the brand-new-
|
|
1603
|
+
// operator browser both poll this `?op=` snapshot mid-setup before any
|
|
1604
|
+
// session exists — gating then would break first-boot vault
|
|
1605
|
+
// provisioning. Loopback always passes (same on-box trust as the
|
|
1606
|
+
// `bootstrapToken` branch below); a valid session also passes.
|
|
1607
|
+
const setupComplete = state.hasAdmin && state.hasVault && state.hasExposeMode;
|
|
1608
|
+
const opSnapshotAllowed =
|
|
1609
|
+
!setupComplete ||
|
|
1610
|
+
deps.requestIsLoopback === true ||
|
|
1611
|
+
findActiveSession(deps.db, req) !== null;
|
|
1612
|
+
if (opSnapshotAllowed) {
|
|
1613
|
+
const op = deps.registry?.get(opId);
|
|
1614
|
+
if (op) {
|
|
1615
|
+
envelope.operation = {
|
|
1616
|
+
id: op.id,
|
|
1617
|
+
status: op.status,
|
|
1618
|
+
log: op.log,
|
|
1619
|
+
...(op.error !== undefined ? { error: op.error } : {}),
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1603
1622
|
}
|
|
1604
1623
|
}
|
|
1605
1624
|
// hub#576: hand the actual token to a LOOPBACK caller only. The on-box
|
package/src/supervisor.ts
CHANGED
|
@@ -38,6 +38,7 @@ import { spawnSync } from "node:child_process";
|
|
|
38
38
|
import {
|
|
39
39
|
MissingDependencyError,
|
|
40
40
|
type MissingDependencyWire,
|
|
41
|
+
NonExecutableError,
|
|
41
42
|
ensureExecutable,
|
|
42
43
|
rethrowIfMissing,
|
|
43
44
|
} from "@openparachute/depcheck";
|
|
@@ -263,6 +264,14 @@ export interface SupervisorOpts {
|
|
|
263
264
|
* Tests exercising the missing-binary branch inject `which: () => null`.
|
|
264
265
|
*/
|
|
265
266
|
readonly which?: (cmd: string) => string | null;
|
|
267
|
+
/**
|
|
268
|
+
* #634 secondary-probe seam for `ensureExecutable`: when `which` returns null,
|
|
269
|
+
* walk PATH IGNORING X_OK to detect a present-but-non-executable binary (a
|
|
270
|
+
* `bin` that lost its +x bit). Production leaves this undefined so depcheck's
|
|
271
|
+
* real PATH walk runs (gated to the real `Bun.which`); tests inject it to
|
|
272
|
+
* exercise the non-executable preflight branch through a stubbed `which`.
|
|
273
|
+
*/
|
|
274
|
+
readonly findNonExecutable?: (binary: string) => string | null;
|
|
266
275
|
/**
|
|
267
276
|
* Pre-spawn port-squatter detection (#580 item 4). Returns the pid holding a
|
|
268
277
|
* TCP LISTEN on the module's port, or undefined when the port is free /
|
|
@@ -427,8 +436,11 @@ export class LogRingBuffer {
|
|
|
427
436
|
* boot and threads it into the API handlers.
|
|
428
437
|
*/
|
|
429
438
|
export class Supervisor {
|
|
430
|
-
private readonly opts: Required<Omit<SupervisorOpts, "spawnFn">> & {
|
|
439
|
+
private readonly opts: Required<Omit<SupervisorOpts, "spawnFn" | "findNonExecutable">> & {
|
|
431
440
|
readonly spawnFn: SpawnFn;
|
|
441
|
+
// Optional #634 probe seam — undefined on the production path so depcheck's
|
|
442
|
+
// own real PATH walk runs (gated to the real `Bun.which`).
|
|
443
|
+
readonly findNonExecutable?: (binary: string) => string | null;
|
|
432
444
|
};
|
|
433
445
|
private readonly modules = new Map<string, ModuleEntry>();
|
|
434
446
|
|
|
@@ -459,6 +471,9 @@ export class Supervisor {
|
|
|
459
471
|
lateBindWatchMs: opts.lateBindWatchMs ?? DEFAULT_LATE_BIND_WATCH_MS,
|
|
460
472
|
lateBindPollMs: opts.lateBindPollMs ?? DEFAULT_LATE_BIND_POLL_MS,
|
|
461
473
|
which: opts.which ?? (isProductionPath ? Bun.which : () => "/stub/bin/preflight-skipped"),
|
|
474
|
+
// #634: undefined on production so depcheck's real PATH walk runs (its
|
|
475
|
+
// gate keys on the real `Bun.which`); tests inject it to drive the branch.
|
|
476
|
+
findNonExecutable: opts.findNonExecutable,
|
|
462
477
|
// Squatter detection (#580 item 4): real probes on the production path;
|
|
463
478
|
// the stub-spawner test path defaults to "no squatter / unknown owner" so
|
|
464
479
|
// fake-proc tests (which never hold a real port) aren't tripped. Tests
|
|
@@ -509,7 +524,9 @@ export class Supervisor {
|
|
|
509
524
|
const startBinary = req.cmd[0];
|
|
510
525
|
if (startBinary) {
|
|
511
526
|
try {
|
|
512
|
-
ensureExecutable
|
|
527
|
+
const ensureOpts: Parameters<typeof ensureExecutable>[1] = { which: this.opts.which };
|
|
528
|
+
if (this.opts.findNonExecutable) ensureOpts.findNonExecutable = this.opts.findNonExecutable;
|
|
529
|
+
ensureExecutable(startBinary, ensureOpts);
|
|
513
530
|
} catch (err) {
|
|
514
531
|
if (err instanceof MissingDependencyError) {
|
|
515
532
|
entry.state = {
|
|
@@ -520,6 +537,18 @@ export class Supervisor {
|
|
|
520
537
|
};
|
|
521
538
|
return entry.state;
|
|
522
539
|
}
|
|
540
|
+
// #634: the binary IS present but not executable (a `bin` that lost its
|
|
541
|
+
// +x bit). Record the actionable chmod hint instead of a misleading
|
|
542
|
+
// "not installed" — and never throw out of `start`.
|
|
543
|
+
if (err instanceof NonExecutableError) {
|
|
544
|
+
entry.state = {
|
|
545
|
+
...entry.state,
|
|
546
|
+
status: "crashed",
|
|
547
|
+
pid: undefined,
|
|
548
|
+
startError: nonExecutableStartError(err, this.opts.now),
|
|
549
|
+
};
|
|
550
|
+
return entry.state;
|
|
551
|
+
}
|
|
523
552
|
throw err;
|
|
524
553
|
}
|
|
525
554
|
}
|
|
@@ -1243,6 +1272,21 @@ function startErrorFromWire(wire: MissingDependencyWire, now: () => number): Mod
|
|
|
1243
1272
|
};
|
|
1244
1273
|
}
|
|
1245
1274
|
|
|
1275
|
+
/**
|
|
1276
|
+
* #634: map a `NonExecutableError` (binary present on PATH but not +x) onto the
|
|
1277
|
+
* `ModuleStartError` shape. `error_type: "non_executable"` so a UI can branch;
|
|
1278
|
+
* `error_description` is the formatted `chmod +x` block. No install card — the
|
|
1279
|
+
* fix is a permission flip, not a reinstall.
|
|
1280
|
+
*/
|
|
1281
|
+
function nonExecutableStartError(err: NonExecutableError, now: () => number): ModuleStartError {
|
|
1282
|
+
return {
|
|
1283
|
+
error_type: err.errorType,
|
|
1284
|
+
error_description: err.message,
|
|
1285
|
+
binary: err.binary,
|
|
1286
|
+
at: new Date(now()).toISOString(),
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1246
1290
|
/**
|
|
1247
1291
|
* Production group-aware kill (hub#88). Sends `signal` to the entire process
|
|
1248
1292
|
* group rooted at `pid` (the negative-pid syscall) so a wrapped startCmd's
|