@edgehero/pi-dispatch 4.3.1 → 5.0.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/.env.example +84 -3
- package/README.md +20 -7
- package/deploy/console.service +52 -0
- package/deploy/docker-compose.yml +3 -1
- package/deploy/pi-dispatch-valkey.container +4 -2
- package/deploy/sandbox-gateway.service +23 -0
- package/deploy/worker-env-wrapper.cmd +6 -0
- package/deploy/worker-env-wrapper.sh +10 -1
- package/package.json +117 -19
- package/src/agent-snapshot.mjs +49 -0
- package/src/answerer-runtime.mjs +194 -0
- package/src/answerer.mjs +178 -0
- package/src/approval-policy.mjs +76 -0
- package/src/approval-runtime.mjs +188 -0
- package/src/approval-session.mjs +137 -0
- package/src/approval-state.mjs +170 -0
- package/src/auth-cli.mjs +86 -0
- package/src/auth-connections.mjs +145 -0
- package/src/auth-policy.mjs +32 -0
- package/src/auth-store.mjs +97 -0
- package/src/azure-host.mjs +66 -4
- package/src/azure-prompt.mjs +5 -4
- package/src/backend-conformance.mjs +34 -2
- package/src/backend-local.mjs +103 -34
- package/src/backend-openshell.mjs +1540 -0
- package/src/backend-podman.mjs +63 -2
- package/src/backend-registry.mjs +61 -4
- package/src/backends.mjs +395 -6
- package/src/bullmq-internals.mjs +208 -0
- package/src/canary-cli.mjs +28 -0
- package/src/canary-lock.mjs +41 -0
- package/src/canary-runtime.mjs +237 -0
- package/src/canary.mjs +53 -0
- package/src/cancel-cli.mjs +47 -34
- package/src/cancel-state.mjs +213 -15
- package/src/capabilities.mjs +36 -1
- package/src/capacity-cli.mjs +28 -8
- package/src/capacity-records.mjs +75 -16
- package/src/capacity.mjs +65 -0
- package/src/chat-cli.mjs +288 -0
- package/src/chat-fork.mjs +547 -0
- package/src/chat-mode.mjs +156 -0
- package/src/chat-view.mjs +720 -0
- package/src/checkpoint-paths.mjs +113 -0
- package/src/cli.mjs +128 -41
- package/src/comment-tables.mjs +322 -0
- package/src/community-auth-adapters.mjs +87 -0
- package/src/community-auth-helper.mjs +76 -0
- package/src/community-auth-metadata.mjs +6 -0
- package/src/community-auth-output-pins.mjs +38 -0
- package/src/community-auth-patches.mjs +168 -0
- package/src/community-auth-pins.mjs +38 -0
- package/src/config.mjs +190 -29
- package/src/connection.mjs +83 -7
- package/src/console-answer.mjs +113 -0
- package/src/console-audit.mjs +604 -0
- package/src/console-bootstrap.mjs +159 -0
- package/src/console-client.mjs +848 -0
- package/src/console-probe.mjs +106 -0
- package/src/console-tokens.mjs +459 -0
- package/src/container-names.mjs +51 -0
- package/src/container-spec.mjs +20 -1
- package/src/cpu-reserve.mjs +4 -1
- package/src/defer-reasons.mjs +200 -0
- package/src/defer-write.mjs +208 -0
- package/src/deployment-venue.mjs +23 -6
- package/src/docker-run.mjs +11 -0
- package/src/doctor-bundles.mjs +130 -0
- package/src/doctor-model-proposals.mjs +109 -0
- package/src/doctor-openshell.mjs +238 -0
- package/src/doctor-results.mjs +602 -0
- package/src/doctor-spawner.mjs +215 -0
- package/src/doctor.mjs +1639 -196
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +96 -6
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-host.mjs +42 -5
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +33 -3
- package/src/fork-job.mjs +58 -0
- package/src/fork-policy.mjs +23 -0
- package/src/fork-store.mjs +338 -0
- package/src/get-token.mjs +1 -1
- package/src/git-dirty.mjs +119 -14
- package/src/git-hardening.mjs +85 -0
- package/src/github-host.mjs +31 -6
- package/src/github-prompt.mjs +7 -16
- package/src/gitlab-host.mjs +42 -3
- package/src/gitlab-prompt.mjs +5 -4
- package/src/held-document.mjs +23 -0
- package/src/held-replies.mjs +325 -0
- package/src/held-settle.mjs +266 -0
- package/src/hook-runs.mjs +188 -0
- package/src/host-budget.mjs +8 -5
- package/src/host-clone.mjs +72 -0
- package/src/host-loads.mjs +557 -0
- package/src/host-registry.mjs +66 -6
- package/src/host-telemetry.mjs +92 -0
- package/src/http-body.mjs +78 -0
- package/src/image-preflight.mjs +156 -13
- package/src/import-pi.mjs +60 -172
- package/src/inbox-relay.mjs +482 -0
- package/src/inbox-settle.mjs +196 -0
- package/src/index.mjs +734 -129
- package/src/init.mjs +35 -5
- package/src/job-data-version.mjs +75 -0
- package/src/job-id.mjs +30 -15
- package/src/job-layout.mjs +22 -0
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +14 -6
- package/src/live-probes.mjs +6 -2
- package/src/live-transcript.mjs +872 -0
- package/src/loaded-record.mjs +609 -0
- package/src/mcp-config.mjs +1 -1
- package/src/mcp-preflight.mjs +36 -5
- package/src/model-attempts.mjs +84 -0
- package/src/model-catalog.mjs +83 -0
- package/src/model-cooldowns.mjs +22 -0
- package/src/model-failover-protocol.mjs +11 -0
- package/src/model-failover.mjs +134 -0
- package/src/model-observation.mjs +50 -0
- package/src/model-proposal-protocol.mjs +21 -0
- package/src/model-proposals.mjs +75 -0
- package/src/model-ref.mjs +67 -10
- package/src/model-roles-file.mjs +24 -0
- package/src/model-roles.mjs +73 -0
- package/src/model-status.mjs +34 -0
- package/src/models-json.mjs +4 -1
- package/src/money.mjs +1 -1
- package/src/oauth-openai.mjs +121 -0
- package/src/older-state.mjs +249 -0
- package/src/on-failure.mjs +17 -13
- package/src/open-browser.mjs +31 -2
- package/src/opener-cli.mjs +135 -0
- package/src/opener-protocol.mjs +27 -0
- package/src/opener-terminal.mjs +128 -0
- package/src/openshell-cli.mjs +155 -0
- package/src/openshell-policy.mjs +163 -0
- package/src/outbox.mjs +14 -0
- package/src/output-cap.mjs +1 -0
- package/src/package-canary-record.mjs +26 -0
- package/src/package-canary.mjs +135 -0
- package/src/package-names.mjs +9 -0
- package/src/package-stage.mjs +418 -0
- package/src/packages.mjs +24 -16
- package/src/parked.mjs +2 -1
- package/src/pause-windows.mjs +136 -17
- package/src/podman-stack.mjs +40 -14
- package/src/portfolio-snapshot.mjs +26 -7
- package/src/prepare-github.mjs +41 -77
- package/src/prepare-local.mjs +32 -2
- package/src/prepare.mjs +45 -11
- package/src/private-store-boundary.mjs +70 -0
- package/src/processor.mjs +1550 -173
- package/src/projects.mjs +23 -0
- package/src/prompt-fence.mjs +18 -0
- package/src/provider-key.mjs +7 -0
- package/src/provider-steering.mjs +1 -0
- package/src/question-context.mjs +35 -0
- package/src/question-group-runtime.mjs +91 -0
- package/src/question-groups.mjs +171 -0
- package/src/queue.mjs +264 -55
- package/src/reason-catalog.mjs +448 -0
- package/src/receiver-status.mjs +230 -0
- package/src/record-reason.mjs +37 -0
- package/src/recovery-queue.mjs +566 -0
- package/src/redact-secrets.mjs +219 -0
- package/src/reserve-estimate.mjs +101 -0
- package/src/reserved-env.mjs +36 -1
- package/src/retention.mjs +67 -0
- package/src/run-budget-ledger.mjs +283 -0
- package/src/run-checkpoint.mjs +627 -0
- package/src/run-container.mjs +749 -33
- package/src/run-history.mjs +791 -45
- package/src/run-mirror.mjs +75 -7
- package/src/run-progress.mjs +751 -0
- package/src/runtime-observations.mjs +3 -1
- package/src/runtime-settings.mjs +272 -37
- package/src/sandbox-cli.mjs +78 -2
- package/src/sandbox-deployment.mjs +51 -0
- package/src/sandbox-gateway-protocol.mjs +378 -0
- package/src/sandbox-hosts.mjs +100 -0
- package/src/sandbox-inventory.mjs +83 -0
- package/src/sandbox-policy.mjs +225 -0
- package/src/sandbox-prepare-requests.mjs +145 -0
- package/src/sandbox-prepare.mjs +285 -0
- package/src/sandbox-refs.mjs +70 -0
- package/src/sandbox-session-monitor.mjs +4 -0
- package/src/sandbox-session-watch.mjs +124 -0
- package/src/sandbox-sessions.mjs +161 -0
- package/src/sandbox-store.mjs +63 -6
- package/src/sandbox-workspace-guard.mjs +39 -0
- package/src/sandbox.mjs +27 -200
- package/src/scheduler-stall-guard.mjs +14 -8
- package/src/schedules.mjs +19 -6
- package/src/secret-bundles.mjs +245 -0
- package/src/secret-profiles.mjs +103 -3
- package/src/secrets.mjs +19 -9
- package/src/service-env.mjs +38 -5
- package/src/service.mjs +638 -106
- package/src/session-key.mjs +60 -0
- package/src/session-store.mjs +168 -10
- package/src/setup-contract.mjs +40 -0
- package/src/setup-egress.mjs +113 -0
- package/src/setup-host-entry.mjs +33 -0
- package/src/setup-host.mjs +242 -0
- package/src/setup-lock.mjs +41 -0
- package/src/size-records.mjs +15 -3
- package/src/start.mjs +1353 -135
- package/src/status-comment.mjs +415 -0
- package/src/stderr-transcript.mjs +318 -0
- package/src/steer-ack.mjs +122 -0
- package/src/steer-cli.mjs +213 -0
- package/src/steer-framing.mjs +185 -0
- package/src/steer-inbox.mjs +693 -0
- package/src/store-version.mjs +139 -0
- package/src/subscription-config.mjs +56 -0
- package/src/subscription-limits.mjs +3 -0
- package/src/subscription-quota.mjs +259 -0
- package/src/subscription-runtime.mjs +123 -0
- package/src/subscription-status.mjs +42 -0
- package/src/subscription-wait.mjs +11 -0
- package/src/subscriptions.mjs +7 -1
- package/src/thinking-level.mjs +19 -0
- package/src/touch-outbox.mjs +61 -0
- package/src/touch-state.mjs +133 -0
- package/src/triggers-file.mjs +299 -39
- package/src/triggers.mjs +780 -38
- package/src/trust-paths.mjs +171 -0
- package/src/up.mjs +398 -51
- package/src/updates-cli.mjs +61 -0
- package/src/updates-look.mjs +127 -0
- package/src/updates-reconcile.mjs +182 -0
- package/src/updates-subscription-adapters.mjs +8 -0
- package/src/updates.mjs +252 -0
- package/src/valkey-auth.mjs +32 -9
- package/src/valkey-endpoint.mjs +14 -4
- package/src/wait-for.mjs +1 -1
- package/src/wait-state.mjs +62 -3
- package/src/watch-board.mjs +38 -0
- package/src/watch-closer.mjs +1 -1
- package/src/watch-contract.mjs +102 -0
- package/src/watch-decision.mjs +78 -0
- package/src/watch-drainer.mjs +14 -0
- package/src/watch-issue-adapter.mjs +44 -0
- package/src/watch-issue.mjs +111 -0
- package/src/watch-record.mjs +10 -0
- package/src/watch-runtime.mjs +167 -0
- package/src/watch-store.mjs +183 -0
- package/src/watch-trace.mjs +34 -0
- package/src/workspace-checkpoint.mjs +1750 -0
- package/src/zero-touch-fixes.mjs +99 -0
- package/src/zero-touch.mjs +225 -0
- package/src/repeat-slot.mjs +0 -13
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operator console's audit log (issue #640; INT-CONSOLE-AUDIT-LOG). One JSON row per line in
|
|
3
|
+
* `PI_LOGS_DIR/console-audit/YYYY-MM.jsonl` (the UTC month of the row's `at`), directory 0700, files 0600, appended with
|
|
4
|
+
* O_APPEND. A worker subpath (`@edgehero/pi-dispatch/console-audit`) and not an admin module, because doctor verifies the
|
|
5
|
+
* chain and the worker cannot import the admin.
|
|
6
|
+
*
|
|
7
|
+
* TAMPER-EVIDENT, NOT TAMPER-PROOF. Every row carries `seq` (one more than the row before it) and `prev` (sha256 hex of
|
|
8
|
+
* the previous row's raw line, without its newline; `null` only for the very first row ever written). Changing,
|
|
9
|
+
* removing or moving a row breaks the link of the row AFTER it, which `verifyConsoleAudit` names. Named residuals: the
|
|
10
|
+
* account that runs the console can rewrite the whole chain from any row on (nothing here is signed by a key it does not
|
|
11
|
+
* hold), and a change to the LAST row has no successor to break, so it shows only once the next row is written.
|
|
12
|
+
*
|
|
13
|
+
* The head is recovered from the newest month file on EVERY append, not cached after the first: `pi-dispatch-console
|
|
14
|
+
* token add` (a separate process) appends to the same chain while `serve` runs, and a cached head in `serve` would then
|
|
15
|
+
* fork the chain at its next row. The tail read is bounded (`TAIL_BYTES`), so a flood of refusals costs a constant read
|
|
16
|
+
* per row, not one that grows with the file. The head read and the write happen under a short append lock
|
|
17
|
+
* (`APPEND_LOCK`, created exclusively with a token of its own, removed in `finally` only while it still holds that token), so `serve` and a CLI command appending at once take turns
|
|
18
|
+
* instead of both writing a row with the same `seq` and `prev`. A lock whose holder pid is dead is taken over; a lock
|
|
19
|
+
* still held after `LOCK_WAIT_MS` makes the append throw, which refuses the write (see `withAppendLock`).
|
|
20
|
+
*
|
|
21
|
+
* Rows come from the console (`serve`), its CLI (actor role `shell`) and, since issue #642, the panel: the `/dispatch` TUI
|
|
22
|
+
* and the pi extension's tools write theirs as actor role `panel` (`admin/src/audited-write.mjs`). The role is an escaped
|
|
23
|
+
* string like the name, not a closed set. A write row may also carry `kind` (which write), `reserve` (micro-dollars a
|
|
24
|
+
* paid action holds) and `count` (a bulk action's size), all optional: a row without them verifies as it always did.
|
|
25
|
+
*
|
|
26
|
+
* ACROSS VERSIONS (issue #761): the writer writes only the actions and outcomes this build lists, and the verifier links
|
|
27
|
+
* any row whose action and outcome have their shape (`CONSOLE_AUDIT_ACTION_RE`, `CONSOLE_AUDIT_OUTCOME_RE`), so a chain
|
|
28
|
+
* a newer console wrote into is still intact here, and an intact chain counts the rows with a value it does not know as
|
|
29
|
+
* `unknown`. A row carries no version: a version an older verifier refused would bring back the false "chain is broken"
|
|
30
|
+
* this rule removes. Keys a row holds beyond the ones judged here are not judged, as before.
|
|
31
|
+
*
|
|
32
|
+
* A row is mirrored to Valkey (`console:audit`, LPUSH then LTRIM, the `alloc:log` precedent in `allocation.mjs`) as a
|
|
33
|
+
* VIEW: the file is the record, and a mirror that fails loses nothing the file does not hold.
|
|
34
|
+
*/
|
|
35
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
36
|
+
import * as nodeFs from "node:fs";
|
|
37
|
+
import { join } from "node:path";
|
|
38
|
+
import { escapeControls } from "./projects.mjs";
|
|
39
|
+
|
|
40
|
+
export const CONSOLE_AUDIT_DIR = "console-audit";
|
|
41
|
+
export const CONSOLE_AUDIT_KEY = "console:audit";
|
|
42
|
+
export const CONSOLE_AUDIT_ACTIONS = Object.freeze(["login", "login-failed", "login-code", "logout", "refused", "write", "write-applied", "token-added", "token-revoked", "token-rotated", "sessions-ended", "serve-started"]);
|
|
43
|
+
export const CONSOLE_AUDIT_OUTCOMES = Object.freeze(["ok", "refused", "failed"]);
|
|
44
|
+
// A string field's cap, in code points, AFTER its controls are written out: a row stays small enough for the bounded tail
|
|
45
|
+
// read below whatever a caller hands in (a Host header, a refusal reason), and no field can bury the others.
|
|
46
|
+
export const CONSOLE_AUDIT_FIELD_MAX = 200;
|
|
47
|
+
// A write row's optional action name (issue #642: the console's action table and the panel's own writes), one short
|
|
48
|
+
// lowercase word with dashes: a closed charset rather than an escaped free string, so a reader can group rows by it and
|
|
49
|
+
// no caller can carry text in it. `action` itself stays the closed vocabulary above; `kind` only says which write.
|
|
50
|
+
export const CONSOLE_AUDIT_KIND_RE = /^[a-z0-9-]{1,40}$/;
|
|
51
|
+
// The SHAPE of an action and of an outcome (issue #761): what every value either list above has ever held, and what a
|
|
52
|
+
// value a later release adds must keep (a test holds every member to it). The writer stays closed (`auditRowBody` writes
|
|
53
|
+
// only the lists' members); the verifier links a row whose action or outcome it does not know but that has this shape,
|
|
54
|
+
// and counts it as `unknown`: such a row is shaped and linked, which is what a newer console writes, so it is not
|
|
55
|
+
// damage. Refusing it, as the verifier did before, reported an intact chain as broken at the first row a newer console
|
|
56
|
+
// wrote, and the older console then said "a row was changed" about a row nobody changed. Letter first, like every value
|
|
57
|
+
// so far, and `kind`'s cap of 40.
|
|
58
|
+
export const CONSOLE_AUDIT_ACTION_RE = /^[a-z][a-z0-9-]{0,39}$/;
|
|
59
|
+
export const CONSOLE_AUDIT_OUTCOME_RE = /^[a-z][a-z0-9-]{0,39}$/;
|
|
60
|
+
// Only names of this shape are the log's: the reaper and the verifier never touch or read anything else an operator put
|
|
61
|
+
// in the directory.
|
|
62
|
+
const MONTH_FILE_RE = /^\d{4}-\d{2}\.jsonl$/;
|
|
63
|
+
// The bounded read of a file's end, for its last line. A row is at most ten capped fields of 200 code points (4 bytes
|
|
64
|
+
// each in UTF-8 at worst) plus its keys, far under this.
|
|
65
|
+
const TAIL_BYTES = 65536;
|
|
66
|
+
// The append lock, beside the month files (the reaper and the verifier only look at month-file names).
|
|
67
|
+
export const APPEND_LOCK = ".append.lock";
|
|
68
|
+
const LOCK_WAIT_MS = 2000;
|
|
69
|
+
const LOCK_PAUSE = new Int32Array(new SharedArrayBuffer(4));
|
|
70
|
+
|
|
71
|
+
function processAlive(pid) {
|
|
72
|
+
try {
|
|
73
|
+
process.kill(pid, 0);
|
|
74
|
+
return true;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
return err?.code === "EPERM";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Hold `dir`'s append lock while `fn` runs. Sync, like the append it guards; waits in short sleeps, never spins hot.
|
|
82
|
+
*
|
|
83
|
+
* The lock file holds its holder's pid and a random token (`<pid> <hex>`), and a lock is STALE only when that pid is dead
|
|
84
|
+
* (`alive`, the same rule as the console's single-instance lock). Never by age: an age is the filesystem's mtime against
|
|
85
|
+
* this process's clock, two clocks, and a skew between them (or the clock-shifted CI run) would make every live lock look
|
|
86
|
+
* stale and let two writers in at once, which is the fork this lock exists to prevent. The audit directory is local to
|
|
87
|
+
* the host whose console writes it, so a pid names a process here. A lock still held, or still empty (a writer between
|
|
88
|
+
* its create and its pid), after `LOCK_WAIT_MS` makes the append throw, which refuses the write it was recording.
|
|
89
|
+
*
|
|
90
|
+
* The token is what makes a removal safe. Two writers can both judge the same dead lock: the first removes it and
|
|
91
|
+
* creates its own, and a second that removed by path would then remove that FRESH lock and both would append with the
|
|
92
|
+
* same `seq` and `prev`. So a takeover re-reads the lock just before it removes it and removes it only while it still
|
|
93
|
+
* holds exactly the stale text that was judged dead, and the `finally` removes the lock only while it still holds this
|
|
94
|
+
* writer's own token. Named residual: the window between that re-read and the unlink (no portable compare-and-unlink
|
|
95
|
+
* exists on a path), in which a writer that judged the same dead lock can create its fresh one and lose it to this
|
|
96
|
+
* unlink. Two takers have to read the same dead text and interleave inside that window, after a holder crashed.
|
|
97
|
+
*/
|
|
98
|
+
function withAppendLock(dir, fs, fn, alive) {
|
|
99
|
+
const lock = join(dir, APPEND_LOCK);
|
|
100
|
+
const token = `${process.pid} ${randomBytes(8).toString("hex")}\n`;
|
|
101
|
+
const readLock = () => {
|
|
102
|
+
try {
|
|
103
|
+
return fs.readFileSync(lock, "utf8");
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (err?.code === "ENOENT") return null;
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const started = Date.now();
|
|
110
|
+
for (;;) {
|
|
111
|
+
try {
|
|
112
|
+
fs.writeFileSync(lock, token, { flag: "wx", mode: 0o600 });
|
|
113
|
+
break;
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (err?.code !== "EEXIST") throw err;
|
|
116
|
+
}
|
|
117
|
+
const stale = readLock();
|
|
118
|
+
if (stale === null) continue;
|
|
119
|
+
const holder = Number.parseInt(stale, 10);
|
|
120
|
+
if (Number.isSafeInteger(holder) && holder > 0 && !alive(holder)) {
|
|
121
|
+
// Only the text judged dead goes: another writer may have taken it over since, and its fresh lock is live.
|
|
122
|
+
if (readLock() === stale) {
|
|
123
|
+
try {
|
|
124
|
+
fs.unlinkSync(lock);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
if (err?.code !== "ENOENT") throw err;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (Date.now() - started > LOCK_WAIT_MS) {
|
|
132
|
+
throw Object.assign(new Error(`the console audit log is locked by another writer (${lock}${Number.isSafeInteger(holder) ? `, pid ${holder}` : ""}); try again, or remove the file if no console command is running`), { code: "EAUDITLOCKED" });
|
|
133
|
+
}
|
|
134
|
+
Atomics.wait(LOCK_PAUSE, 0, 0, 10);
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
return fn();
|
|
138
|
+
} finally {
|
|
139
|
+
try {
|
|
140
|
+
// A lock that no longer holds our token was taken over (we were judged dead, or removed by hand): not ours.
|
|
141
|
+
if (readLock() === token) fs.unlinkSync(lock);
|
|
142
|
+
} catch {
|
|
143
|
+
// Already gone or unreadable: nothing of ours to remove.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sha256(text) {
|
|
149
|
+
return createHash("sha256").update(text).digest("hex");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A string field as it is stored: controls and the other characters that change what a reader SEES written out as
|
|
154
|
+
* `\u{XXXX}` (the worker's `escapeControls`, the same class the admin panel scrubs), then capped. Written out, not
|
|
155
|
+
* deleted: an audit row that silently lost a character would hide exactly what a forged Host header tried to smuggle.
|
|
156
|
+
*/
|
|
157
|
+
function auditString(value, field) {
|
|
158
|
+
if (typeof value !== "string") throw new Error(`console audit row: ${field} must be a string`);
|
|
159
|
+
const text = escapeControls(value);
|
|
160
|
+
const points = [...text];
|
|
161
|
+
return points.length > CONSOLE_AUDIT_FIELD_MAX ? points.slice(0, CONSOLE_AUDIT_FIELD_MAX).join("") : text;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Watch rows name the other participant without admitting an arbitrary peer object into the journal. */
|
|
165
|
+
export function consoleAuditDriver(value) {
|
|
166
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).sort().join() !== "name,role,session" || !/^[a-z0-9][a-z0-9._-]{0,31}$/.test(value.name ?? "") || value.role !== "operator" || !/^[a-f0-9]{12}$/.test(value.session ?? "")) return null;
|
|
167
|
+
return { name: value.name, role: value.role, session: value.session };
|
|
168
|
+
}
|
|
169
|
+
const WATCH_AUDIT_KINDS = new Set(["sandbox-watch-opening", "sandbox-watch-start", "sandbox-watch-end"]);
|
|
170
|
+
/** The row as stored, from a caller's input, or a throw naming the field that is wrong (the caller refuses its write). */
|
|
171
|
+
function auditRowBody(input) {
|
|
172
|
+
if (input === null || typeof input !== "object") throw new Error("console audit row must be an object");
|
|
173
|
+
const { actor, action, outcome } = input;
|
|
174
|
+
if (!CONSOLE_AUDIT_ACTIONS.includes(action)) throw new Error(`console audit row: unknown action ${JSON.stringify(escapeControls(String(action)))} (one of ${CONSOLE_AUDIT_ACTIONS.join(", ")})`);
|
|
175
|
+
if (!CONSOLE_AUDIT_OUTCOMES.includes(outcome)) throw new Error(`console audit row: unknown outcome ${JSON.stringify(escapeControls(String(outcome)))} (one of ${CONSOLE_AUDIT_OUTCOMES.join(", ")})`);
|
|
176
|
+
if (actor === null || typeof actor !== "object") throw new Error("console audit row: actor must be an object with name, role and session");
|
|
177
|
+
const body = {
|
|
178
|
+
actor: {
|
|
179
|
+
name: auditString(actor.name, "actor.name"),
|
|
180
|
+
role: auditString(actor.role, "actor.role"),
|
|
181
|
+
session: actor.session === null || actor.session === undefined ? null : auditString(actor.session, "actor.session"),
|
|
182
|
+
},
|
|
183
|
+
action,
|
|
184
|
+
};
|
|
185
|
+
const optional = (field) => (input[field] === undefined || input[field] === null ? {} : { [field]: auditString(input[field], field) });
|
|
186
|
+
const absent = (field) => input[field] === undefined || input[field] === null;
|
|
187
|
+
if (!absent("kind") && !(typeof input.kind === "string" && CONSOLE_AUDIT_KIND_RE.test(input.kind))) throw new Error("console audit row: kind must be 1 to 40 of a-z, 0-9 and -");
|
|
188
|
+
if (!absent("reserve") && !(Number.isSafeInteger(input.reserve) && input.reserve >= 0)) throw new Error("console audit row: reserve must be a non-negative whole number of micro-dollars");
|
|
189
|
+
if (!absent("count") && !(Number.isSafeInteger(input.count) && input.count >= 1)) throw new Error("console audit row: count must be a positive whole number");
|
|
190
|
+
const number = (field) => (absent(field) ? {} : { [field]: input[field] });
|
|
191
|
+
const driver = consoleAuditDriver(input.driver);
|
|
192
|
+
if (WATCH_AUDIT_KINDS.has(input.kind) ? !driver : !absent("driver")) throw new Error("console audit row: driver is required only on watch attachment rows");
|
|
193
|
+
// In reading order: who, did what (and which write), to what, how it went, why, what it holds and how many, then the
|
|
194
|
+
// digests and the ticket that tie it to a change.
|
|
195
|
+
return { ...body, ...(absent("kind") ? {} : { kind: input.kind }), ...optional("target"), outcome, ...optional("reason"), ...number("reserve"), ...number("count"), ...optional("beforeDigest"), ...optional("afterDigest"), ...optional("ticket"), ...(driver ? { driver } : {}) };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Whether a parsed row's optional issue #642 fields, where present, are what `auditRowBody` writes: `kind` in its
|
|
200
|
+
* charset, `reserve` a non-negative safe integer, `count` a positive one. A row without them is a row from before them,
|
|
201
|
+
* and is fine.
|
|
202
|
+
*/
|
|
203
|
+
function optionalFieldsWellFormed(row) {
|
|
204
|
+
if (row.kind !== undefined && !(typeof row.kind === "string" && CONSOLE_AUDIT_KIND_RE.test(row.kind))) return false;
|
|
205
|
+
if (row.reserve !== undefined && !(Number.isSafeInteger(row.reserve) && row.reserve >= 0)) return false;
|
|
206
|
+
if (row.count !== undefined && !(Number.isSafeInteger(row.count) && row.count >= 1)) return false;
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Whether a parsed row's action or outcome is a string of `re`'s shape (issue #761). */
|
|
211
|
+
function shapedWord(value, re) {
|
|
212
|
+
return typeof value === "string" && re.test(value);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The `YYYY-MM.jsonl` names in `dir`, oldest first; [] when the directory does not exist. Other errors throw. */
|
|
216
|
+
function monthFiles(dir, fs) {
|
|
217
|
+
let names;
|
|
218
|
+
try {
|
|
219
|
+
names = fs.readdirSync(dir);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
if (err?.code === "ENOENT") return [];
|
|
222
|
+
throw err;
|
|
223
|
+
}
|
|
224
|
+
return names.filter((n) => MONTH_FILE_RE.test(n)).sort();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The last non-empty line of a file, read from its end, or null for an empty file. */
|
|
228
|
+
function lastLine(path, fs) {
|
|
229
|
+
const fd = fs.openSync(path, "r");
|
|
230
|
+
try {
|
|
231
|
+
const size = fs.fstatSync(fd).size;
|
|
232
|
+
const length = Math.min(size, TAIL_BYTES);
|
|
233
|
+
const buf = Buffer.alloc(length);
|
|
234
|
+
let got = 0;
|
|
235
|
+
while (got < length) {
|
|
236
|
+
const n = fs.readSync(fd, buf, got, length - got, size - length + got);
|
|
237
|
+
if (n === 0) break;
|
|
238
|
+
got += n;
|
|
239
|
+
}
|
|
240
|
+
const lines = buf.subarray(0, got).toString("utf8").split("\n").filter((l) => l !== "");
|
|
241
|
+
if (lines.length === 0) return null;
|
|
242
|
+
// A tail read that started mid-file and holds one partial line cannot vouch for it: refuse rather than chain a
|
|
243
|
+
// fragment.
|
|
244
|
+
if (lines.length === 1 && size > length) throw new Error(`the console audit file ${path} ends in a line longer than ${TAIL_BYTES} bytes; run \`pi-dispatch-console audit verify\``);
|
|
245
|
+
return lines[lines.length - 1];
|
|
246
|
+
} finally {
|
|
247
|
+
fs.closeSync(fd);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The chain's head on disk: `{ seq, hash, month }`, seq 0 and hash null when no row exists. Read from the newest month
|
|
253
|
+
* file's last line, so a row in a new month links to the old month's last. A newest file that exists but is empty is
|
|
254
|
+
* skipped for the one before it (a crash between create and write leaves one).
|
|
255
|
+
*/
|
|
256
|
+
function readHead(dir, fs) {
|
|
257
|
+
const files = monthFiles(dir, fs);
|
|
258
|
+
for (let i = files.length - 1; i >= 0; i--) {
|
|
259
|
+
const line = lastLine(join(dir, files[i]), fs);
|
|
260
|
+
if (line === null) continue;
|
|
261
|
+
let row;
|
|
262
|
+
try {
|
|
263
|
+
row = JSON.parse(line);
|
|
264
|
+
} catch {
|
|
265
|
+
row = null;
|
|
266
|
+
}
|
|
267
|
+
if (row === null || typeof row !== "object" || !Number.isInteger(row.seq) || row.seq < 1) {
|
|
268
|
+
throw new Error(`the last line of the console audit file ${join(dir, files[i])} is not an audit row, so the next row cannot link to it; run \`pi-dispatch-console audit verify\` and move the damaged file aside`);
|
|
269
|
+
}
|
|
270
|
+
return { seq: row.seq, hash: sha256(line), month: files[i].slice(0, 7) };
|
|
271
|
+
}
|
|
272
|
+
return { seq: 0, hash: null, month: null };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Refuse a directory or file that is a symlink: the console's own account writes here, and a link would aim it elsewhere. */
|
|
276
|
+
function refuseLink(path, fs, what) {
|
|
277
|
+
let st;
|
|
278
|
+
try {
|
|
279
|
+
st = fs.lstatSync(path);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
if (err?.code === "ENOENT") return;
|
|
282
|
+
throw err;
|
|
283
|
+
}
|
|
284
|
+
if (st.isSymbolicLink()) throw new Error(`the console audit ${what} ${path} is a symlink; replace it with a real ${what} owned by this account`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The audit writer. `append(row)` is SYNC and THROWS on any failure (a bad row, a full disk, a damaged head): the caller
|
|
289
|
+
* refuses the write it was about to make, because a change the record does not hold is the outcome this log exists to
|
|
290
|
+
* prevent. Returns the stored row. `head()` is the chain's head on disk, `{ seq, hash }`.
|
|
291
|
+
*/
|
|
292
|
+
export function makeConsoleAudit({ logsDir, fs = nodeFs, now = () => Date.now(), alive = processAlive }) {
|
|
293
|
+
const dir = join(logsDir, CONSOLE_AUDIT_DIR);
|
|
294
|
+
return {
|
|
295
|
+
dir,
|
|
296
|
+
append(input) {
|
|
297
|
+
const body = auditRowBody(input);
|
|
298
|
+
refuseLink(dir, fs, "directory");
|
|
299
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
300
|
+
return withAppendLock(dir, fs, () => {
|
|
301
|
+
const head = readHead(dir, fs);
|
|
302
|
+
const at = new Date(now()).toISOString();
|
|
303
|
+
// A clock that stepped back across a month boundary still appends to the NEWEST file: the chain is read file by
|
|
304
|
+
// file in name order, and a row written into an older file would sit before rows it follows.
|
|
305
|
+
const month = head.month !== null && head.month > at.slice(0, 7) ? head.month : at.slice(0, 7);
|
|
306
|
+
const row = { at, seq: head.seq + 1, prev: head.hash, ...body };
|
|
307
|
+
const path = join(dir, `${month}.jsonl`);
|
|
308
|
+
refuseLink(path, fs, "file");
|
|
309
|
+
fs.appendFileSync(path, `${JSON.stringify(row)}\n`, { flag: "a", mode: 0o600 });
|
|
310
|
+
return row;
|
|
311
|
+
}, alive);
|
|
312
|
+
},
|
|
313
|
+
head() {
|
|
314
|
+
const { seq, hash } = readHead(dir, fs);
|
|
315
|
+
return { seq, hash };
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Walk the chain, oldest file first: `{ ok: true, rows, startsAt }` or `{ ok: false, rows, broken: { file, line, reason } }`
|
|
322
|
+
* with the FIRST broken link (`line` is 1-based within `file`; `rows` counts the rows read before it). The oldest
|
|
323
|
+
* surviving row's `prev` cannot be checked once the reaper took the file before it, so it is not: a chain that starts
|
|
324
|
+
* there is reported as `startsAt: { file, seq }`, not as broken. `startsAt` is null when the chain starts at its very
|
|
325
|
+
* first row (seq 1, prev null). Never throws: an unreadable directory or file is the broken link.
|
|
326
|
+
*
|
|
327
|
+
* The intact shape also carries `unknown: n` when n of its `rows` have an action or outcome this build does not know but
|
|
328
|
+
* of the shape the console writes (issue #761: shaped and linked, which a newer console writes), and only then. A broken
|
|
329
|
+
* result never carries it: the row before a break whose line shows no matching `prev` passed its own checks and is
|
|
330
|
+
* counted in `rows`, yet nothing vouches for it, so a count there would vouch for a row the chain does not, and no
|
|
331
|
+
* reader shows it for a broken chain.
|
|
332
|
+
*
|
|
333
|
+
* `withLast` (issue #737, the console's audit screen) adds three fields to both shapes, for a reader that marks rows by
|
|
334
|
+
* their place (the place is the one thing that tells two rows apart reliably: a damaged line has no seq, and a tampered
|
|
335
|
+
* file can hold one seq twice):
|
|
336
|
+
*
|
|
337
|
+
* - `last: { file, line, seq }`, the last row the chain VOUCHES for (null when none). A row is vouched for only by
|
|
338
|
+
* the line after it, when that line's `prev` matches the row's hash. On an intact chain `last` is the newest row
|
|
339
|
+
* walked, though no line vouches for that one yet (none follows it). At a break whose line does not show a
|
|
340
|
+
* `prev` that matches the row before it (it does not parse, its `prev` is not a hash or null, or its `prev` is
|
|
341
|
+
* another hash), `last` is the row before THAT one: an edit to the row before the break leaves that row's own line
|
|
342
|
+
* well formed and moves only its hash, so a line after it that does not show a matching `prev` cannot tell an
|
|
343
|
+
* edited row from an intact one, and the row before the break is not vouched for. That is judged before the line's
|
|
344
|
+
* action, outcome and fields are, so an unknown action does not hide it. Only a line whose `prev` matched and that
|
|
345
|
+
* then fails another check (its seq, action, outcome or fields) is damaged alone, and the row before it stays
|
|
346
|
+
* vouched for. A file that cannot be read stops the walk, and the row before it stays as the newest row read.
|
|
347
|
+
* - `suspect: { file, line, seq }`, that row before a break whose line does not show a matching `prev`, which the
|
|
348
|
+
* chain walked but cannot vouch for, or null for every other result.
|
|
349
|
+
* - `files: [{ file, seq, rows }]`, oldest first: each month file the walk passed a row in, the seq of its first row
|
|
350
|
+
* (always its line 1: a line that does not pass ends the walk) and how many rows the walk passed in it. Row `s` of an
|
|
351
|
+
* entry is then at line `s - seq + 1` of its file, so the screen can open the page that holds a row by its number
|
|
352
|
+
* without walking every row written since (it reads that place and checks the seq there, falling back to the walk
|
|
353
|
+
* when the file no longer agrees). One entry a month, so it stays small.
|
|
354
|
+
*
|
|
355
|
+
* Opt-in, so the result doctor, the CLI and the tests compare whole stays as it was.
|
|
356
|
+
*/
|
|
357
|
+
export function verifyConsoleAudit({ logsDir, fs = nodeFs, withLast = false }) {
|
|
358
|
+
const dir = join(logsDir, CONSOLE_AUDIT_DIR);
|
|
359
|
+
let last = null; // { file, line, seq } of the last good row, for `withLast`
|
|
360
|
+
let beforeLast = null; // the good row before `last`, which a line that does not link to `last` leaves as the last vouched for
|
|
361
|
+
const index = []; // { file, seq, rows } per month file the walk passed a row in, for `withLast`
|
|
362
|
+
let unknown = 0; // rows that passed every check with an action or outcome this build does not know (issue #761)
|
|
363
|
+
// `unknown` only on an intact chain and only when some row has it (issue #761), so a chain of known rows answers
|
|
364
|
+
// exactly as it always did (doctor, the CLI and the tests compare this result whole), and a broken one never counts
|
|
365
|
+
// the row before its break, which nothing vouches for.
|
|
366
|
+
const shaped = (result, unlinked = false) => {
|
|
367
|
+
const counted = result.ok && unknown > 0 ? { ...result, unknown } : result;
|
|
368
|
+
if (!withLast) return counted;
|
|
369
|
+
return unlinked ? { ...counted, last: beforeLast, suspect: last, files: index } : { ...counted, last, suspect: null, files: index };
|
|
370
|
+
};
|
|
371
|
+
let files;
|
|
372
|
+
try {
|
|
373
|
+
files = monthFiles(dir, fs);
|
|
374
|
+
} catch (err) {
|
|
375
|
+
return shaped({ ok: false, rows: 0, broken: { file: null, line: 0, reason: `the audit directory ${dir} cannot be read (${err?.code ?? "error"})` } });
|
|
376
|
+
}
|
|
377
|
+
let rows = 0;
|
|
378
|
+
let prior = null; // { seq, hash } of the last good row
|
|
379
|
+
let startsAt = null;
|
|
380
|
+
for (const file of files) {
|
|
381
|
+
let i = -1;
|
|
382
|
+
try {
|
|
383
|
+
for (const line of fileLines(fs, join(dir, file))) {
|
|
384
|
+
i += 1;
|
|
385
|
+
let row;
|
|
386
|
+
try {
|
|
387
|
+
row = JSON.parse(line);
|
|
388
|
+
} catch {
|
|
389
|
+
row = undefined;
|
|
390
|
+
}
|
|
391
|
+
// Whether this line vouches for the row before it (`withLast`, see above), decided before anything else about
|
|
392
|
+
// the line is judged. `prior.hash` is a sha256, so only a well-formed `prev` can equal it. The first row walked
|
|
393
|
+
// has no row before it to vouch for.
|
|
394
|
+
const linked = prior !== null && row?.prev === prior.hash;
|
|
395
|
+
const broken = (reason) => shaped({ ok: false, rows, broken: { file, line: i + 1, reason } }, !linked);
|
|
396
|
+
if (row === undefined) return broken("the line is not a JSON row (damaged, or written by something else)");
|
|
397
|
+
if (row === null || typeof row !== "object" || Array.isArray(row) || !Number.isInteger(row.seq) || row.seq < 1 || !(row.prev === null || (typeof row.prev === "string" && /^[0-9a-f]{64}$/.test(row.prev)))) {
|
|
398
|
+
return broken("the row has no valid seq and prev");
|
|
399
|
+
}
|
|
400
|
+
// Known, or of the shape a newer console's value keeps (issue #761): linked either way, and an unknown one is
|
|
401
|
+
// counted below once the row passed every other check. Judged after `linked`, so an edited unknown row is still
|
|
402
|
+
// named by the broken link of the row after it, exactly as an edited known row is.
|
|
403
|
+
const known = CONSOLE_AUDIT_ACTIONS.includes(row.action) && CONSOLE_AUDIT_OUTCOMES.includes(row.outcome);
|
|
404
|
+
if (!known && !(shapedWord(row.action, CONSOLE_AUDIT_ACTION_RE) && shapedWord(row.outcome, CONSOLE_AUDIT_OUTCOME_RE))) {
|
|
405
|
+
return broken("the row's action or outcome is not of the shape the console writes");
|
|
406
|
+
}
|
|
407
|
+
if (WATCH_AUDIT_KINDS.has(row.kind) ? !consoleAuditDriver(row.driver) : row.driver !== undefined) return broken("the row's watch driver is not one the console writes");
|
|
408
|
+
if (!optionalFieldsWellFormed(row)) return broken("the row's kind, reserve or count is not one the console writes");
|
|
409
|
+
if (prior === null) {
|
|
410
|
+
if (row.prev === null) {
|
|
411
|
+
if (row.seq !== 1) return broken(`the first row has no prev but seq ${row.seq}; the chain's start was changed`);
|
|
412
|
+
} else {
|
|
413
|
+
// The rows before it were reaped (retention), or removed: the two look alike, so the start is reported.
|
|
414
|
+
startsAt = { file, seq: row.seq };
|
|
415
|
+
}
|
|
416
|
+
} else {
|
|
417
|
+
if (!linked) return broken(`prev does not match the row before it (seq ${prior.seq}): a row was changed, removed or moved`);
|
|
418
|
+
if (row.seq !== prior.seq + 1) return broken(`seq ${row.seq} follows seq ${prior.seq}`);
|
|
419
|
+
}
|
|
420
|
+
prior = { seq: row.seq, hash: sha256(line) };
|
|
421
|
+
beforeLast = last;
|
|
422
|
+
last = { file, line: i + 1, seq: row.seq };
|
|
423
|
+
if (index.length === 0 || index[index.length - 1].file !== file) index.push({ file, seq: row.seq, rows: 0 });
|
|
424
|
+
index[index.length - 1].rows += 1;
|
|
425
|
+
rows += 1;
|
|
426
|
+
if (!known) unknown += 1;
|
|
427
|
+
}
|
|
428
|
+
} catch (err) {
|
|
429
|
+
return shaped({ ok: false, rows, broken: { file, line: i + 1, reason: `the file cannot be read (${err?.code ?? "error"})` } });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return shaped({ ok: true, rows, startsAt });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** The most rows one `readConsoleAudit` call returns, whatever it is asked for. */
|
|
436
|
+
export const CONSOLE_AUDIT_READ_MAX = 500;
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* An audit cursor, `YYYY-MM:<line>`: the month file's name without `.jsonl` and a 1-based line number in it (see
|
|
440
|
+
* `readConsoleAudit`). The line is at most 16 digits and must be a safe integer.
|
|
441
|
+
*/
|
|
442
|
+
export const CONSOLE_AUDIT_CURSOR_RE = /^(\d{4}-\d{2}):([1-9]\d{0,15})$/;
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The newest audit rows first, for the console's audit screen (issue #641): `{ rows, next }`, or `{ rows: [], next: null,
|
|
446
|
+
* unreadable }` when the directory cannot be listed or `before` is not a cursor. Never throws.
|
|
447
|
+
*
|
|
448
|
+
* Each line is parsed and returned as it is stored. A line that is not an audit row (not JSON, not an object, no
|
|
449
|
+
* positive integer `seq`) is NOT skipped: it comes back as `{ broken: true, file, line }` (`line` 1-based in `file`), in
|
|
450
|
+
* its place, because an audit screen that quietly drops a damaged line hides exactly what `verifyConsoleAudit` exists to
|
|
451
|
+
* name. A file that cannot be read (or stops being readable part-way) is one such entry with `line: 0`.
|
|
452
|
+
*
|
|
453
|
+
* THE CURSOR IS A PLACE IN THE FILES, NOT A ROW: `YYYY-MM:<line>` (`CONSOLE_AUDIT_CURSOR_RE`), the month file and the
|
|
454
|
+
* 1-based line an entry was read from (a read failure's entry sits one line past the last line read). `before` returns
|
|
455
|
+
* only entries above it in file order (an older month file, or the same file at a lower line), and `next` is the last
|
|
456
|
+
* returned entry's cursor, or null when nothing older exists. Every entry has its own place, so a page can end on any
|
|
457
|
+
* one of a run of damaged lines and the next page starts at the line under it. A place stays put because the files only
|
|
458
|
+
* ever grow at the end of the newest one (the reaper takes whole files). The seq was the cursor once, with a damaged
|
|
459
|
+
* line at a half step beside the good row before it; it was refuted, because two damaged lines in a row shared one half
|
|
460
|
+
* step (a page ending on the first skipped the second) and a file's leading damaged line shared it with the previous
|
|
461
|
+
* file's trailing one.
|
|
462
|
+
*
|
|
463
|
+
* BOUNDED MEMORY: files are walked newest first, each through the chunked `fileLines`, and only the newest `limit`
|
|
464
|
+
* qualifying entries of a file are held (a ring buffer), so a month file of any size costs `limit` entries of memory. A
|
|
465
|
+
* file newer than the cursor is not opened, and the cursor's own file is read only up to the cursor's line.
|
|
466
|
+
*/
|
|
467
|
+
export function readConsoleAudit({ logsDir, limit = 100, before = null, fs = nodeFs, positions = false } = {}) {
|
|
468
|
+
const cap = Math.min(CONSOLE_AUDIT_READ_MAX, Math.max(1, Number.isFinite(limit) ? Math.floor(limit) : 100));
|
|
469
|
+
// `positions` (issue #737) adds each entry's own place, `YYYY-MM:<line>`, parallel to `rows`: the console's audit screen
|
|
470
|
+
// compares it with `verifyConsoleAudit`'s `broken` and `last` to mark a row damaged, not verified or not yet checked,
|
|
471
|
+
// and a row's id on the page is built from it when its seq cannot be one. Opt-in, so the shape callers compare whole is
|
|
472
|
+
// unchanged.
|
|
473
|
+
const unread = (unreadable) => (positions ? { rows: [], next: null, positions: [], unreadable } : { rows: [], next: null, unreadable });
|
|
474
|
+
let bound = null; // { file, line }: entries strictly above this place
|
|
475
|
+
if (before !== null && before !== undefined) {
|
|
476
|
+
const m = typeof before === "string" ? CONSOLE_AUDIT_CURSOR_RE.exec(before) : null;
|
|
477
|
+
if (m === null || !Number.isSafeInteger(Number(m[2]))) return unread("the cursor is not an audit position (YYYY-MM:line)");
|
|
478
|
+
bound = { file: `${m[1]}.jsonl`, line: Number(m[2]) };
|
|
479
|
+
}
|
|
480
|
+
const dir = join(logsDir, CONSOLE_AUDIT_DIR);
|
|
481
|
+
let files;
|
|
482
|
+
try {
|
|
483
|
+
files = monthFiles(dir, fs);
|
|
484
|
+
} catch (err) {
|
|
485
|
+
return unread(`the audit directory cannot be read (${err?.code ?? "error"})`);
|
|
486
|
+
}
|
|
487
|
+
const out = []; // newest first, each { entry, pos }
|
|
488
|
+
let more = false;
|
|
489
|
+
for (let f = files.length - 1; f >= 0 && !more; f--) {
|
|
490
|
+
const file = files[f];
|
|
491
|
+
if (bound !== null && file > bound.file) continue;
|
|
492
|
+
// The first line that is NOT this page's: the cursor's own line in its file, none in an older one.
|
|
493
|
+
const stop = bound !== null && file === bound.file ? bound.line : Infinity;
|
|
494
|
+
const want = cap - out.length;
|
|
495
|
+
const ring = new Array(want);
|
|
496
|
+
let held = 0; // qualifying entries seen in this file
|
|
497
|
+
const keep = (entry, line) => {
|
|
498
|
+
ring[held % want] = { entry, pos: `${file.slice(0, 7)}:${line}` };
|
|
499
|
+
held += 1;
|
|
500
|
+
};
|
|
501
|
+
let i = 0;
|
|
502
|
+
try {
|
|
503
|
+
for (const line of fileLines(fs, join(dir, file))) {
|
|
504
|
+
i += 1;
|
|
505
|
+
if (i >= stop) break;
|
|
506
|
+
let row;
|
|
507
|
+
try {
|
|
508
|
+
row = JSON.parse(line);
|
|
509
|
+
} catch {
|
|
510
|
+
row = null;
|
|
511
|
+
}
|
|
512
|
+
if (row === null || typeof row !== "object" || Array.isArray(row) || !Number.isInteger(row.seq) || row.seq < 1) keep({ broken: true, file, line: i }, i);
|
|
513
|
+
else keep(row, i);
|
|
514
|
+
}
|
|
515
|
+
} catch (err) {
|
|
516
|
+
if (i + 1 < stop) keep({ broken: true, file, line: 0, reason: err?.code ?? "error" }, i + 1);
|
|
517
|
+
}
|
|
518
|
+
const n = Math.min(held, want);
|
|
519
|
+
for (let k = 1; k <= n; k++) out.push(ring[(held - k) % want]);
|
|
520
|
+
if (held > want) more = true;
|
|
521
|
+
// A page that filled exactly at the end of a file: an older file may still hold rows, so say there is more.
|
|
522
|
+
else if (out.length === cap && f > 0) more = true;
|
|
523
|
+
}
|
|
524
|
+
const page = { rows: out.map((x) => x.entry), next: more && out.length > 0 ? out[out.length - 1].pos : null };
|
|
525
|
+
return positions ? { ...page, positions: out.map((x) => x.pos) } : page;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* The lines of `path`, read a chunk at a time, never the whole file at once: a month file can grow past what one
|
|
530
|
+
* string holds (V8's limit is about 512 MiB), and verify must still walk it rather than call it broken. A line is cut
|
|
531
|
+
* at the newline BYTE, so a multi-byte character split across two chunks is decoded whole. The newline after the last
|
|
532
|
+
* row ends nothing; a file cut mid-row yields its fragment as a last line, which the caller judges.
|
|
533
|
+
*/
|
|
534
|
+
function* fileLines(fs, path, chunkBytes = 1 << 20) {
|
|
535
|
+
const fd = fs.openSync(path, "r");
|
|
536
|
+
try {
|
|
537
|
+
const buf = Buffer.alloc(chunkBytes);
|
|
538
|
+
let carry = [];
|
|
539
|
+
for (;;) {
|
|
540
|
+
const n = fs.readSync(fd, buf, 0, chunkBytes, null);
|
|
541
|
+
if (n === 0) break;
|
|
542
|
+
let start = 0;
|
|
543
|
+
for (let at = buf.indexOf(10, 0); at !== -1 && at < n; at = buf.indexOf(10, at + 1)) {
|
|
544
|
+
carry.push(Buffer.from(buf.subarray(start, at)));
|
|
545
|
+
yield Buffer.concat(carry).toString("utf8");
|
|
546
|
+
carry = [];
|
|
547
|
+
start = at + 1;
|
|
548
|
+
}
|
|
549
|
+
if (start < n) carry.push(Buffer.from(buf.subarray(start, n)));
|
|
550
|
+
}
|
|
551
|
+
if (carry.length > 0) yield Buffer.concat(carry).toString("utf8");
|
|
552
|
+
} finally {
|
|
553
|
+
fs.closeSync(fd);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* The reaper of the audit files (a sibling of `makeAllocationLogReaper`), on `PI_CONSOLE_AUDIT_RETENTION_DAYS`, which
|
|
559
|
+
* inherits `PI_LOG_RETENTION_DAYS` when unset (issue #761: its own setting, so shortening the run history for privacy
|
|
560
|
+
* does not shorten the security trail; the caller passes the resolved days): a `YYYY-MM.jsonl` file whose last write
|
|
561
|
+
* (mtime) is older than the retention goes, 0 keeps everything, nothing of another name is touched. The NEWEST file is never reaped, however old: it holds the chain's
|
|
562
|
+
* head, and without it the next row would start a new chain at seq 1 that `verifyConsoleAudit` cannot tell from a
|
|
563
|
+
* forged one. Never throws.
|
|
564
|
+
*/
|
|
565
|
+
export function makeConsoleAuditReaper({ logsDir, retentionDays, fs = nodeFs, log = () => {}, now = () => Date.now() }) {
|
|
566
|
+
const dir = join(logsDir, CONSOLE_AUDIT_DIR);
|
|
567
|
+
return function reapConsoleAudit() {
|
|
568
|
+
if (retentionDays === 0) return;
|
|
569
|
+
let names;
|
|
570
|
+
try {
|
|
571
|
+
names = monthFiles(dir, fs);
|
|
572
|
+
} catch (err) {
|
|
573
|
+
log("console_audit_reaper_skipped", { reason: err?.code ?? "error" });
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const cutoff = now() - retentionDays * 86400000;
|
|
577
|
+
for (const name of names.slice(0, -1)) {
|
|
578
|
+
try {
|
|
579
|
+
const st = fs.lstatSync(join(dir, name));
|
|
580
|
+
if (st.isFile() && st.mtimeMs < cutoff) {
|
|
581
|
+
fs.unlinkSync(join(dir, name));
|
|
582
|
+
log("reaped_console_audit", { file: name });
|
|
583
|
+
}
|
|
584
|
+
} catch (err) {
|
|
585
|
+
log("console_audit_reaper_skipped", { file: name, reason: err?.code ?? "error" });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Best effort: the row onto `console:audit` (LPUSH, then LTRIM to `max`), for a screen that lists recent rows without
|
|
593
|
+
* reading files. Resolves true when both landed, false otherwise; never throws, and logs nothing (the caller has the
|
|
594
|
+
* row and knows the file holds it).
|
|
595
|
+
*/
|
|
596
|
+
export async function mirrorRow(redis, row, max = 500) {
|
|
597
|
+
try {
|
|
598
|
+
await redis.lpush(CONSOLE_AUDIT_KEY, JSON.stringify(row));
|
|
599
|
+
await redis.ltrim(CONSOLE_AUDIT_KEY, 0, max - 1);
|
|
600
|
+
return true;
|
|
601
|
+
} catch {
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
}
|