@edgehero/pi-dispatch 4.3.1 → 5.1.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 +21 -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 +118 -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 +136 -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 +325 -0
- package/src/console-client.mjs +848 -0
- package/src/console-devices.mjs +372 -0
- package/src/console-install.mjs +239 -0
- package/src/console-open.mjs +99 -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 +1657 -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/envelope.mjs +5 -3
- 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 +4 -2
- 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 +12 -2
- 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 +36 -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 +402 -53
- 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,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operator console's tokens file (issue #640, INT-CONSOLE-TOKENS-FILE-CONTRACT).
|
|
3
|
+
*
|
|
4
|
+
* One JSON file, `{version: 1, generation, tokens: [{name, role, sha256, createdAt}]}`, at PI_CONSOLE_TOKENS_FILE
|
|
5
|
+
* (default `console-tokens.json` in the deployment directory). The console reads it to sign a browser in; its writers
|
|
6
|
+
* are the CLI (`pi-dispatch-console token ...`), `pi-dispatch console bootstrap` (`console-bootstrap.mjs`, the first
|
|
7
|
+
* operator) and the console's setup page (`admin/src/setup-actions.mjs`, a viewer and its undo), all through the
|
|
8
|
+
* writers below, and doctor judges it. Only the SHA-256 of each token is kept, so a copy of the file signs nobody in.
|
|
9
|
+
*
|
|
10
|
+
* ACROSS VERSIONS (issue #761). Within version 1 a later release may add only what an older reader can safely ignore:
|
|
11
|
+
* a role (an entry whose role this build does not know signs nobody in here, which is narrower) and a field or a
|
|
12
|
+
* top-level key (carried and ignored). Such an entry is KEPT, never dropped: `parseTokensFile` puts it in `carry.kept`,
|
|
13
|
+
* apart from `tokens`, so every reader of `tokens` (the sign-in, the login code, the operator count, the setup grants,
|
|
14
|
+
* doctor) never meets it, and every writer writes it back. A field of an entry or a key of the file that would NARROW
|
|
15
|
+
* who may sign in (an expiry, a scope, another hash, a file-wide minimum role) raises `version`, which every older
|
|
16
|
+
* reader refuses as a whole file: carried and ignored, it would widen access on an older console.
|
|
17
|
+
*
|
|
18
|
+
* WHY SHA-256 AND NOT A PASSWORD KDF (bcrypt, scrypt, argon2): a KDF exists to make guessing a LOW-entropy secret slow.
|
|
19
|
+
* These tokens are 32 random bytes (256 bits) from the CSPRNG, which no amount of guessing reaches, so a slow hash adds
|
|
20
|
+
* only CPU per login and a dependency. A fast hash of a full-entropy secret is the standard construction for API
|
|
21
|
+
* tokens. Do not "fix" this to a KDF; a token an operator typed would be the case for one, and none is.
|
|
22
|
+
*
|
|
23
|
+
* WHY THE FILE IS JUDGED LIKE ssh JUDGES authorized_keys: whoever can write it can add an operator, and whoever can read
|
|
24
|
+
* it learns who exists. So a symlink, anything but a regular file, any group or world bit, or another owner than the
|
|
25
|
+
* process is a refusal, never a warning. The read opens with O_NOFOLLOW and re-checks the open descriptor, so a link
|
|
26
|
+
* swapped in between the lstat and the read is refused too.
|
|
27
|
+
*
|
|
28
|
+
* Writes are tmp + rename in the same directory (a tmp of its own, created exclusively at 0600), a symlinked target is
|
|
29
|
+
* refused, and the file is re-read right before the rename: a concurrent writer's change is a refusal, never a silent
|
|
30
|
+
* lost update.
|
|
31
|
+
*/
|
|
32
|
+
import * as nodeFs from "node:fs";
|
|
33
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
34
|
+
import { resolve } from "node:path";
|
|
35
|
+
|
|
36
|
+
/** Every plaintext token starts with this, so a leaked one is greppable and a secret scanner can match it. */
|
|
37
|
+
export const TOKEN_PREFIX = "pdc_";
|
|
38
|
+
|
|
39
|
+
/** A token's name: lowercase, starts with a letter or digit, at most 32 characters. It lands in audit rows and logs. */
|
|
40
|
+
export const TOKEN_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,31}$/;
|
|
41
|
+
|
|
42
|
+
/** The roles a token can carry: an operator may write, a viewer only reads. */
|
|
43
|
+
export const TOKEN_ROLES = Object.freeze(["operator", "viewer", "agent"]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The shape of a role (issue #761): every role above has it, and a role a later release adds must keep it. An entry
|
|
47
|
+
* whose role this build does not know but has this shape is kept and signs in nowhere here; any other role is a file
|
|
48
|
+
* this build cannot read, refused as before.
|
|
49
|
+
*/
|
|
50
|
+
export const TOKEN_ROLE_RE = /^[a-z][a-z0-9-]{0,31}$/;
|
|
51
|
+
|
|
52
|
+
/** An entry's four fields, the only ones `tokens` holds; the rest of a known entry is carried (`carry.extras`). */
|
|
53
|
+
const ENTRY_FIELDS = Object.freeze(["name", "role", "sha256", "createdAt"]);
|
|
54
|
+
/** The top-level keys this build reads; any other is carried (`carry.top`). */
|
|
55
|
+
const FILE_KEYS = Object.freeze(["version", "generation", "tokens"]);
|
|
56
|
+
|
|
57
|
+
const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
|
|
58
|
+
const DEFAULT_FILE = "console-tokens.json";
|
|
59
|
+
const ADD_HINT = "`pi-dispatch-console token add --name <name> --role operator`";
|
|
60
|
+
|
|
61
|
+
/** A new plaintext token: the prefix plus 32 random bytes as base64url (43 characters). Shown once, never stored. */
|
|
62
|
+
export function newToken(random = randomBytes) {
|
|
63
|
+
return `${TOKEN_PREFIX}${random(32).toString("base64url")}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The SHA-256 of a token, lowercase hex: what the file keeps. */
|
|
67
|
+
export function hashToken(token) {
|
|
68
|
+
return createHash("sha256").update(String(token), "utf8").digest("hex");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The tokens file's absolute path: PI_CONSOLE_TOKENS_FILE, or `console-tokens.json`, resolved against `dir`. */
|
|
72
|
+
export function tokensFilePath(env, dir) {
|
|
73
|
+
const configured = typeof env?.PI_CONSOLE_TOKENS_FILE === "string" ? env.PI_CONSOLE_TOKENS_FILE.trim() : "";
|
|
74
|
+
return resolve(dir, configured || DEFAULT_FILE);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `key` set on `target` as an own data property, whatever its name (`__proto__` included): a carried key from a file. */
|
|
78
|
+
function defineOwn(target, key, value) {
|
|
79
|
+
Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
|
|
80
|
+
return target;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Parse and validate the file's text. Returns `{version: 1, generation, tokens}` with each entry of a known role reduced
|
|
85
|
+
* to its four fields, or THROWS an Error whose message is a sentence an operator can act on. A version other than 1 is
|
|
86
|
+
* refused (a newer pi-dispatch wrote it, and reading it as 1 could drop a rule it added). Duplicate names are refused (a
|
|
87
|
+
* revoke would remove only one), and so are duplicate hashes (two names for one secret make the audit row's actor
|
|
88
|
+
* ambiguous), across the known and the kept entries alike. The message never quotes a hash.
|
|
89
|
+
*
|
|
90
|
+
* What a later release wrote that this one does not read (issue #761, the header) is in `carry`, present only when it
|
|
91
|
+
* holds something, so a file with nothing to carry parses to exactly the shape it always did: `carry.kept`, each entry
|
|
92
|
+
* whose role has `TOKEN_ROLE_RE`'s shape but is not in `TOKEN_ROLES`, as it was written (its name, sha256 and createdAt
|
|
93
|
+
* are judged as a known entry's are); `carry.extras`, a known entry's fields beyond its four, by the entry's name; and
|
|
94
|
+
* `carry.top`, the top-level keys beyond `version`, `generation` and `tokens`. `serialize` writes all three back.
|
|
95
|
+
*/
|
|
96
|
+
export function parseTokensFile(text, path) {
|
|
97
|
+
let raw;
|
|
98
|
+
try {
|
|
99
|
+
raw = JSON.parse(text);
|
|
100
|
+
} catch {
|
|
101
|
+
throw new Error(`the console tokens file ${path} is not valid JSON; fix it by hand or remove it and run ${ADD_HINT}`);
|
|
102
|
+
}
|
|
103
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
104
|
+
throw new Error(`the console tokens file ${path} must hold a JSON object with "version" and "tokens"`);
|
|
105
|
+
}
|
|
106
|
+
if (raw.version !== 1) {
|
|
107
|
+
const seen = raw.version === undefined ? "no version" : `version ${JSON.stringify(raw.version)}`;
|
|
108
|
+
throw new Error(`the console tokens file ${path} has ${seen}, and this pi-dispatch reads only version 1; it was written by another pi-dispatch, so upgrade this one or recreate the file with ${ADD_HINT}`);
|
|
109
|
+
}
|
|
110
|
+
const generation = raw.generation === undefined ? 0 : raw.generation;
|
|
111
|
+
if (!Number.isSafeInteger(generation) || generation < 0) {
|
|
112
|
+
throw new Error(`the console tokens file ${path} has a "generation" that is not a whole number of 0 or more; set it to 0`);
|
|
113
|
+
}
|
|
114
|
+
if (!Array.isArray(raw.tokens)) {
|
|
115
|
+
throw new Error(`the console tokens file ${path} has no "tokens" list; add one with ${ADD_HINT}`);
|
|
116
|
+
}
|
|
117
|
+
const names = new Set();
|
|
118
|
+
const hashes = new Set();
|
|
119
|
+
const tokens = [];
|
|
120
|
+
const kept = [];
|
|
121
|
+
const extras = {};
|
|
122
|
+
raw.tokens.forEach((entry, i) => {
|
|
123
|
+
const at = `entry ${i + 1} of the console tokens file ${path}`;
|
|
124
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) throw new Error(`${at} is not an object`);
|
|
125
|
+
if (typeof entry.name !== "string" || !TOKEN_NAME_RE.test(entry.name)) {
|
|
126
|
+
throw new Error(`${at} has a name that is not 1 to 32 characters of a-z, 0-9, '.', '_' and '-' starting with a letter or digit`);
|
|
127
|
+
}
|
|
128
|
+
if (names.has(entry.name)) throw new Error(`${at} repeats the name "${entry.name}"; remove one of them (token revoke removes both)`);
|
|
129
|
+
names.add(entry.name);
|
|
130
|
+
const known = TOKEN_ROLES.includes(entry.role);
|
|
131
|
+
if (!known && !(typeof entry.role === "string" && TOKEN_ROLE_RE.test(entry.role))) {
|
|
132
|
+
throw new Error(`${at} ("${entry.name}") has a role other than ${TOKEN_ROLES.join(" or ")}, and not the shape of one a later pi-dispatch adds (a-z, 0-9 and '-', starting with a letter, at most 32)`);
|
|
133
|
+
}
|
|
134
|
+
if (typeof entry.sha256 !== "string" || !SHA256_HEX_RE.test(entry.sha256)) {
|
|
135
|
+
throw new Error(`${at} ("${entry.name}") has a sha256 that is not 64 lowercase hex characters; rotate it with \`pi-dispatch-console token rotate --name ${entry.name}\``);
|
|
136
|
+
}
|
|
137
|
+
if (hashes.has(entry.sha256)) throw new Error(`${at} ("${entry.name}") has the same secret as another entry; rotate one of them`);
|
|
138
|
+
hashes.add(entry.sha256);
|
|
139
|
+
if (typeof entry.createdAt !== "string" || Number.isNaN(Date.parse(entry.createdAt))) {
|
|
140
|
+
throw new Error(`${at} ("${entry.name}") has a createdAt that is not an ISO date`);
|
|
141
|
+
}
|
|
142
|
+
if (!known) {
|
|
143
|
+
// As written, never rebuilt: the release that added its role reads every field of it.
|
|
144
|
+
kept.push(entry);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
tokens.push({ name: entry.name, role: entry.role, sha256: entry.sha256, createdAt: entry.createdAt });
|
|
148
|
+
const more = Object.keys(entry).filter((k) => !ENTRY_FIELDS.includes(k));
|
|
149
|
+
if (more.length > 0) defineOwn(extras, entry.name, more.reduce((out, k) => defineOwn(out, k, entry[k]), {}));
|
|
150
|
+
});
|
|
151
|
+
const top = Object.keys(raw).filter((k) => !FILE_KEYS.includes(k)).reduce((out, k) => defineOwn(out, k, raw[k]), {});
|
|
152
|
+
const carried = kept.length > 0 || Object.keys(extras).length > 0 || Object.keys(top).length > 0;
|
|
153
|
+
return carried ? { version: 1, generation, tokens, carry: { kept, extras, top } } : { version: 1, generation, tokens };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The entries `parseTokensFile` kept for a role this build does not know (issue #761), as written: none when the file
|
|
158
|
+
* carries nothing. They sign in nowhere, count as no operator and match no setup grant, because no reader of `tokens`
|
|
159
|
+
* sees them; `token list` and doctor name them.
|
|
160
|
+
*/
|
|
161
|
+
export function keptTokens(file) {
|
|
162
|
+
return Array.isArray(file?.carry?.kept) ? file.carry.kept : [];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Every name the file holds, known and kept (issue #761): a new entry must not take a name a kept entry holds, or the
|
|
167
|
+
* file would hold the name twice and the release that knows its role would refuse it. Not a list of names that may sign
|
|
168
|
+
* in: that is `tokens` alone.
|
|
169
|
+
*/
|
|
170
|
+
export function tokenNames(file) {
|
|
171
|
+
return [...(Array.isArray(file?.tokens) ? file.tokens : []), ...keptTokens(file)].map((t) => t.name);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The uid the file must belong to by default: this process's, or undefined where there is none (Windows). */
|
|
175
|
+
function processUid() {
|
|
176
|
+
return typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** What is wrong with a stat of the tokens file, or null. Shared by the lstat and the open-descriptor check. */
|
|
180
|
+
function statProblem(st, path, uid) {
|
|
181
|
+
if (st.isSymbolicLink?.()) {
|
|
182
|
+
return `the console tokens file ${path} is a symbolic link, and the console reads only a regular file; point PI_CONSOLE_TOKENS_FILE at the real file`;
|
|
183
|
+
}
|
|
184
|
+
if (!st.isFile()) return `the console tokens file ${path} is not a regular file; remove it and run ${ADD_HINT}`;
|
|
185
|
+
if ((st.mode & 0o077) !== 0) {
|
|
186
|
+
return `the console tokens file ${path} can be read or written by other accounts (mode ${(st.mode & 0o777).toString(8).padStart(3, "0")}); run \`chmod 600 ${path}\``;
|
|
187
|
+
}
|
|
188
|
+
if (uid !== undefined && uid !== null && st.uid !== uid) {
|
|
189
|
+
return `the console tokens file ${path} is owned by uid ${st.uid}, not by this account (uid ${uid}); run the console as that account, or \`chown\` the file to this one`;
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* null when the file at `path` is fit to read, else the sentence saying why not: missing, a symlink (by lstat, never
|
|
196
|
+
* stat), not a regular file, any group or world mode bit, or an owner other than `uid`. `uid` defaults to this
|
|
197
|
+
* process's; null skips the owner check (Windows has no uid).
|
|
198
|
+
*/
|
|
199
|
+
export function tokensFileProblem({ path, fs = nodeFs, uid = processUid() }) {
|
|
200
|
+
let st;
|
|
201
|
+
try {
|
|
202
|
+
st = fs.lstatSync(path);
|
|
203
|
+
} catch (e) {
|
|
204
|
+
if (e?.code === "ENOENT") return `the console tokens file ${path} does not exist; create the first operator with ${ADD_HINT}`;
|
|
205
|
+
return `the console tokens file ${path} could not be inspected (${typeof e?.code === "string" ? e.code : "error"}); check its directory's permissions`;
|
|
206
|
+
}
|
|
207
|
+
return statProblem(st, path, uid);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Read the tokens file: `{file}` (the parsed shape) or `{refusal}` (a sentence). The problem check first, then the
|
|
212
|
+
* read through an O_NOFOLLOW descriptor whose own fstat is judged again, so the bytes parsed are the file judged.
|
|
213
|
+
*/
|
|
214
|
+
export function readTokensFile({ path, fs = nodeFs, uid = processUid() }) {
|
|
215
|
+
const read = readTokensText(path, fs, uid);
|
|
216
|
+
return read.refusal ? { refusal: read.refusal } : { file: read.file };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The validated descriptor's bytes and shape for a confirmed caller; neither may be rendered (#774). */
|
|
220
|
+
export function readTokensSnapshot({ path, fs = nodeFs, uid = processUid() }) {
|
|
221
|
+
return readTokensText(path, fs, uid);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** `readTokensFile`, plus the exact `text` that was judged and parsed (a writer's `before`). */
|
|
225
|
+
function readTokensText(path, fs, uid) {
|
|
226
|
+
const problem = tokensFileProblem({ path, fs, uid });
|
|
227
|
+
if (problem) return { refusal: problem };
|
|
228
|
+
let text;
|
|
229
|
+
try {
|
|
230
|
+
text = readJudged(path, fs, uid);
|
|
231
|
+
} catch (e) {
|
|
232
|
+
if (e?.refusal) return { refusal: e.refusal };
|
|
233
|
+
return { refusal: `the console tokens file ${path} could not be read (${typeof e?.code === "string" ? e.code : "error"}); check its permissions` };
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
return { file: parseTokensFile(text, path), text };
|
|
237
|
+
} catch (e) {
|
|
238
|
+
return { refusal: e?.message ?? String(e) };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The file's text read through a no-follow descriptor whose fstat passes `statProblem`; throws `{refusal}` if not. */
|
|
243
|
+
function readJudged(path, fs, uid) {
|
|
244
|
+
const noFollow = fs.constants?.O_NOFOLLOW ?? nodeFs.constants.O_NOFOLLOW ?? 0;
|
|
245
|
+
const rdOnly = fs.constants?.O_RDONLY ?? nodeFs.constants.O_RDONLY;
|
|
246
|
+
if (typeof fs.openSync !== "function" || typeof fs.fstatSync !== "function") return fs.readFileSync(path, "utf8");
|
|
247
|
+
let fd;
|
|
248
|
+
try {
|
|
249
|
+
fd = fs.openSync(path, rdOnly | noFollow);
|
|
250
|
+
} catch (e) {
|
|
251
|
+
if (e?.code === "ELOOP") throw { refusal: statProblem({ isSymbolicLink: () => true }, path, uid) };
|
|
252
|
+
throw e;
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
const problem = statProblem(fs.fstatSync(fd), path, uid);
|
|
256
|
+
if (problem) throw { refusal: problem };
|
|
257
|
+
return fs.readFileSync(fd, "utf8");
|
|
258
|
+
} finally {
|
|
259
|
+
fs.closeSync(fd);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The entry whose token `presented` is, or null. The presented value is hashed and compared against EVERY entry with
|
|
265
|
+
* `timingSafeEqual`, without an early exit, so the time taken says nothing about which entry (or how much of one)
|
|
266
|
+
* matched. Equal-length buffers always: both sides are 32-byte digests.
|
|
267
|
+
*/
|
|
268
|
+
export function findToken(file, presented) {
|
|
269
|
+
if (typeof presented !== "string" || presented === "" || !Array.isArray(file?.tokens)) return null;
|
|
270
|
+
const digest = createHash("sha256").update(presented, "utf8").digest();
|
|
271
|
+
let found = null;
|
|
272
|
+
for (const entry of file.tokens) {
|
|
273
|
+
const stored = Buffer.from(typeof entry?.sha256 === "string" && SHA256_HEX_RE.test(entry.sha256) ? entry.sha256 : "0".repeat(64), "hex");
|
|
274
|
+
if (timingSafeEqual(stored, digest) && found === null) found = entry;
|
|
275
|
+
}
|
|
276
|
+
return found;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** How many operator tokens the file holds. The console refuses to start on none. */
|
|
280
|
+
export function operatorCount(file) {
|
|
281
|
+
return Array.isArray(file?.tokens) ? file.tokens.filter((t) => t?.role === "operator").length : 0;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The serialized file, the one shape every writer produces: the known entries (each with its carried fields after its
|
|
286
|
+
* four), then the kept ones as they were read, then the carried top-level keys (issue #761). A file that carries nothing
|
|
287
|
+
* is written byte for byte as before; a kept entry moves after the known ones, which means nothing to any reader.
|
|
288
|
+
*/
|
|
289
|
+
function serialize(file) {
|
|
290
|
+
const extras = file.carry?.extras ?? {};
|
|
291
|
+
const tokens = file.tokens.map((t) => (Object.hasOwn(extras, t.name) ? { ...t, ...extras[t.name] } : t));
|
|
292
|
+
return `${JSON.stringify({ version: 1, generation: file.generation, tokens: [...tokens, ...keptTokens(file)], ...(file.carry?.top ?? {}) }, null, 2)}\n`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The file as it is now, for a writer: `{file, text}` (text null when it does not exist yet, then `file` is the empty
|
|
297
|
+
* one) or `{invalid}`. An existing file must pass the same check the console applies, so the CLI never quietly keeps
|
|
298
|
+
* writing a file the console would refuse.
|
|
299
|
+
*/
|
|
300
|
+
function loadForWrite(path, fs, uid, { create }) {
|
|
301
|
+
let missing = false;
|
|
302
|
+
try {
|
|
303
|
+
fs.lstatSync(path);
|
|
304
|
+
} catch (e) {
|
|
305
|
+
if (e?.code !== "ENOENT") return { invalid: `the console tokens file ${path} could not be inspected (${typeof e?.code === "string" ? e.code : "error"}); nothing was written` };
|
|
306
|
+
missing = true;
|
|
307
|
+
}
|
|
308
|
+
if (missing) {
|
|
309
|
+
if (!create) return { invalid: `the console tokens file ${path} does not exist, so there is nothing to change; create the first operator with ${ADD_HINT}` };
|
|
310
|
+
return { file: { version: 1, generation: 0, tokens: [] }, text: null };
|
|
311
|
+
}
|
|
312
|
+
// ONE read: the text parsed is the text the pre-rename re-check compares against. A second read for `before` could
|
|
313
|
+
// see another command's write that `next` (built from the first) does not hold, pass the re-check, and lose it.
|
|
314
|
+
const read = readTokensText(path, fs, uid);
|
|
315
|
+
if (read.refusal) return { invalid: `${read.refusal}. Nothing was written` };
|
|
316
|
+
return { file: read.file, text: read.text };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The file's text, null when missing; any other read error is its own string, which no file's text equals. */
|
|
320
|
+
function snapshot(fs, path) {
|
|
321
|
+
try {
|
|
322
|
+
return fs.readFileSync(path, "utf8");
|
|
323
|
+
} catch (e) {
|
|
324
|
+
return e?.code === "ENOENT" ? null : `\u0000unreadable:${typeof e?.code === "string" ? e.code : "error"}`;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Replace the file with `next`: a tmp of its own beside it (`wx`, 0600, chmod'd exactly since create is masked by the
|
|
330
|
+
* umask), the target re-checked as not a symlink and as still holding `before` right before the rename, then rename.
|
|
331
|
+
* Returns `{ok}` or `{invalid}`.
|
|
332
|
+
*/
|
|
333
|
+
function writeTokens(path, next, before, fs, random) {
|
|
334
|
+
const code = (e) => (typeof e?.code === "string" ? e.code : "error");
|
|
335
|
+
const linked = () => {
|
|
336
|
+
try {
|
|
337
|
+
return fs.lstatSync(path).isSymbolicLink();
|
|
338
|
+
} catch {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
if (linked()) return { invalid: `the console tokens file ${path} is a symbolic link, and the console writes only a regular file; point PI_CONSOLE_TOKENS_FILE at the real file. Nothing was written` };
|
|
343
|
+
const tmp = `${path}.${process.pid}.${random(6).toString("hex")}.tmp`;
|
|
344
|
+
const drop = () => {
|
|
345
|
+
try {
|
|
346
|
+
fs.unlinkSync(tmp);
|
|
347
|
+
} catch {
|
|
348
|
+
// nothing to drop
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
try {
|
|
352
|
+
fs.writeFileSync(tmp, serialize(next), { mode: 0o600, flag: "wx" });
|
|
353
|
+
} catch (e) {
|
|
354
|
+
return { invalid: `the console tokens file ${path} could not be written (${code(e)}: a new file beside it could not be created; does its directory exist and belong to this account?). Nothing was written` };
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
fs.chmodSync(tmp, 0o600);
|
|
358
|
+
} catch (e) {
|
|
359
|
+
drop();
|
|
360
|
+
return { invalid: `the console tokens file ${path} could not be written (${code(e)}: the new file could not be made private). Nothing was written` };
|
|
361
|
+
}
|
|
362
|
+
if (linked() || snapshot(fs, path) !== before) {
|
|
363
|
+
drop();
|
|
364
|
+
return { invalid: `the console tokens file ${path} changed while this change was being made (another command wrote it); nothing was written. Run the command again` };
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
fs.renameSync(tmp, path);
|
|
368
|
+
} catch (e) {
|
|
369
|
+
drop();
|
|
370
|
+
return { invalid: `the console tokens file ${path} could not be replaced (${code(e)}); nothing was written` };
|
|
371
|
+
}
|
|
372
|
+
return { ok: true };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** The ISO time of `now()`. */
|
|
376
|
+
function iso(now) {
|
|
377
|
+
return new Date(now()).toISOString();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Add a token named `name` with `role`: `{token, entry}` (the plaintext, to print ONCE, and the stored entry) or
|
|
382
|
+
* `{invalid}`. Creates the file at 0600 when it is missing. An existing name is refused: rotate it instead, so a typo
|
|
383
|
+
* never silently replaces someone's secret. So is a name an entry kept for a role this build does not know holds (issue
|
|
384
|
+
* #761): the file would then hold the name twice, which the release that knows that role refuses.
|
|
385
|
+
*/
|
|
386
|
+
export function addToken({ path, name, role, fs = nodeFs, now = () => Date.now(), random = randomBytes, uid = processUid(), expect = undefined }) {
|
|
387
|
+
if (typeof name !== "string" || !TOKEN_NAME_RE.test(name)) {
|
|
388
|
+
return { invalid: "a token name is 1 to 32 characters of a-z, 0-9, '.', '_' and '-', starting with a letter or digit" };
|
|
389
|
+
}
|
|
390
|
+
if (!TOKEN_ROLES.includes(role)) return { invalid: `a token's role is ${TOKEN_ROLES.join(" or ")}` };
|
|
391
|
+
const loaded = loadForWrite(path, fs, uid, { create: true });
|
|
392
|
+
if (loaded.invalid) return loaded;
|
|
393
|
+
if (expect !== undefined && loaded.text !== expect) return { invalid: "the console tokens file changed after preview; nothing was written" };
|
|
394
|
+
if (loaded.file.tokens.some((t) => t.name === name)) {
|
|
395
|
+
return { invalid: `a token named "${name}" already exists; give it a new secret with \`pi-dispatch-console token rotate --name ${name}\`, or choose another name` };
|
|
396
|
+
}
|
|
397
|
+
const keptEntry = keptTokens(loaded.file).find((t) => t.name === name);
|
|
398
|
+
if (keptEntry) return { invalid: `a token named "${name}" already exists, with the role \`${keptEntry.role}\` that this pi-dispatch-console does not know; choose another name` };
|
|
399
|
+
const token = newToken(random);
|
|
400
|
+
const entry = { name, role, sha256: hashToken(token), createdAt: iso(now) };
|
|
401
|
+
const written = writeTokens(path, { ...loaded.file, tokens: [...loaded.file.tokens, entry] }, loaded.text, fs, random);
|
|
402
|
+
if (written.invalid) return written;
|
|
403
|
+
return { token, entry };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Remove the token named `name`, a kept one (issue #761) included: `{ok}` or `{invalid}`. Every session signed in with it
|
|
408
|
+
* ends on its next request.
|
|
409
|
+
*/
|
|
410
|
+
export function revokeToken({ path, name, fs = nodeFs, random = randomBytes, uid = processUid(), expect = undefined, identity = undefined }) {
|
|
411
|
+
const loaded = loadForWrite(path, fs, uid, { create: false });
|
|
412
|
+
if (loaded.invalid) return loaded;
|
|
413
|
+
if (expect !== undefined && loaded.text !== expect) return { invalid: "the console tokens file changed after preview; nothing was written" };
|
|
414
|
+
const entry = loaded.file.tokens.find((t) => t.name === name);
|
|
415
|
+
// `identity` (the setup page's undo of the viewer it made) names a known entry only, so a kept one never matches it.
|
|
416
|
+
if (identity !== undefined && (!entry || entry.sha256 !== identity.sha256 || entry.createdAt !== identity.createdAt || entry.role !== identity.role)) return { invalid: "the viewer entry changed; the replacement was not revoked" };
|
|
417
|
+
const kept = keptTokens(loaded.file);
|
|
418
|
+
// A kept entry (issue #761) can be removed here: taking access away is safe whatever the role means.
|
|
419
|
+
if (!entry && !kept.some((t) => t.name === name)) return { invalid: `there is no token named "${String(name)}"; \`pi-dispatch-console token list\` shows the names` };
|
|
420
|
+
const carry = loaded.file.carry;
|
|
421
|
+
const next = { ...loaded.file, tokens: loaded.file.tokens.filter((t) => t.name !== name) };
|
|
422
|
+
// Its carried fields go with it, so a token added later under the same name does not inherit them.
|
|
423
|
+
if (carry) next.carry = { ...carry, kept: kept.filter((t) => t.name !== name), extras: Object.keys(carry.extras).filter((k) => k !== name).reduce((out, k) => defineOwn(out, k, carry.extras[k]), {}) };
|
|
424
|
+
return writeTokens(path, next, loaded.text, fs, random);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Give the token named `name` a new secret, keeping its role and its carried fields (issue #761): `{token, entry}` or
|
|
429
|
+
* `{invalid}`. The old secret stops working, and every session signed in with it ends on its next request (its hash is
|
|
430
|
+
* gone from the file). An entry kept for a role this build does not know is refused.
|
|
431
|
+
*/
|
|
432
|
+
export function rotateToken({ path, name, fs = nodeFs, now = () => Date.now(), random = randomBytes, uid = processUid() }) {
|
|
433
|
+
const loaded = loadForWrite(path, fs, uid, { create: false });
|
|
434
|
+
if (loaded.invalid) return loaded;
|
|
435
|
+
const old = loaded.file.tokens.find((t) => t.name === name);
|
|
436
|
+
const keptEntry = old ? null : keptTokens(loaded.file).find((t) => t.name === name);
|
|
437
|
+
// A kept entry (issue #761) keeps the secret it has: a new one written by this build could lose a field of the entry
|
|
438
|
+
// the release that knows its role relies on, and a rotate that cannot say what the token can do is not one to make.
|
|
439
|
+
if (keptEntry) return { invalid: `the token "${keptEntry.name}" cannot be rotated here: its role \`${keptEntry.role}\` is not one this pi-dispatch-console knows; rotate it with the release that added that role, or revoke it` };
|
|
440
|
+
if (!old) return { invalid: `there is no token named "${String(name)}"; \`pi-dispatch-console token list\` shows the names` };
|
|
441
|
+
const token = newToken(random);
|
|
442
|
+
const entry = { name, role: old.role, sha256: hashToken(token), createdAt: iso(now) };
|
|
443
|
+
const written = writeTokens(path, { ...loaded.file, tokens: loaded.file.tokens.map((t) => (t.name === name ? entry : t)) }, loaded.text, fs, random);
|
|
444
|
+
if (written.invalid) return written;
|
|
445
|
+
return { token, entry };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* End every console session at once: bump `generation`, which every session was created under, so each one fails its
|
|
450
|
+
* next request. The tokens stay valid. `{ok, generation}` (the new one) or `{invalid}`.
|
|
451
|
+
*/
|
|
452
|
+
export function endAllSessions({ path, fs = nodeFs, random = randomBytes, uid = processUid() }) {
|
|
453
|
+
const loaded = loadForWrite(path, fs, uid, { create: false });
|
|
454
|
+
if (loaded.invalid) return loaded;
|
|
455
|
+
const generation = loaded.file.generation + 1;
|
|
456
|
+
const written = writeTokens(path, { ...loaded.file, generation }, loaded.text, fs, random);
|
|
457
|
+
if (written.invalid) return written;
|
|
458
|
+
return { ok: true, generation };
|
|
459
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Runtime names shared with operator guidance without importing runtime or provider implementations. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `pi-job-` -- the container-name namespace, and a LOAD-BEARING string rather than a prefix chosen for
|
|
6
|
+
* readability. TWO sweeps match it as a SUBSTRING, both at boot in `start.mjs`: the container reaper's
|
|
7
|
+
* `docker ps` filter and the network reaper's `docker network ls` filter. They share the NAME rule
|
|
8
|
+
* (`isJobNamespace`) and deliberately not the STATE question -- see `reapNetwork` for why one lists running
|
|
9
|
+
* containers and the other asks about members in any state. The sandbox tooling is the
|
|
10
|
+
* counterpart rather than a third sweep -- it names itself `pi-sandbox-` precisely to stay OUTSIDE this
|
|
11
|
+
* namespace, so a worker restart cannot tear down the shell an operator is sitting in, and it reaps its own
|
|
12
|
+
* by job id rather than by name.
|
|
13
|
+
*
|
|
14
|
+
* Exported and imported rather than re-typed at each site for the reason `CONTAINER_GLOBAL_PI_DIR` is: the
|
|
15
|
+
* namespace and the filters that sweep it are ONE fact, and two literals in two modules is how a rename
|
|
16
|
+
* lands in the producer and not in the reaper, leaving every crashed worker's containers behind forever with
|
|
17
|
+
* both test suites green.
|
|
18
|
+
*/
|
|
19
|
+
export const JOB_NAME_PREFIX = "pi-job-";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `pi-job-<jobId>`. The name a running job answers to, for `docker stop` on the 30-minute timeout, for the
|
|
23
|
+
* per-job egress network derived from it, and for the reaper's filter.
|
|
24
|
+
*
|
|
25
|
+
* Sanitised to the runtimes' own name rule, `[a-zA-Z0-9][a-zA-Z0-9_.-]*` (docker's `RestrictedNameChars`, and
|
|
26
|
+
* Podman's, measured), every other character becoming `_`, as `sanitizeJobId` does for file names. It said here that
|
|
27
|
+
* BullMQ ids are already that shape, and a cron job's is not: the job scheduler mints `repeat:<schedulerId>:<millis>`,
|
|
28
|
+
* so `pi-job-repeat:...` was refused at create (exit 125, measured on Podman 5.8.1) and so was its `-net` network, and
|
|
29
|
+
* every cron job ended `container-never-started` before it ever ran (issue #435). The prefix supplies the first
|
|
30
|
+
* character. Deterministic, because every path that must find the container again (the timeout's stop, the cancel,
|
|
31
|
+
* the network, the log sink) asks this function for the name rather than deriving it. Not injective: `repeat:a:1`
|
|
32
|
+
* and a literal `repeat_a_1` share a name. BullMQ ids are unique per queue and no producer here mints an id with `_`
|
|
33
|
+
* where a scheduler id has `:`, so it is a residual rather than a collision anything reaches.
|
|
34
|
+
*/
|
|
35
|
+
export function jobContainerName(jobId) {
|
|
36
|
+
return `${JOB_NAME_PREFIX}${String(jobId).replace(/[^A-Za-z0-9._-]/g, "_")}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A sandbox's name for a job's container name. OpenShell refuses a sandbox name longer than 19 characters (measured:
|
|
41
|
+
* "name exceeds maximum length (34 > 19)"), and a job's container name is `pi-job-<the queue's job id>`, routinely
|
|
42
|
+
* longer, so the sandbox is named by a hash of it: `pi-job-s` and 11 hex digits, 19 exactly, still inside the job
|
|
43
|
+
* namespace every sweep matches. A name already of that shape is its own sandbox name, so what `list` returned (a
|
|
44
|
+
* leftover the host budget seeded by its sandbox name) maps to itself, and every step, the stop and the gone-check
|
|
45
|
+
* included, goes through this one function.
|
|
46
|
+
*/
|
|
47
|
+
export const SANDBOX_NAME = /^pi-job-s[0-9a-f]{11}$/;
|
|
48
|
+
export function sandboxNameOf(containerName) {
|
|
49
|
+
if (SANDBOX_NAME.test(containerName)) return containerName;
|
|
50
|
+
return `${JOB_NAME_PREFIX}s${createHash("sha256").update(String(containerName)).digest("hex").slice(0, 11)}`;
|
|
51
|
+
}
|
package/src/container-spec.mjs
CHANGED
|
@@ -48,6 +48,13 @@ export const CONTAINER_GLOBAL_PI_DIR = "/opt/pi-global";
|
|
|
48
48
|
export const CONTAINER_SESSION_DIR = "/session";
|
|
49
49
|
export const CONTAINER_SESSION_FILE = `${CONTAINER_SESSION_DIR}/current.jsonl`;
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Where a steer-armed job's inbox lands (issue #646, INT-STEER-INBOX-CONTRACT): the runner (image/runner/src/inbox.mjs
|
|
53
|
+
* `INBOX_DIR`) reads it, the worker's relay writes the host side. A constant path, like `/session`, so nothing about
|
|
54
|
+
* the host's layout is legible inside the job.
|
|
55
|
+
*/
|
|
56
|
+
export const CONTAINER_INBOX_DIR = "/inbox";
|
|
57
|
+
|
|
51
58
|
/**
|
|
52
59
|
* The job image's home, and the uid its `USER` directive runs as (issue #341, `DES-JOB-USER-INFERRED-READ-BACK-ON-
|
|
53
60
|
* REQUEST`). A job run under `--user` needs `HOME=CONTAINER_HOME` beside it, because such a uid has no passwd entry in
|
|
@@ -132,6 +139,8 @@ export function assertUserns(userns) {
|
|
|
132
139
|
* @param sessionDir host path to this job's OWN copy of its session transcript (REQ-RESUMABLE-SESSION);
|
|
133
140
|
* mounted /session:rw. Per-job, like jobDir -- never the shared store.
|
|
134
141
|
* @param globalPiDir host path to the operator's global pi overlay (REQ-GLOBAL-PI-OVERLAY); mounted /opt/pi-global:ro
|
|
142
|
+
* @param steerInboxDir host path to a steer-armed job's inbox (issue #646), `<jobDir>.inbox`; mounted /inbox:ro. Absent
|
|
143
|
+
* for every job that is not armed, so its argv is byte-identical to one built before the feature.
|
|
135
144
|
* @param name container name (for `docker stop` at the timeout)
|
|
136
145
|
* @param size the job's size, `{ memMiB, cpuCenti }` (issue #596, `job-size.mjs`); the built-in 4g and 2 when
|
|
137
146
|
* absent. It becomes `memory`, `memorySwap` (equal: no swap beyond memory), `cpuShares` and `shmSize`
|
|
@@ -169,6 +178,7 @@ export function containerSpec({
|
|
|
169
178
|
outboxDir,
|
|
170
179
|
sessionDir,
|
|
171
180
|
globalPiDir,
|
|
181
|
+
steerInboxDir,
|
|
172
182
|
name,
|
|
173
183
|
size = DEFAULT_JOB_SIZE,
|
|
174
184
|
hostCpus = null,
|
|
@@ -181,6 +191,7 @@ export function containerSpec({
|
|
|
181
191
|
extraFlags = [],
|
|
182
192
|
relabel = false,
|
|
183
193
|
workspaceOwned = false,
|
|
194
|
+
workspaceReadOnly = false,
|
|
184
195
|
}) {
|
|
185
196
|
if (!image) throw new Error("docker run: image is required");
|
|
186
197
|
assertJobUser(user);
|
|
@@ -194,6 +205,7 @@ export function containerSpec({
|
|
|
194
205
|
// Booleans, strictly: a truthy string from a caller that forwarded an option bag must not re-own host directories.
|
|
195
206
|
if (typeof relabel !== "boolean") throw new Error(`docker run: relabel must be a boolean; got ${typeof relabel}`);
|
|
196
207
|
if (typeof workspaceOwned !== "boolean") throw new Error(`docker run: workspaceOwned must be a boolean; got ${typeof workspaceOwned}`);
|
|
208
|
+
if (typeof workspaceReadOnly !== "boolean") throw new Error("docker run: workspaceReadOnly must be a boolean");
|
|
197
209
|
|
|
198
210
|
// Issue #355. A SPREAD rather than a `relabel: null` field, so a spec built without it has mount objects byte-identical
|
|
199
211
|
// to every one built before this feature existed. PRIVATE, never shared: each of these directories is made for this
|
|
@@ -208,7 +220,7 @@ export function containerSpec({
|
|
|
208
220
|
// second container mounting it afterwards is denied, and the label an operator chose is gone. So only a workspace the
|
|
209
221
|
// worker made is relabelled; an operator's folder needs the `semanage fcontext` fix doctor names, and the runner
|
|
210
222
|
// refuses one it cannot read before any spend.
|
|
211
|
-
mounts.push({ host: workspace, container: "/workspace", readOnly:
|
|
223
|
+
mounts.push({ host: workspace, container: "/workspace", readOnly: workspaceReadOnly, ...(workspaceOwned ? own : {}) });
|
|
212
224
|
// Local jobs get a writable /outbox host bind, the same host-bind mechanism as /workspace
|
|
213
225
|
// (DES-WORKER-ON-HOST). github jobs pass no outboxDir, so the request channel does not exist for
|
|
214
226
|
// them -- an untrusted issue author cannot chain (INT-OUTBOX-CONTRACT).
|
|
@@ -223,6 +235,13 @@ export function containerSpec({
|
|
|
223
235
|
// resolved, so an unarmed job's argv is byte-identical to one built before this feature existed.
|
|
224
236
|
if (sessionDir) mounts.push({ host: sessionDir, container: CONTAINER_SESSION_DIR, readOnly: false, ...own });
|
|
225
237
|
|
|
238
|
+
// A steer-armed job's inbox (issue #646, DES-STEER-VIA-READONLY-INBOX-MOUNT, INT-STEER-INBOX-CONTRACT). READ-ONLY: the
|
|
239
|
+
// worker writes the messages, the job only reads them, so the channel is one way and nothing the agent writes there
|
|
240
|
+
// can reach the worker (the delivery ack travels in the transcript, MACed, never through this directory). Per job and
|
|
241
|
+
// BESIDE the job directory, never inside it: `/job` is merge-gated input only. Relabelled like the other per-job
|
|
242
|
+
// directories (issue #355), since it is made for this one container. Absent unless armed.
|
|
243
|
+
if (steerInboxDir) mounts.push({ host: steerInboxDir, container: CONTAINER_INBOX_DIR, readOnly: true, ...own });
|
|
244
|
+
|
|
226
245
|
// The operator's global pi overlay (REQ-GLOBAL-PI-OVERLAY): custom models, global skills, a global
|
|
227
246
|
// persona, layered UNDER each repo's own .pi/. Read-only -- it is operator-authored deploy-time config,
|
|
228
247
|
// the same trust class as the baked floor, but the agent still must not rewrite it. Both job kinds.
|
package/src/cpu-reserve.mjs
CHANGED
|
@@ -179,13 +179,16 @@ export function cgroupParentFor(runtime) {
|
|
|
179
179
|
* `parent` false when jobs run without the parent (`cgroupParentFor`): their shares are then capped instead
|
|
180
180
|
* `method` `user-systemd` | `helper` | `system-systemd` | null (the quota is not kept here; `why` says which)
|
|
181
181
|
* `why` a fixed token: `podman-cgroupfs`, `cgroup-v1`, `remote-daemon`, `rootless-docker`, `driver-unknown`,
|
|
182
|
-
* `podman-rootful-remote` or null
|
|
182
|
+
* `podman-rootful-remote`, `venue-unmanaged` or null
|
|
183
183
|
* `facts` is the venue's parsed info: for `local` the `docker info` facts (`parseDaemonFacts`, with `cgroupDriver` and
|
|
184
184
|
* `cgroupVersion`), for `podman` the `podman info` read (`parsePodmanInfo`). `endpointLocal` is the docker endpoint's
|
|
185
185
|
* observed locality, and `platform` this process's: a systemd read on THIS host says nothing about a remote daemon's.
|
|
186
186
|
*/
|
|
187
187
|
export function reservePlan({ venue, facts, endpointLocal = false, platform = process.platform }) {
|
|
188
188
|
const plan = (parent, method, why) => ({ venue, parent, method, why });
|
|
189
|
+
// Issue #681: only the two venues whose facts this reads are planned. Another venue used to fall through to docker's
|
|
190
|
+
// rules and could be handed docker's helper; it keeps no quota here, said with a token rather than guessed.
|
|
191
|
+
if (venue !== "local" && venue !== "podman") return plan(false, null, "venue-unmanaged");
|
|
189
192
|
if (venue === "podman") {
|
|
190
193
|
if (cgroupParentFor({ podman: true, cgroupManager: facts?.cgroupManager }) === null) return plan(false, null, "podman-cgroupfs");
|
|
191
194
|
if (facts?.cgroupVersion && facts.cgroupVersion !== "v2") return plan(true, null, "cgroup-v1");
|