residoo 0.23.1 → 0.24.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 +10 -7
- package/package.json +1 -1
- package/src/cli.js +158 -0
- package/src/dashboard.js +175 -0
package/README.md
CHANGED
|
@@ -53,8 +53,10 @@ again, since there's nothing to lose. `residoo guard` blocks an obviously
|
|
|
53
53
|
sensitive file read before it happens (100% recall, 0% false positives on
|
|
54
54
|
its own [scored 81-case corpus](bench/guard/RESULTS.md)) and, via a second
|
|
55
55
|
hook, a secret typed directly into the prompt itself — confirmed against
|
|
56
|
-
Claude Code's own docs to block before the model ever processes it.
|
|
57
|
-
|
|
56
|
+
Claude Code's own docs to block before the model ever processes it.
|
|
57
|
+
`residoo dashboard` is this same report as a local, read-only web page
|
|
58
|
+
instead of a terminal, opened in your browser on demand. All five are
|
|
59
|
+
covered in [docs/features.md](docs/features.md).
|
|
58
60
|
|
|
59
61
|
> [!NOTE]
|
|
60
62
|
> gitleaks and trufflehog scan **commits**. residoo scans the **conversation
|
|
@@ -148,10 +150,11 @@ while losing rows, then fixed in public against the classes it was losing
|
|
|
148
150
|
acknowledgement ledger. See [docs/architecture.md](docs/architecture.md#rotation-from-found-to-closed).
|
|
149
151
|
- `--project <dir>` scans a repository checkout instead of the machine,
|
|
150
152
|
for CI and pre-commit. See [docs/ci.md](docs/ci.md).
|
|
151
|
-
- `residoo watch` / `residoo mcp` / `residoo cred` / `residoo guard
|
|
152
|
-
continuous scanning, conversational queries,
|
|
153
|
-
without pasting,
|
|
154
|
-
prompt before either happens
|
|
153
|
+
- `residoo watch` / `residoo mcp` / `residoo cred` / `residoo guard` /
|
|
154
|
+
`residoo dashboard`: continuous scanning, conversational queries,
|
|
155
|
+
credential injection without pasting, blocking a sensitive file read or
|
|
156
|
+
a sensitive prompt before either happens, and a local read-only web UI.
|
|
157
|
+
See [docs/features.md](docs/features.md).
|
|
155
158
|
|
|
156
159
|
## What it does not do
|
|
157
160
|
|
|
@@ -243,7 +246,7 @@ residoo ack <fingerprint> [--note <text>] mark one finding rotated
|
|
|
243
246
|
residoo unseal <vault-dir> list a vault's contents
|
|
244
247
|
residoo unseal <vault-dir> --restore <n> --out <p> restore one file, hash-verified
|
|
245
248
|
|
|
246
|
-
residoo watch / mcp / cred / guard
|
|
249
|
+
residoo watch / mcp / cred / guard / dashboard see docs/features.md
|
|
247
250
|
```
|
|
248
251
|
|
|
249
252
|
The vault passphrase comes from `RESIDOO_PASSPHRASE` or a hidden interactive
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "residoo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Find secrets leaking through your AI coding agent's session history. Zero network calls in the scan path, zero dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "CloudRoam (https://cloudroam.io)",
|
package/src/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ const {
|
|
|
12
12
|
ROTATION_GUIDANCE, guidanceFor, loadAcks, loadDismissed, ackFinding, dismissFinding, renderRotation,
|
|
13
13
|
} = require("./rotation");
|
|
14
14
|
const { startWatch, isTailable } = require("./watch");
|
|
15
|
+
const { startDashboardServer, openBrowser } = require("./dashboard");
|
|
15
16
|
const { startMcpServer } = require("./mcp");
|
|
16
17
|
const { buildTools } = require("./mcpTools");
|
|
17
18
|
const { runGuard: runGuardEngine, buildHookConfig } = require("./guard");
|
|
@@ -218,6 +219,30 @@ Watch:
|
|
|
218
219
|
Ctrl+C stops cleanly and prints a session summary (skipped with --json,
|
|
219
220
|
where the same information is one final NDJSON event).
|
|
220
221
|
|
|
222
|
+
Dashboard:
|
|
223
|
+
residoo dashboard the exact "scan --html" report, served live at
|
|
224
|
+
a local URL instead of written to disk, and
|
|
225
|
+
opened in your browser automatically. Read-only:
|
|
226
|
+
this only visualizes what a scan already shows
|
|
227
|
+
you, it never acks/dismisses/seals anything.
|
|
228
|
+
Every page reload re-scans from scratch -- there
|
|
229
|
+
is no separate refresh action, and nothing here
|
|
230
|
+
is cached across requests.
|
|
231
|
+
--port <n> use a specific port instead of letting the OS
|
|
232
|
+
assign one
|
|
233
|
+
--no-open print the URL instead of opening a browser tab
|
|
234
|
+
automatically
|
|
235
|
+
--include-noisy, --include-suppressed, --include-pii,
|
|
236
|
+
--include-injection, --no-integrity, --project [dir]
|
|
237
|
+
same meaning as scan
|
|
238
|
+
Bound to 127.0.0.1 only, never your network, and every request must
|
|
239
|
+
present a random token generated fresh for this run (printed as part
|
|
240
|
+
of the URL) -- the same local-security model Jupyter Notebook uses.
|
|
241
|
+
The server also rejects any request whose Host header isn't
|
|
242
|
+
127.0.0.1/localhost, closing the DNS-rebinding class this project's
|
|
243
|
+
own cve.js already catalogued two real MCP SDK CVEs for. Ctrl+C stops
|
|
244
|
+
it; nothing is left running in the background afterward.
|
|
245
|
+
|
|
221
246
|
MCP:
|
|
222
247
|
residoo mcp run residoo as an MCP server over stdio, so
|
|
223
248
|
Claude Code (or any other MCP client) can query
|
|
@@ -853,6 +878,138 @@ async function runWatch(args) {
|
|
|
853
878
|
return 0;
|
|
854
879
|
}
|
|
855
880
|
|
|
881
|
+
/**
|
|
882
|
+
* `residoo dashboard`: the exact `--html` report, served live over a local,
|
|
883
|
+
* read-only HTTP server instead of written to disk -- see dashboard.js's
|
|
884
|
+
* own header for the DNS-rebinding defense, the per-run token, and why
|
|
885
|
+
* this is strictly more locked-down than the static file it's built from.
|
|
886
|
+
* On-demand only (this session's own explicit scope decision, matching
|
|
887
|
+
* `watch`'s "you run a command when you want it" model, not an always-on
|
|
888
|
+
* background service): the server runs until Ctrl-C, same shutdown
|
|
889
|
+
* contract as `runWatch` above. Every request re-scans from scratch, so
|
|
890
|
+
* reloading the page in the browser is how you refresh it -- no separate
|
|
891
|
+
* "refresh" action, no cached-at-startup snapshot going stale.
|
|
892
|
+
*/
|
|
893
|
+
async function runDashboard(args) {
|
|
894
|
+
const includeNoisy = args.includes("--include-noisy");
|
|
895
|
+
const includeSuppressed = args.includes("--include-suppressed");
|
|
896
|
+
const includePii = args.includes("--include-pii");
|
|
897
|
+
const includeInjection = args.includes("--include-injection");
|
|
898
|
+
const wantsIntegrity = !args.includes("--no-integrity");
|
|
899
|
+
const noBrowser = args.includes("--no-open");
|
|
900
|
+
|
|
901
|
+
let port = 0; // 0: OS assigns an ephemeral port
|
|
902
|
+
const portArg = argValue(args, "--port");
|
|
903
|
+
if (portArg !== null) {
|
|
904
|
+
const n = Number(portArg);
|
|
905
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1 || n > 65535) {
|
|
906
|
+
process.stderr.write(`--port must be an integer between 1 and 65535; got "${portArg}".\n`);
|
|
907
|
+
return 2;
|
|
908
|
+
}
|
|
909
|
+
port = n;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
let projectRoot = null;
|
|
913
|
+
const projectIdx = args.indexOf("--project");
|
|
914
|
+
if (projectIdx >= 0) {
|
|
915
|
+
const next = args[projectIdx + 1];
|
|
916
|
+
projectRoot = path.resolve(next && !next.startsWith("--") ? next : ".");
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
let sources;
|
|
920
|
+
if (projectRoot) {
|
|
921
|
+
const projectArtifacts = require("./sources/project-artifacts");
|
|
922
|
+
const src = projectArtifacts.withRoot(projectRoot);
|
|
923
|
+
if (!src.available()) {
|
|
924
|
+
process.stderr.write(`--project: "${projectRoot}" is not a readable directory.\n`);
|
|
925
|
+
return 2;
|
|
926
|
+
}
|
|
927
|
+
sources = [src];
|
|
928
|
+
} else {
|
|
929
|
+
sources = availableSources();
|
|
930
|
+
if (sources.length === 0) {
|
|
931
|
+
process.stderr.write(
|
|
932
|
+
"No known transcript sources found on this machine; nothing to show.\n" +
|
|
933
|
+
`Checked: ${sourceStatusList()}.\n`
|
|
934
|
+
);
|
|
935
|
+
return 0;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Same crash-degrades-to-a-visible-warning contract runIntegrity() in
|
|
940
|
+
// the scan path above already has, duplicated rather than shared: this
|
|
941
|
+
// function does its own arg parsing and orchestration independently of
|
|
942
|
+
// the scan-command code path (the same "one small self-contained CLI
|
|
943
|
+
// command function" split runWatch/runMcp already establish), rather
|
|
944
|
+
// than risking a regression in the far more heavily depended-on scan
|
|
945
|
+
// path by factoring it out.
|
|
946
|
+
const runIntegrityOnce = () => {
|
|
947
|
+
try {
|
|
948
|
+
const integ = checkIntegrity(projectRoot ? { home: projectRoot, cwd: projectRoot, projectMode: true } : {});
|
|
949
|
+
if (projectRoot) {
|
|
950
|
+
integ.scopeNote = "Integrity checks cover the --project directory only (paths shown relative to it); this machine's home-level agent configs were not examined on this run.";
|
|
951
|
+
}
|
|
952
|
+
return integ;
|
|
953
|
+
} catch (e) {
|
|
954
|
+
const why = String((e && e.message) || e).replace(/[\x00-\x1f\x7f]/g, "").slice(0, 200);
|
|
955
|
+
return {
|
|
956
|
+
findings: [{
|
|
957
|
+
severity: "warn", kind: "integrity-crashed", file: "(integrity checker)",
|
|
958
|
+
detail: `integrity checks crashed (${why}). Config locations are UNVERIFIED, not clean; the secret-scan results are unaffected`,
|
|
959
|
+
}],
|
|
960
|
+
filesChecked: [],
|
|
961
|
+
scopeNote: "Integrity checks did not complete on this run.",
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
// Called fresh on every HTTP request (see dashboard.js) -- never caches
|
|
967
|
+
// across requests, so a reload always reflects the current machine
|
|
968
|
+
// state, not a snapshot from when the server started.
|
|
969
|
+
const gatherData = async () => {
|
|
970
|
+
const acks = loadAcks();
|
|
971
|
+
const dismissed = loadDismissed();
|
|
972
|
+
const integrity = wantsIntegrity ? runIntegrityOnce() : null;
|
|
973
|
+
const result = await scan({
|
|
974
|
+
sources, includeNoisy, includeSuppressed, includePii, includeInjection,
|
|
975
|
+
verify: false, noColor: false,
|
|
976
|
+
});
|
|
977
|
+
const rotation = renderRotation(result.findings, acks, dismissed);
|
|
978
|
+
return { result, integrity, rotation };
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
let started;
|
|
982
|
+
try {
|
|
983
|
+
started = await startDashboardServer({ port, gatherData });
|
|
984
|
+
} catch (err) {
|
|
985
|
+
process.stderr.write(`residoo dashboard: could not start the local server: ${(err && err.message) || err}\n`);
|
|
986
|
+
return 1;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
process.stderr.write(
|
|
990
|
+
`residoo dashboard: ${started.url}\n` +
|
|
991
|
+
"Read-only, localhost only -- nothing here leaves this machine, and nothing here can change anything on it.\n" +
|
|
992
|
+
"Every page reload re-scans from scratch. Press Ctrl-C to stop.\n"
|
|
993
|
+
);
|
|
994
|
+
if (!noBrowser) openBrowser(started.url);
|
|
995
|
+
|
|
996
|
+
let signalled = false;
|
|
997
|
+
return new Promise((resolve) => {
|
|
998
|
+
const onSignal = () => {
|
|
999
|
+
if (signalled) return;
|
|
1000
|
+
signalled = true;
|
|
1001
|
+
started.stop().then(() => {
|
|
1002
|
+
process.removeListener("SIGINT", onSignal);
|
|
1003
|
+
process.removeListener("SIGTERM", onSignal);
|
|
1004
|
+
process.stderr.write("residoo dashboard: stopped.\n");
|
|
1005
|
+
resolve(0);
|
|
1006
|
+
});
|
|
1007
|
+
};
|
|
1008
|
+
process.once("SIGINT", onSignal);
|
|
1009
|
+
process.once("SIGTERM", onSignal);
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
|
|
856
1013
|
/**
|
|
857
1014
|
* `residoo guard --print-config`: print the merged .claude/settings.json
|
|
858
1015
|
* a user would need to register all three guard hooks, without ever
|
|
@@ -1084,6 +1241,7 @@ async function main(argv) {
|
|
|
1084
1241
|
if (cmd === "ack") return runAck(args);
|
|
1085
1242
|
if (cmd === "dismiss") return runDismiss(args);
|
|
1086
1243
|
if (cmd === "watch") return runWatch(args);
|
|
1244
|
+
if (cmd === "dashboard") return runDashboard(args);
|
|
1087
1245
|
if (cmd === "mcp") return runMcp(args);
|
|
1088
1246
|
if (cmd === "cred") return runCred(args);
|
|
1089
1247
|
if (cmd === "guard") {
|
package/src/dashboard.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const http = require("http");
|
|
4
|
+
const crypto = require("crypto");
|
|
5
|
+
// Not destructured: `cp.spawn(...)` at the call site, matching notify.js's
|
|
6
|
+
// own documented reasoning -- a test can monkey-patch
|
|
7
|
+
// `require("child_process").spawn` on the shared module object.
|
|
8
|
+
const cp = require("child_process");
|
|
9
|
+
const { renderHtml } = require("./report");
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `residoo dashboard`'s engine: a local, read-only HTTP server serving the
|
|
13
|
+
* exact same self-contained page `residoo scan --html` writes to disk
|
|
14
|
+
* (`renderHtml`, unmodified -- see report.js), except regenerated fresh on
|
|
15
|
+
* every request instead of written once. No new rendering code, no new
|
|
16
|
+
* data model: this is `--html`'s own output, served live instead of saved.
|
|
17
|
+
*
|
|
18
|
+
* SECURITY, taken as seriously as this project takes everything else it
|
|
19
|
+
* ships -- this is the first HTTP server residoo has ever run, and this
|
|
20
|
+
* project spent the last two releases cataloguing exactly what goes wrong
|
|
21
|
+
* when a local MCP server gets this wrong (see cve.js: CVE-2025-66414 and
|
|
22
|
+
* CVE-2025-66416, both "DNS rebinding protection not enabled by default"
|
|
23
|
+
* in the MCP TypeScript/Python SDKs). The same lesson, applied here:
|
|
24
|
+
*
|
|
25
|
+
* - **DNS rebinding**: binding to 127.0.0.1 alone does NOT stop a
|
|
26
|
+
* malicious webpage's browser tab from reaching this server -- an
|
|
27
|
+
* attacker-controlled domain can be made to resolve to 127.0.0.1
|
|
28
|
+
* after an initial same-origin check passes, letting page JavaScript
|
|
29
|
+
* read the response as if it were same-origin. The actual fix (the
|
|
30
|
+
* one those two CVEs shipped) is validating the `Host` header
|
|
31
|
+
* server-side on every request: rejected outright unless it's
|
|
32
|
+
* exactly `127.0.0.1:<this server's port>` or `localhost:<port>`.
|
|
33
|
+
* - **A random per-run token**, required on every request (checked
|
|
34
|
+
* AFTER the Host check, so a wrong-Host request never even reaches
|
|
35
|
+
* token comparison) -- the same well-established local-security model
|
|
36
|
+
* Jupyter Notebook has used for years. Defense in depth beyond DNS
|
|
37
|
+
* rebinding: it also stops another local process or another user
|
|
38
|
+
* account on a shared machine from stumbling onto the port and
|
|
39
|
+
* reading a scan (already-redacted, but still real file paths and
|
|
40
|
+
* rotation status) without ever being invited to.
|
|
41
|
+
* - **Response headers** a static file has no mechanism to carry at
|
|
42
|
+
* all: `X-Frame-Options: DENY` and `frame-ancestors 'none'` (no
|
|
43
|
+
* clickjacking-by-iframe), `X-Content-Type-Options: nosniff`, a CSP
|
|
44
|
+
* that blocks any REMOTE resource load (`default-src 'self'`) while
|
|
45
|
+
* still allowing the page's own inline `<style>`/`<script>` --
|
|
46
|
+
* which is server-generated, not reflecting unescaped request data
|
|
47
|
+
* (every finding field already goes through `escapeHtml()` in
|
|
48
|
+
* report.js). This makes the dashboard strictly more defended than
|
|
49
|
+
* the plain `--html` file it's built from, which ships with none of
|
|
50
|
+
* these headers because a file on disk has nowhere to put them.
|
|
51
|
+
* - **No write path at all**: every request is a GET of `/`; nothing
|
|
52
|
+
* here can mutate state, matching this release's own scoped-down
|
|
53
|
+
* "visualize existing data, take no actions" decision. A future
|
|
54
|
+
* action-taking dashboard (ack/dismiss/seal from the UI) is a
|
|
55
|
+
* separate, later decision -- see README/features.md.
|
|
56
|
+
* - **No CORS headers are ever sent.** The browser's own same-origin
|
|
57
|
+
* policy already blocks a different-origin page's JavaScript from
|
|
58
|
+
* reading this response; adding a permissive
|
|
59
|
+
* `Access-Control-Allow-Origin` would undo that for no benefit here.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Cross-platform "open this URL in the default browser," best-effort only
|
|
64
|
+
* -- the terminal always prints the URL too, so a failure here just means
|
|
65
|
+
* the user clicks/copies it instead of it opening automatically. Never
|
|
66
|
+
* throws, matching notify.js's own "a UI convenience must never affect
|
|
67
|
+
* the caller" contract.
|
|
68
|
+
*/
|
|
69
|
+
function openBrowser(url) {
|
|
70
|
+
try {
|
|
71
|
+
if (process.platform === "darwin") {
|
|
72
|
+
const child = cp.spawn("open", [url], { stdio: "ignore" });
|
|
73
|
+
child.on("error", () => {});
|
|
74
|
+
child.unref();
|
|
75
|
+
} else if (process.platform === "win32") {
|
|
76
|
+
// cmd's built-in `start` treats its first quoted argument as the
|
|
77
|
+
// window TITLE, not the target -- the empty "" is required so `url`
|
|
78
|
+
// is parsed as the target instead.
|
|
79
|
+
const child = cp.spawn("cmd.exe", ["/c", "start", "", url], { stdio: "ignore", windowsHide: true });
|
|
80
|
+
child.on("error", () => {});
|
|
81
|
+
child.unref();
|
|
82
|
+
} else {
|
|
83
|
+
const child = cp.spawn("xdg-open", [url], { stdio: "ignore" });
|
|
84
|
+
child.on("error", () => {});
|
|
85
|
+
child.unref();
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// Best-effort only -- see function docstring.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const CSP =
|
|
93
|
+
"default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
|
|
94
|
+
"frame-ancestors 'none'; connect-src 'self'; img-src 'self' data:";
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Start the dashboard's HTTP server. `gatherData()` is an async function
|
|
98
|
+
* returning `{ result, integrity, rotation }` (the same three arguments
|
|
99
|
+
* `renderHtml` already takes) -- called fresh on every request, so
|
|
100
|
+
* reloading the page in the browser re-scans rather than showing a
|
|
101
|
+
* cached-at-startup snapshot. `port` 0 (the default) lets the OS assign
|
|
102
|
+
* an ephemeral port; a caller-chosen port is used verbatim.
|
|
103
|
+
*
|
|
104
|
+
* Resolves to `{ url, stop }` once the server is actually listening --
|
|
105
|
+
* `url` already carries the per-run token as a query parameter, ready to
|
|
106
|
+
* open directly. `stop()` closes the server and resolves once fully shut
|
|
107
|
+
* down (never leaves a lingering listener behind on Ctrl-C).
|
|
108
|
+
*/
|
|
109
|
+
function startDashboardServer({ port = 0, gatherData }) {
|
|
110
|
+
const token = crypto.randomBytes(16).toString("hex");
|
|
111
|
+
|
|
112
|
+
const server = http.createServer(async (req, res) => {
|
|
113
|
+
try {
|
|
114
|
+
const hostHeader = String(req.headers.host || "");
|
|
115
|
+
const hostname = hostHeader.split(":")[0];
|
|
116
|
+
if (hostname !== "127.0.0.1" && hostname !== "localhost") {
|
|
117
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
118
|
+
res.end("Forbidden: unexpected Host header");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let url;
|
|
123
|
+
try { url = new URL(req.url, `http://${hostHeader || "localhost"}`); }
|
|
124
|
+
catch { res.writeHead(400, { "Content-Type": "text/plain" }); res.end("Bad request"); return; }
|
|
125
|
+
|
|
126
|
+
if (url.searchParams.get("token") !== token) {
|
|
127
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
128
|
+
res.end("Forbidden: missing or incorrect token");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (req.method !== "GET" || url.pathname !== "/") {
|
|
133
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
134
|
+
res.end("Not found");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const { result, integrity, rotation } = await gatherData();
|
|
139
|
+
const html = renderHtml(result, integrity, rotation);
|
|
140
|
+
res.writeHead(200, {
|
|
141
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
142
|
+
"X-Frame-Options": "DENY",
|
|
143
|
+
"X-Content-Type-Options": "nosniff",
|
|
144
|
+
"Content-Security-Policy": CSP,
|
|
145
|
+
// Every response is a fresh scan; a cached stale one defeats the
|
|
146
|
+
// whole point of reloading to see current state.
|
|
147
|
+
"Cache-Control": "no-store",
|
|
148
|
+
});
|
|
149
|
+
res.end(html);
|
|
150
|
+
} catch {
|
|
151
|
+
// A scan/render failure must not crash the server or hang the
|
|
152
|
+
// request -- degrade to a visible 500, the same "never a silent
|
|
153
|
+
// all-clear, never a silent crash" discipline every other part of
|
|
154
|
+
// this project already holds to.
|
|
155
|
+
try {
|
|
156
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
157
|
+
res.end("residoo dashboard: the scan failed while rendering this page. Check the terminal residoo is running in for details.");
|
|
158
|
+
} catch { /* response already sent/destroyed */ }
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return new Promise((resolve, reject) => {
|
|
163
|
+
server.once("error", reject);
|
|
164
|
+
server.listen(port, "127.0.0.1", () => {
|
|
165
|
+
const actualPort = server.address().port;
|
|
166
|
+
const url = `http://127.0.0.1:${actualPort}/?token=${token}`;
|
|
167
|
+
resolve({
|
|
168
|
+
url,
|
|
169
|
+
stop: () => new Promise((r) => server.close(() => r())),
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = { startDashboardServer, openBrowser };
|