faberun 0.3.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/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/faberun.mjs +25 -0
- package/integrations/claude-code/statusline-bench.sh +42 -0
- package/integrations/claude-code/statusline.sh +80 -0
- package/package.json +33 -0
- package/skills/faberun/SKILL.md +24 -0
- package/skills/faberun/references/contract.md +380 -0
- package/skills/faberun/references/engineering.md +29 -0
- package/skills/faberun/references/handoffs.md +26 -0
- package/skills/faberun/references/operations.md +184 -0
- package/skills/faberun/references/rules.md +35 -0
- package/skills/faberun/references/workflow.md +23 -0
- package/skills/init-agentkit/SKILL.md +108 -0
- package/skills/init-agentkit/scripts/install-agentkit.sh +127 -0
- package/skills/init-agentkit/templates/.claude/commands/create-adr.md +44 -0
- package/skills/init-agentkit/templates/.github/workflows/quality.yml +43 -0
- package/skills/init-agentkit/templates/.sentrux/baseline.json +9 -0
- package/skills/init-agentkit/templates/.sentrux/rules.toml +21 -0
- package/skills/init-agentkit/templates/AGENTS.md +110 -0
- package/skills/init-agentkit/templates/docs/ABSTRACTIONS.md +30 -0
- package/skills/init-agentkit/templates/docs/ARCHITECTURE.md +31 -0
- package/skills/init-agentkit/templates/docs/GETTING-STARTED.md +44 -0
- package/skills/init-agentkit/templates/docs/VISION.md +33 -0
- package/skills/init-agentkit/templates/docs/adr/0001-record-architecture-decisions.md +36 -0
- package/skills/init-agentkit/templates/docs/adr/0002-root-managed-ai-guidance.md +37 -0
- package/skills/init-agentkit/templates/docs/adr/0003-sentrux-structural-quality-gates.md +49 -0
- package/skills/init-agentkit/templates/docs/adr/README.md +52 -0
- package/skills/init-agentkit/templates/docs/sentrux.md +66 -0
- package/skills/init-agentkit/templates/githooks/commit-msg +22 -0
- package/skills/init-agentkit/templates/githooks/pre-commit +32 -0
- package/src/campaign/brief.mjs +394 -0
- package/src/campaign/chain.mjs +555 -0
- package/src/campaign/handoff.mjs +516 -0
- package/src/campaign/index.mjs +300 -0
- package/src/campaign/journal.mjs +347 -0
- package/src/campaign/layout.mjs +51 -0
- package/src/campaign/metrics-evals.mjs +25 -0
- package/src/campaign/metrics.mjs +517 -0
- package/src/campaign/projection.mjs +250 -0
- package/src/campaign/record.mjs +102 -0
- package/src/campaign/unpark.mjs +56 -0
- package/src/cli/brand.mjs +205 -0
- package/src/cli/campaign.mjs +730 -0
- package/src/cli/contract.mjs +67 -0
- package/src/cli/init.mjs +170 -0
- package/src/cli/launch.mjs +239 -0
- package/src/cli/seat.mjs +139 -0
- package/src/cli/setup.mjs +294 -0
- package/src/cli/skills.mjs +105 -0
- package/src/cli/update.mjs +216 -0
- package/src/cli.mjs +525 -0
- package/src/contract/articles.mjs +12 -0
- package/src/contract/assert.mjs +162 -0
- package/src/contract/definition-of-done.mjs +97 -0
- package/src/contract/final-verification.mjs +96 -0
- package/src/contract/index.mjs +641 -0
- package/src/contract/judge-envelope.mjs +25 -0
- package/src/contract/review-modes.mjs +151 -0
- package/src/contract/runtime.mjs +204 -0
- package/src/contract/schema-version.mjs +25 -0
- package/src/contract/scope-findings.mjs +77 -0
- package/src/contract/snapshot.mjs +639 -0
- package/src/contract/task-packet.mjs +495 -0
- package/src/contract/untrusted.mjs +75 -0
- package/src/contract/verification.mjs +185 -0
- package/src/contract/worker-result.mjs +138 -0
- package/src/engine/assignment.mjs +63 -0
- package/src/engine/backoff.mjs +492 -0
- package/src/engine/bulk-read.mjs +361 -0
- package/src/engine/cancel.mjs +177 -0
- package/src/engine/detach.mjs +101 -0
- package/src/engine/dispatch.mjs +752 -0
- package/src/engine/failover.mjs +192 -0
- package/src/engine/gate.mjs +183 -0
- package/src/engine/judge-gate.mjs +517 -0
- package/src/engine/lifecycle.mjs +772 -0
- package/src/engine/live-preflight.mjs +299 -0
- package/src/engine/mutation.mjs +146 -0
- package/src/engine/notify-queue.mjs +327 -0
- package/src/engine/process-identity.mjs +72 -0
- package/src/engine/process.mjs +774 -0
- package/src/engine/prompts.mjs +289 -0
- package/src/engine/recover.mjs +300 -0
- package/src/engine/result-file.mjs +222 -0
- package/src/engine/resume.mjs +635 -0
- package/src/engine/retry.mjs +334 -0
- package/src/engine/review.mjs +228 -0
- package/src/engine/run-command.mjs +287 -0
- package/src/engine/run-identity.mjs +411 -0
- package/src/engine/runtime-discovery.mjs +235 -0
- package/src/engine/scheduler.mjs +526 -0
- package/src/engine/scope.mjs +378 -0
- package/src/engine/settle.mjs +207 -0
- package/src/engine/state.mjs +148 -0
- package/src/engine/supervise.mjs +713 -0
- package/src/engine/verify.mjs +167 -0
- package/src/harnesses/agy/index.mjs +62 -0
- package/src/harnesses/catalogue.mjs +509 -0
- package/src/harnesses/claude/index.mjs +90 -0
- package/src/harnesses/codex/index.mjs +87 -0
- package/src/harnesses/dsh/closed-packet.patch.yml +42 -0
- package/src/harnesses/dsh/index.mjs +210 -0
- package/src/harnesses/dsh/runner.mjs +259 -0
- package/src/harnesses/exec-jsonl/index.mjs +788 -0
- package/src/harnesses/index.mjs +508 -0
- package/src/harnesses/protocol.mjs +531 -0
- package/src/harnesses/replay/bin.mjs +386 -0
- package/src/harnesses/replay/index.mjs +238 -0
- package/src/harnesses/zcode/index.mjs +276 -0
- package/src/host/config.mjs +87 -0
- package/src/host/home.mjs +149 -0
- package/src/host/package.mjs +23 -0
- package/src/host/preflight.mjs +520 -0
- package/src/host/tool-policy-decisions.mjs +341 -0
- package/src/host/tool-policy-hook.mjs +270 -0
- package/src/notify/index.mjs +359 -0
- package/src/notify/os-macos.mjs +81 -0
- package/src/repo/declared-paths.mjs +220 -0
- package/src/repo/integrate.mjs +546 -0
- package/src/repo/scope-closure.mjs +665 -0
- package/src/repo/signal-block.mjs +16 -0
- package/src/repo/signal.mjs +222 -0
- package/src/repo/source-identity.mjs +295 -0
- package/src/repo/workspace.mjs +557 -0
- package/src/repo/worktree.mjs +352 -0
- package/src/report/final.mjs +200 -0
- package/src/report/metrics-report.mjs +99 -0
- package/src/report/next.mjs +383 -0
- package/src/report/render.mjs +716 -0
- package/src/run/disk-gc.mjs +251 -0
- package/src/run/lock.mjs +329 -0
- package/src/run/node-store.mjs +62 -0
- package/src/run/operations.mjs +286 -0
- package/src/run/store.mjs +187 -0
- package/src/run/usage.mjs +337 -0
- package/src/seat/harnesses.mjs +83 -0
- package/src/seat/index.mjs +239 -0
- package/src/seat/tmux.mjs +208 -0
- package/src/util.mjs +0 -0
- package/src/web/api.mjs +371 -0
- package/src/web/boundary.mjs +88 -0
- package/src/web/index.html +299 -0
- package/src/web/server.mjs +552 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tmux half of the operator seat: one session (`faberun-seat`) with
|
|
3
|
+
* one window per open campaign.
|
|
4
|
+
*
|
|
5
|
+
* tmux is optional by design (ADR-0033): the run engine never binds to it, so
|
|
6
|
+
* every function here returns an explicit result and an absent binary becomes
|
|
7
|
+
* `{available: false}` instead of a throw. That is what keeps every campaign
|
|
8
|
+
* command working on a host without tmux, where only reattaching is lost.
|
|
9
|
+
*/
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { errorCode, exitStatus } from "../util.mjs";
|
|
12
|
+
|
|
13
|
+
/** The single seat session every campaign window lives in. */
|
|
14
|
+
export const SEAT_SESSION = "faberun-seat";
|
|
15
|
+
|
|
16
|
+
/** Window option recording which harness launched the window. */
|
|
17
|
+
const HARNESS_OPTION = "@faberun-harness";
|
|
18
|
+
|
|
19
|
+
/** `list-windows` format: name, index, harness option, pane command, tab-separated. */
|
|
20
|
+
const WINDOW_FORMAT = "#{window_name}\t#{window_index}\t#{@faberun-harness}\t#{pane_current_command}";
|
|
21
|
+
|
|
22
|
+
/** Measured 2026-09-12: every seat tmux call here returns in well under a second. */
|
|
23
|
+
const TMUX_TIMEOUT_MS = 10_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {{available: boolean, ok: boolean, status: number|null, stdout: string, stderr: string, reason: string|null}} TmuxResult
|
|
27
|
+
* @typedef {{window: string, index: number|null, harness: string|null, command: string|null}} SeatWindow
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string[]} args
|
|
32
|
+
* @param {{cwd?: string}} [options]
|
|
33
|
+
* @returns {TmuxResult}
|
|
34
|
+
*/
|
|
35
|
+
function runTmux(args, options = {}) {
|
|
36
|
+
try {
|
|
37
|
+
const stdout = execFileSync("tmux", args, {
|
|
38
|
+
cwd: options.cwd,
|
|
39
|
+
encoding: "utf8",
|
|
40
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
41
|
+
timeout: TMUX_TIMEOUT_MS,
|
|
42
|
+
});
|
|
43
|
+
return { available: true, ok: true, status: 0, stdout: String(stdout), stderr: "", reason: null };
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (errorCode(error) === "ENOENT") {
|
|
46
|
+
return { available: false, ok: false, status: null, stdout: "", stderr: "", reason: "tmux_unavailable" };
|
|
47
|
+
}
|
|
48
|
+
const failure = spawnFailure(error);
|
|
49
|
+
return {
|
|
50
|
+
available: true,
|
|
51
|
+
ok: false,
|
|
52
|
+
status: exitStatus(error) ?? null,
|
|
53
|
+
stdout: failure.stdout,
|
|
54
|
+
stderr: failure.stderr,
|
|
55
|
+
reason: "tmux_command_failed",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @param {unknown} error @returns {{stdout: string, stderr: string}} */
|
|
61
|
+
function spawnFailure(error) {
|
|
62
|
+
const record = /** @type {{stdout?: string|Buffer, stderr?: string|Buffer}} */ (error);
|
|
63
|
+
return { stdout: textOf(record.stdout), stderr: textOf(record.stderr) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @param {unknown} value @returns {string} */
|
|
67
|
+
function textOf(value) {
|
|
68
|
+
if (value === undefined || value === null) return "";
|
|
69
|
+
return Buffer.isBuffer(value) ? value.toString("utf8") : String(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {string} session
|
|
74
|
+
* @returns {{available: boolean, exists: boolean}}
|
|
75
|
+
*/
|
|
76
|
+
function sessionExists(session) {
|
|
77
|
+
const result = runTmux(["has-session", "-t", session]);
|
|
78
|
+
if (!result.available) return { available: false, exists: false };
|
|
79
|
+
return { available: true, exists: result.ok };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Create the seat window for one campaign, creating the session on the first
|
|
84
|
+
* window. The harness argv is quoted into the single shell-command tmux takes;
|
|
85
|
+
* the harness name is recorded as a window option so status can name it even
|
|
86
|
+
* after the pane's foreground command changes.
|
|
87
|
+
*
|
|
88
|
+
* @param {{session: string, window: string, argv: readonly string[], harness: string, cwd: string}} options
|
|
89
|
+
* @returns {{available: boolean, ok: boolean, created: boolean, session: string, window: string, command: string|null, reason: string|null, stderr: string}}
|
|
90
|
+
*/
|
|
91
|
+
export function createSeatWindow(options) {
|
|
92
|
+
const existing = sessionExists(options.session);
|
|
93
|
+
if (!existing.available) {
|
|
94
|
+
return { available: false, ok: false, created: false, session: options.session, window: options.window, command: null, reason: "tmux_unavailable", stderr: "" };
|
|
95
|
+
}
|
|
96
|
+
const command = shellCommand(options.argv);
|
|
97
|
+
const args = existing.exists
|
|
98
|
+
? ["new-window", "-t", options.session, "-n", options.window, "-c", options.cwd, command]
|
|
99
|
+
: ["new-session", "-d", "-s", options.session, "-n", options.window, "-c", options.cwd, command];
|
|
100
|
+
const result = runTmux(args);
|
|
101
|
+
if (!result.ok) {
|
|
102
|
+
return { available: result.available, ok: false, created: false, session: options.session, window: options.window, command, reason: result.reason, stderr: result.stderr };
|
|
103
|
+
}
|
|
104
|
+
runTmux(["set-option", "-w", "-t", `${options.session}:${options.window}`, HARNESS_OPTION, options.harness]);
|
|
105
|
+
return { available: true, ok: true, created: true, session: options.session, window: options.window, command, reason: null, stderr: "" };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @param {readonly string[]} argv @returns {string} */
|
|
109
|
+
function shellCommand(argv) {
|
|
110
|
+
return argv
|
|
111
|
+
.map((argument) => (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(argument) ? argument : `'${argument.replace(/'/gu, "'\\''")}'`))
|
|
112
|
+
.join(" ");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Replace the process in an existing seat window with a new harness, keeping
|
|
117
|
+
* the window, its name and its index. `respawn-window -k` is the kill and the
|
|
118
|
+
* launch in one tmux verb; the caller materializes the brief before calling,
|
|
119
|
+
* so a failed brief never tears down a working pane.
|
|
120
|
+
*
|
|
121
|
+
* @param {{session: string, window: string, argv: readonly string[], harness: string, cwd: string}} options
|
|
122
|
+
* @returns {{available: boolean, ok: boolean, respawned: boolean, session: string, window: string, command: string|null, reason: string|null, stderr: string}}
|
|
123
|
+
*/
|
|
124
|
+
export function respawnSeatWindow(options) {
|
|
125
|
+
const command = shellCommand(options.argv);
|
|
126
|
+
const result = runTmux(["respawn-window", "-k", "-t", `${options.session}:${options.window}`, "-c", options.cwd, command]);
|
|
127
|
+
if (!result.ok) {
|
|
128
|
+
return { available: result.available, ok: false, respawned: false, session: options.session, window: options.window, command, reason: result.reason, stderr: result.stderr };
|
|
129
|
+
}
|
|
130
|
+
runTmux(["set-option", "-w", "-t", `${options.session}:${options.window}`, HARNESS_OPTION, options.harness]);
|
|
131
|
+
return { available: true, ok: true, respawned: true, session: options.session, window: options.window, command, reason: null, stderr: "" };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {string} session
|
|
136
|
+
* @returns {{available: boolean, windows: SeatWindow[], reason: string|null, stderr: string}}
|
|
137
|
+
*/
|
|
138
|
+
export function listSeatWindows(session) {
|
|
139
|
+
const result = runTmux(["list-windows", "-t", session, "-F", WINDOW_FORMAT]);
|
|
140
|
+
if (!result.available) return { available: false, windows: [], reason: "tmux_unavailable", stderr: "" };
|
|
141
|
+
if (!result.ok) {
|
|
142
|
+
return { available: true, windows: [], reason: result.status === 1 ? "no_session" : "tmux_command_failed", stderr: result.stderr };
|
|
143
|
+
}
|
|
144
|
+
const windows = result.stdout.split("\n").map(parseWindowLine).filter((window) => window !== null);
|
|
145
|
+
return { available: true, windows, reason: null, stderr: "" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {string} line
|
|
150
|
+
* @returns {SeatWindow|null}
|
|
151
|
+
*/
|
|
152
|
+
function parseWindowLine(line) {
|
|
153
|
+
if (!line.trim()) return null;
|
|
154
|
+
const [name, index, harness, command] = line.split("\t");
|
|
155
|
+
if (!name) return null;
|
|
156
|
+
const parsed = Number.parseInt(index ?? "", 10);
|
|
157
|
+
return {
|
|
158
|
+
window: name,
|
|
159
|
+
index: Number.isFinite(parsed) ? parsed : null,
|
|
160
|
+
harness: harness ? harness : null,
|
|
161
|
+
command: command ? command : null,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @returns {{available: boolean, version: string|null, reason: string|null}}
|
|
167
|
+
*/
|
|
168
|
+
export function tmuxAvailability() {
|
|
169
|
+
const result = runTmux(["-V"]);
|
|
170
|
+
return {
|
|
171
|
+
available: result.available,
|
|
172
|
+
version: result.ok && result.stdout.trim() ? result.stdout.trim() : null,
|
|
173
|
+
reason: result.reason,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @param {string} session
|
|
179
|
+
* @param {string} window
|
|
180
|
+
* @returns {{available: boolean, ok: boolean, reason: string|null, stderr: string}}
|
|
181
|
+
*/
|
|
182
|
+
export function stopSeatWindow(session, window) {
|
|
183
|
+
return stopTmux(["kill-window", "-t", `${session}:${window}`], "window");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @param {string} session
|
|
188
|
+
* @returns {{available: boolean, ok: boolean, reason: string|null, stderr: string}}
|
|
189
|
+
*/
|
|
190
|
+
export function stopSeatSession(session) {
|
|
191
|
+
return stopTmux(["kill-session", "-t", session], "session");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* tmux exits 1 when the target is already gone. That is idempotent success for
|
|
196
|
+
* a stop, not a failure: stopping the last window takes the session with it.
|
|
197
|
+
*
|
|
198
|
+
* @param {string[]} args
|
|
199
|
+
* @param {"window"|"session"} label
|
|
200
|
+
* @returns {{available: boolean, ok: boolean, reason: string|null, stderr: string}}
|
|
201
|
+
*/
|
|
202
|
+
function stopTmux(args, label) {
|
|
203
|
+
const result = runTmux(args);
|
|
204
|
+
if (result.available && !result.ok && result.status === 1) {
|
|
205
|
+
return { available: true, ok: true, reason: `no_${label}`, stderr: result.stderr };
|
|
206
|
+
}
|
|
207
|
+
return { available: result.available, ok: result.ok, reason: result.reason, stderr: result.stderr };
|
|
208
|
+
}
|
package/src/util.mjs
ADDED
|
Binary file
|
package/src/web/api.mjs
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operator's remote API: the phone-shaped surface behind the dashboard
|
|
3
|
+
* server. Reads go straight to the campaign/seat aggregators that already
|
|
4
|
+
* exist; every write shells out to the runner CLI and touches no state file of
|
|
5
|
+
* its own (ADR-0034: a daemon that wrote state would be a second state machine
|
|
6
|
+
* with its own rules, and the CLI is the only writer). That is also why there
|
|
7
|
+
* is no replan, contract, routing or gate route: the contract is frozen with a
|
|
8
|
+
* digest, and the sanctioned middle ground from a phone is `campaign note`.
|
|
9
|
+
* Pause/resume ride the run-level `cancel`/`resume` verbs — the reversible
|
|
10
|
+
* pair this repo already owns (a canceled run is a resumable state) — fired
|
|
11
|
+
* once per linked run that still has work in flight.
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, statSync } from "node:fs";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import process from "node:process";
|
|
18
|
+
import { discoverCampaigns } from "../campaign/index.mjs";
|
|
19
|
+
import { BRIEF_FILE, JOURNAL_FILE } from "../campaign/layout.mjs";
|
|
20
|
+
import { seatStatus } from "../seat/index.mjs";
|
|
21
|
+
import { errorMessage, errorCode, fail, readJsonTolerant, truncateChars } from "../util.mjs";
|
|
22
|
+
|
|
23
|
+
const DEFAULT_CLI_ENTRY = fileURLToPath(new URL("../cli.mjs", import.meta.url));
|
|
24
|
+
/** One events page is bounded twice: by the bytes read from the journal and by the entry count returned. */
|
|
25
|
+
const EVENTS_WINDOW_BYTES = 32 * 1024;
|
|
26
|
+
const EVENTS_WINDOW_ENTRIES = 100;
|
|
27
|
+
const REQUEST_BODY_MAX_BYTES = 16 * 1024;
|
|
28
|
+
const OUTPUT_TAIL_CHARS = 4 * 1024;
|
|
29
|
+
const LIST_GOAL_CHARS = 200;
|
|
30
|
+
const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
|
|
31
|
+
const RUN_DONE_STATUSES = new Set(["done", "no-op"]);
|
|
32
|
+
const RUN_TERMINAL_STATUSES = new Set(["done", "no-op", "failed", "blocked", "exhausted", "stalled", "canceled", "cancelled"]);
|
|
33
|
+
const RUN_ATTENTION_STATUSES = new Set(["blocked", "failed", "exhausted", "stalled", "canceled", "cancelled"]);
|
|
34
|
+
/** The journal's `sessionId` records who spoke; the web daemon is a speaker of its own. */
|
|
35
|
+
const WEB_SESSION_ID = "web";
|
|
36
|
+
|
|
37
|
+
/** @typedef {import("node:http").IncomingMessage} IncomingMessage */
|
|
38
|
+
/** @typedef {import("node:http").ServerResponse} ServerResponse */
|
|
39
|
+
/** @typedef {{runsDir: string, repoRoot: string, cliEntry: string, url: URL, request: IncomingMessage}} ApiContext */
|
|
40
|
+
/** @typedef {{type: string, eventId: string, at: string, [key: string]: unknown}} JournalRecord */
|
|
41
|
+
/** @typedef {(context: ApiContext, response: ServerResponse, params: string[]) => Promise<void>} ApiHandler */
|
|
42
|
+
|
|
43
|
+
/** @type {[RegExp, ApiHandler][]} */
|
|
44
|
+
const GET_ROUTES = [
|
|
45
|
+
[/^\/api\/campaigns$/u, listCampaigns],
|
|
46
|
+
[/^\/api\/campaigns\/([^/]+)$/u, showCampaign],
|
|
47
|
+
[/^\/api\/campaigns\/([^/]+)\/events$/u, campaignEvents],
|
|
48
|
+
[/^\/api\/campaigns\/([^/]+)\/brief$/u, campaignBrief],
|
|
49
|
+
[/^\/api\/seats$/u, listSeats],
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/** @type {[RegExp, ApiHandler][]} */
|
|
53
|
+
const POST_ROUTES = [
|
|
54
|
+
[/^\/api\/campaigns\/([^/]+)\/note$/u, postNote],
|
|
55
|
+
[/^\/api\/campaigns\/([^/]+)\/decisions\/([^/]+)$/u, postDecision],
|
|
56
|
+
[/^\/api\/campaigns\/([^/]+)\/pause$/u, postPause],
|
|
57
|
+
[/^\/api\/campaigns\/([^/]+)\/resume$/u, postResume],
|
|
58
|
+
[/^\/api\/seats\/([^/]+)\/switch$/u, postSeatSwitch],
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Route one `/api/*` request. Unknown paths answer 404 — deliberately,
|
|
63
|
+
* including the replan-shaped ones — and a path matched with the wrong method
|
|
64
|
+
* answers 405. Never throws: the failure envelope is JSON like the payloads.
|
|
65
|
+
*
|
|
66
|
+
* @param {IncomingMessage} request
|
|
67
|
+
* @param {ServerResponse} response
|
|
68
|
+
* @param {{runsDir: string, cliEntry?: string}} options
|
|
69
|
+
*/
|
|
70
|
+
export async function handleApiRequest(request, response, options) {
|
|
71
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
72
|
+
/** @type {ApiContext} */
|
|
73
|
+
const context = {
|
|
74
|
+
runsDir: options.runsDir,
|
|
75
|
+
repoRoot: dirname(options.runsDir),
|
|
76
|
+
cliEntry: options.cliEntry ?? DEFAULT_CLI_ENTRY,
|
|
77
|
+
url,
|
|
78
|
+
request,
|
|
79
|
+
};
|
|
80
|
+
try {
|
|
81
|
+
const table = request.method === "GET" ? GET_ROUTES : request.method === "POST" ? POST_ROUTES : [];
|
|
82
|
+
for (const [pattern, handler] of table) {
|
|
83
|
+
const params = pattern.exec(url.pathname)?.slice(1);
|
|
84
|
+
if (params) return await handler(context, response, params.map(safeSegment));
|
|
85
|
+
}
|
|
86
|
+
if ([...GET_ROUTES, ...POST_ROUTES].some(([pattern]) => pattern.test(url.pathname))) {
|
|
87
|
+
throw fail("method_not_allowed", `use ${request.method === "GET" ? "POST" : "GET"} for ${url.pathname}`);
|
|
88
|
+
}
|
|
89
|
+
throw notFound("no such path");
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const code = errorCode(error);
|
|
92
|
+
const status = code === "bad_request" ? 400 : code === "not_found" ? 404 : code === "method_not_allowed" ? 405 : 500;
|
|
93
|
+
sendJson(response, status, { error: errorMessage(error) });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @param {string} message @returns {Error & {code: string}} */
|
|
98
|
+
function notFound(message) {
|
|
99
|
+
return fail("not_found", message);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** @param {string} message @returns {Error & {code: string}} */
|
|
103
|
+
function badRequest(message) {
|
|
104
|
+
return fail("bad_request", message);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @param {ServerResponse} response @param {number} status @param {unknown} payload */
|
|
108
|
+
function sendJson(response, status, payload) {
|
|
109
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
110
|
+
response.end(JSON.stringify(payload));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @param {string} value @returns {string} */
|
|
114
|
+
function safeSegment(value) {
|
|
115
|
+
if (!SAFE_SEGMENT.test(value)) throw notFound("no such path");
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** @param {ApiContext} context @param {string} id @returns {{path: string, campaign: import("../campaign/index.mjs").Campaign}} */
|
|
120
|
+
function findCampaign(context, id) {
|
|
121
|
+
const found = discoverCampaigns(context.runsDir).campaigns.find(({ campaign }) => campaign.id === id);
|
|
122
|
+
if (!found) throw notFound(`unknown campaign: ${id}`);
|
|
123
|
+
return found;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The phone-sized run row: three states and two counters, nothing the dashboard snapshot does not already compute better. @param {string} runsDir @param {string} runId @returns {Record<string, unknown>} */
|
|
127
|
+
function runRow(runsDir, runId) {
|
|
128
|
+
const status = readJsonTolerant(join(runsDir, runId, "status.json"));
|
|
129
|
+
const nodes = status && typeof status === "object" && Array.isArray(/** @type {any} */ (status).nodes) ? /** @type {any[]} */ (/** @type {any} */ (status).nodes) : [];
|
|
130
|
+
return {
|
|
131
|
+
id: runId,
|
|
132
|
+
state: nodes.some((node) => RUN_ATTENTION_STATUSES.has(String(node.status)))
|
|
133
|
+
? "attention"
|
|
134
|
+
: nodes.length > 0 && nodes.every((node) => RUN_TERMINAL_STATUSES.has(String(node.status))) ? "done" : "active",
|
|
135
|
+
nodesDone: nodes.filter((node) => RUN_DONE_STATUSES.has(String(node.status))).length,
|
|
136
|
+
nodesTotal: nodes.length,
|
|
137
|
+
costUsd: typeof /** @type {any} */ (status)?.usage?.costUsd === "number" ? /** @type {any} */ (status).usage.costUsd : null,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Linked runs that still have a node in flight — the only ones pause or resume can change. @param {ApiContext} context @param {import("../campaign/index.mjs").Campaign} campaign @returns {string[]} */
|
|
142
|
+
function runsInFlight(context, campaign) {
|
|
143
|
+
return campaign.linkedRunIds.filter((runId) => {
|
|
144
|
+
const status = readJsonTolerant(join(context.runsDir, runId, "status.json"));
|
|
145
|
+
const nodes = status && typeof status === "object" && Array.isArray(/** @type {any} */ (status).nodes) ? /** @type {any[]} */ (/** @type {any} */ (status).nodes) : [];
|
|
146
|
+
return nodes.some((node) => !RUN_TERMINAL_STATUSES.has(String(node.status)));
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** @param {ApiContext} context @param {ServerResponse} response */
|
|
151
|
+
async function listCampaigns(context, response) {
|
|
152
|
+
const { campaigns, corrupt } = discoverCampaigns(context.runsDir);
|
|
153
|
+
sendJson(response, 200, {
|
|
154
|
+
schemaVersion: 1,
|
|
155
|
+
campaigns: campaigns.map(({ campaign }) => ({
|
|
156
|
+
id: campaign.id,
|
|
157
|
+
goal: truncateChars(String(campaign.goal ?? "").replace(/\s+/gu, " ").trim(), LIST_GOAL_CHARS),
|
|
158
|
+
status: campaign.status,
|
|
159
|
+
updatedAt: campaign.updatedAt,
|
|
160
|
+
runCount: campaign.linkedRunIds.length,
|
|
161
|
+
})),
|
|
162
|
+
corrupt: corrupt.map((entry) => entry.id),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** @param {ApiContext} context @param {ServerResponse} response @param {string[]} params */
|
|
167
|
+
async function showCampaign(context, response, params) {
|
|
168
|
+
const { campaign } = findCampaign(context, params[0]);
|
|
169
|
+
sendJson(response, 200, { campaign, runs: campaign.linkedRunIds.map((runId) => runRow(context.runsDir, runId)) });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** @param {ApiContext} context @param {ServerResponse} response @param {string[]} params */
|
|
173
|
+
async function campaignEvents(context, response, params) {
|
|
174
|
+
const { campaign, path } = findCampaign(context, params[0]);
|
|
175
|
+
const afterRaw = context.url.searchParams.get("after") ?? "0";
|
|
176
|
+
if (!/^\d+$/u.test(afterRaw)) throw badRequest("after must be a non-negative integer cursor");
|
|
177
|
+
const page = readJournalWindow(join(path, JOURNAL_FILE), Number(afterRaw));
|
|
178
|
+
sendJson(response, 200, {
|
|
179
|
+
schemaVersion: 1,
|
|
180
|
+
campaignId: campaign.id,
|
|
181
|
+
after: Number(afterRaw),
|
|
182
|
+
next: page.next,
|
|
183
|
+
size: page.size,
|
|
184
|
+
complete: page.next >= page.size,
|
|
185
|
+
entries: page.entries,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** @param {ApiContext} context @param {ServerResponse} response @param {string[]} params */
|
|
190
|
+
async function campaignBrief(context, response, params) {
|
|
191
|
+
const briefPath = join(findCampaign(context, params[0]).path, BRIEF_FILE);
|
|
192
|
+
if (!existsSync(briefPath)) throw notFound(`operator brief not generated yet: ${BRIEF_FILE}`);
|
|
193
|
+
response.writeHead(200, { "content-type": "text/markdown; charset=utf-8", "cache-control": "no-store" });
|
|
194
|
+
response.end(readFileSync(briefPath));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** @param {ApiContext} context @param {ServerResponse} response */
|
|
198
|
+
async function listSeats(context, response) {
|
|
199
|
+
sendJson(response, 200, seatStatus());
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** @param {ApiContext} context @param {ServerResponse} response @param {string[]} params */
|
|
203
|
+
async function postNote(context, response, params) {
|
|
204
|
+
// The campaign is resolved before the CLI runs, like every other route: an
|
|
205
|
+
// unknown id used to reach `campaign note`, whose own refusal names the
|
|
206
|
+
// absolute path it looked in and handed the caller the server's directory
|
|
207
|
+
// layout.
|
|
208
|
+
findCampaign(context, params[0]);
|
|
209
|
+
const body = await readJsonObject(context.request);
|
|
210
|
+
const argv = ["campaign", "note", params[0], "--session-id", sessionOf(body), "--kind", requiredString(body.kind, "kind"), "--text", requiredString(body.text, "text")];
|
|
211
|
+
for (const [flag, key] of [["--decision-id", "decisionId"], ["--supersedes", "supersedes"], ["--question-id", "questionId"], ["--run-id", "runId"]]) {
|
|
212
|
+
if (typeof body[key] === "string" && /** @type {string} */ (body[key]).trim()) argv.push(flag, /** @type {string} */ (body[key]));
|
|
213
|
+
}
|
|
214
|
+
sendResult(response, await runCli(context, argv));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A pending operator decision is the journal's open question; resolving it is `campaign resolve`. @param {ApiContext} context @param {ServerResponse} response @param {string[]} params */
|
|
218
|
+
async function postDecision(context, response, params) {
|
|
219
|
+
findCampaign(context, params[0]);
|
|
220
|
+
const body = await readJsonObject(context.request);
|
|
221
|
+
const argv = ["campaign", "resolve", params[0], "--session-id", sessionOf(body), "--question-id", params[1], "--text", requiredString(body.text, "text")];
|
|
222
|
+
sendResult(response, await runCli(context, argv));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** @param {ApiContext} context @param {ServerResponse} response @param {string[]} params */
|
|
226
|
+
async function postPause(context, response, params) {
|
|
227
|
+
const targets = runsInFlight(context, findCampaign(context, params[0]).campaign);
|
|
228
|
+
const results = [];
|
|
229
|
+
for (const runId of targets) results.push({ runId, ...await runCli(context, ["cancel", join(context.runsDir, runId)]) });
|
|
230
|
+
sendResult(response, { ok: results.every((result) => result.exitCode === 0), results });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** @param {ApiContext} context @param {ServerResponse} response @param {string[]} params */
|
|
234
|
+
async function postResume(context, response, params) {
|
|
235
|
+
const targets = runsInFlight(context, findCampaign(context, params[0]).campaign);
|
|
236
|
+
const results = [];
|
|
237
|
+
for (const runId of targets) results.push({ runId, ...await runCli(context, ["resume", join(context.runsDir, runId), "--detach"]) });
|
|
238
|
+
sendResult(response, { ok: results.every((result) => result.exitCode === 0), results });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** @param {ApiContext} context @param {ServerResponse} response @param {string[]} params */
|
|
242
|
+
async function postSeatSwitch(context, response, params) {
|
|
243
|
+
const body = await readJsonObject(context.request);
|
|
244
|
+
const argv = ["seat", "switch", params[0], "--harness", requiredString(body.harness, "harness")];
|
|
245
|
+
sendResult(response, await runCli(context, argv));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** @param {ServerResponse} response @param {{ok: boolean, [key: string]: unknown}} envelope */
|
|
249
|
+
function sendResult(response, envelope) {
|
|
250
|
+
sendJson(response, envelope.ok ? 200 : 500, { schemaVersion: 1, ...envelope });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** @param {Record<string, unknown>} body @returns {string} */
|
|
254
|
+
function sessionOf(body) {
|
|
255
|
+
return typeof body.sessionId === "string" && body.sessionId.trim() ? body.sessionId.trim() : WEB_SESSION_ID;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** @param {unknown} value @param {string} label @returns {string} */
|
|
259
|
+
function requiredString(value, label) {
|
|
260
|
+
if (typeof value !== "string" || !value.trim()) throw badRequest(`${label} is required`);
|
|
261
|
+
return value;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** @param {IncomingMessage} request @returns {Promise<Record<string, unknown>>} */
|
|
265
|
+
async function readJsonObject(request) {
|
|
266
|
+
const chunks = [];
|
|
267
|
+
let total = 0;
|
|
268
|
+
for await (const chunk of request) {
|
|
269
|
+
total += chunk.length;
|
|
270
|
+
if (total > REQUEST_BODY_MAX_BYTES) throw badRequest(`request body exceeds ${REQUEST_BODY_MAX_BYTES} bytes`);
|
|
271
|
+
chunks.push(Buffer.from(chunk));
|
|
272
|
+
}
|
|
273
|
+
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
274
|
+
if (!raw) return {};
|
|
275
|
+
let parsed;
|
|
276
|
+
try {
|
|
277
|
+
parsed = JSON.parse(raw);
|
|
278
|
+
} catch {
|
|
279
|
+
throw badRequest("request body is not valid JSON");
|
|
280
|
+
}
|
|
281
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw badRequest("request body must be a JSON object");
|
|
282
|
+
return /** @type {Record<string, unknown>} */ (parsed);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Fire one CLI verb and collect its verdict. The argv is an array handed to
|
|
287
|
+
* spawn, never a shell string, so note text can never become an option or a
|
|
288
|
+
* command.
|
|
289
|
+
*
|
|
290
|
+
* @param {ApiContext} context
|
|
291
|
+
* @param {string[]} argv
|
|
292
|
+
* @returns {Promise<{ok: boolean, command: string, exitCode: number, stdout: string, stderr: string}>}
|
|
293
|
+
*/
|
|
294
|
+
async function runCli(context, argv) {
|
|
295
|
+
const child = spawn(process.execPath, [context.cliEntry, ...argv], { cwd: context.repoRoot, env: process.env, stdio: ["ignore", "pipe", "pipe"] });
|
|
296
|
+
let stdout = "";
|
|
297
|
+
let stderr = "";
|
|
298
|
+
if (child.stdout) child.stdout.on("data", (chunk) => { stdout += String(chunk); });
|
|
299
|
+
if (child.stderr) child.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
|
300
|
+
/** @type {Promise<number>} */
|
|
301
|
+
const closed = new Promise((resolve) => child.on("close", (code) => resolve(code ?? -1)));
|
|
302
|
+
const exitCode = await closed;
|
|
303
|
+
return {
|
|
304
|
+
ok: exitCode === 0,
|
|
305
|
+
command: argv.join(" "),
|
|
306
|
+
exitCode,
|
|
307
|
+
stdout: truncateChars(stdout.trim(), OUTPUT_TAIL_CHARS),
|
|
308
|
+
stderr: truncateChars(stderr.trim(), OUTPUT_TAIL_CHARS),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* One bounded page of the campaign journal, read forward from the `after` byte
|
|
314
|
+
* cursor. A client that starts at zero gets the first window, never the whole
|
|
315
|
+
* file, however long the campaign has run; `next` is the cursor to send back.
|
|
316
|
+
* Only a line the window has seen ended by its own newline is an entry — a
|
|
317
|
+
* trailing fragment torn by a concurrent append is left for the next page, and
|
|
318
|
+
* legacy `liveness` heartbeats are skipped as narrative-free. A cursor beyond
|
|
319
|
+
* the file is a client bug and answers itself as a bad request.
|
|
320
|
+
*
|
|
321
|
+
* @param {string} path
|
|
322
|
+
* @param {number} after
|
|
323
|
+
* @returns {{entries: JournalRecord[], next: number, size: number}}
|
|
324
|
+
*/
|
|
325
|
+
function readJournalWindow(path, after) {
|
|
326
|
+
let size = 0;
|
|
327
|
+
try {
|
|
328
|
+
size = statSync(path).size;
|
|
329
|
+
} catch {
|
|
330
|
+
return { entries: [], next: 0, size: 0 };
|
|
331
|
+
}
|
|
332
|
+
if (after > size) throw badRequest(`cursor ${after} is beyond the journal size ${size}`);
|
|
333
|
+
const bytes = windowBytes(path, Math.min(size - after, EVENTS_WINDOW_BYTES), after);
|
|
334
|
+
/** @type {JournalRecord[]} */
|
|
335
|
+
const entries = [];
|
|
336
|
+
let start = 0;
|
|
337
|
+
let consumed = 0;
|
|
338
|
+
while (entries.length < EVENTS_WINDOW_ENTRIES) {
|
|
339
|
+
const newline = bytes.indexOf(0x0a, start);
|
|
340
|
+
if (newline === -1) break;
|
|
341
|
+
consumed = newline + 1;
|
|
342
|
+
const line = bytes.toString("utf8", start, newline).replace(/\r$/u, "");
|
|
343
|
+
if (line.trim()) {
|
|
344
|
+
try {
|
|
345
|
+
const entry = /** @type {JournalRecord} */ (JSON.parse(line));
|
|
346
|
+
if (entry.type !== "liveness") entries.push(entry);
|
|
347
|
+
} catch {
|
|
348
|
+
// dropped: a line torn by a concurrent append; the cursor advances past it
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
start = newline + 1;
|
|
352
|
+
}
|
|
353
|
+
return { entries, next: after + consumed, size };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Positioned read that loops until the window is full, because one readSync may return short. @param {string} path @param {number} length @param {number} position @returns {Buffer} */
|
|
357
|
+
function windowBytes(path, length, position) {
|
|
358
|
+
const descriptor = openSync(path, "r");
|
|
359
|
+
try {
|
|
360
|
+
const buffer = Buffer.alloc(length);
|
|
361
|
+
let filled = 0;
|
|
362
|
+
while (filled < length) {
|
|
363
|
+
const readNow = readSync(descriptor, buffer, filled, length - filled, position + filled);
|
|
364
|
+
if (readNow === 0) break;
|
|
365
|
+
filled += readNow;
|
|
366
|
+
}
|
|
367
|
+
return buffer.subarray(0, filled);
|
|
368
|
+
} finally {
|
|
369
|
+
closeSync(descriptor);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dashboard's network boundary: the decisions that must be settled before
|
|
3
|
+
* a socket exists. `server.mjs` serves routes; this module decides whether it
|
|
4
|
+
* may listen at all — which address the bind host resolves to, whether that
|
|
5
|
+
* address is private (loopback, RFC1918, or the 100.64.0.0/10 CGNAT range
|
|
6
|
+
* Tailscale assigns), and the bearer token file that is the second lock behind
|
|
7
|
+
* the private network. The address ranges decide, never the Tailscale binary:
|
|
8
|
+
* a literal classifies identically with or without the daemon, so nothing here
|
|
9
|
+
* shells out or resolves anything beyond the local resolver.
|
|
10
|
+
*/
|
|
11
|
+
import { lookup } from "node:dns/promises";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import net from "node:net";
|
|
14
|
+
import { errorMessage, fail } from "../util.mjs";
|
|
15
|
+
|
|
16
|
+
/** The admissible bind space: loopback, RFC1918 and CGNAT — the range Tailscale hands out. Everything else, wildcard or public, is refused. */
|
|
17
|
+
const PRIVATE_BIND = new net.BlockList();
|
|
18
|
+
PRIVATE_BIND.addSubnet("127.0.0.0", 8, "ipv4");
|
|
19
|
+
PRIVATE_BIND.addSubnet("10.0.0.0", 8, "ipv4");
|
|
20
|
+
PRIVATE_BIND.addSubnet("172.16.0.0", 12, "ipv4");
|
|
21
|
+
PRIVATE_BIND.addSubnet("192.168.0.0", 16, "ipv4");
|
|
22
|
+
PRIVATE_BIND.addSubnet("100.64.0.0", 10, "ipv4");
|
|
23
|
+
PRIVATE_BIND.addSubnet("::1", 128, "ipv6");
|
|
24
|
+
|
|
25
|
+
const BIND_HELP = "bind loopback, an RFC1918 address, or a 100.64.0.0/10 CGNAT address (the Tailscale range)";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The address a bind host stands for: IP literals pass through untouched, and
|
|
29
|
+
* a name resolves through the host resolver (which reads /etc/hosts) with its
|
|
30
|
+
* first result standing in for the set.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} host
|
|
33
|
+
* @returns {Promise<string>}
|
|
34
|
+
*/
|
|
35
|
+
export async function resolveBindAddress(host) {
|
|
36
|
+
if (net.isIP(host) !== 0) return host;
|
|
37
|
+
try {
|
|
38
|
+
return (await lookup(host)).address;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
throw fail("bind_unresolvable", `cannot resolve bind host ${host}: ${errorMessage(error)}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** An IPv6 form like `::ffff:203.0.113.10` classifies by the IPv4 address it carries. @param {string} address @returns {string} */
|
|
45
|
+
function unwrapIPv4Mapped(address) {
|
|
46
|
+
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/iu.exec(address);
|
|
47
|
+
return mapped ? mapped[1] : address;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Passes when the address is private and returns the canonical address to
|
|
52
|
+
* bind; throws with the reason when it is a wildcard or public. Callers run
|
|
53
|
+
* this before `listen`: binding and closing again still exposed the port for
|
|
54
|
+
* the time it was open.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} address
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
export function assertPrivateBind(address) {
|
|
60
|
+
if (address === "0.0.0.0") throw fail("public_bind", `refusing to bind 0.0.0.0: the IPv4 wildcard accepts connections on every interface, the public ones included; ${BIND_HELP}`);
|
|
61
|
+
if (address === "::") throw fail("public_bind", `refusing to bind '::': the IPv6 wildcard accepts connections on every interface, the public ones included; ${BIND_HELP}`);
|
|
62
|
+
const plain = unwrapIPv4Mapped(address);
|
|
63
|
+
// The family must be stated: without it, BlockList.check misses IPv6 rules (measured on Node 26.8.1).
|
|
64
|
+
const family = net.isIPv4(plain) ? "ipv4" : "ipv6";
|
|
65
|
+
if (PRIVATE_BIND.check(plain, family)) return plain;
|
|
66
|
+
throw fail("public_bind", `refusing to bind ${address}: not a private address; ${BIND_HELP}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The bearer token from a local file — the second lock behind the private
|
|
71
|
+
* network; there is no login system. One trimmed, space-free line, and the
|
|
72
|
+
* value never appears in any message this module produces.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} path
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
export function loadBearerToken(path) {
|
|
78
|
+
let raw;
|
|
79
|
+
try {
|
|
80
|
+
raw = readFileSync(path, "utf8");
|
|
81
|
+
} catch (error) {
|
|
82
|
+
throw fail("token_unreadable", `bearer token file is missing or unreadable: ${path} (${errorMessage(error)})`);
|
|
83
|
+
}
|
|
84
|
+
const token = raw.trim();
|
|
85
|
+
if (!token) throw fail("token_empty", `bearer token file is empty: ${path}`);
|
|
86
|
+
if (/\s/u.test(token)) throw fail("token_invalid", `bearer token file must hold a single space-free line: ${path}`);
|
|
87
|
+
return token;
|
|
88
|
+
}
|