@edgehero/pi-dispatch 4.3.0 → 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 +94 -4
- 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 +199 -30
- 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 +1663 -198
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +103 -9
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-auth.mjs +4 -0
- package/src/forgejo-host.mjs +127 -31
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +73 -15
- 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 +740 -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 +1554 -167
- 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 +296 -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 +782 -40
- 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,219 @@
|
|
|
1
|
+
import { egressEnv } from "./egress.mjs";
|
|
2
|
+
import { FORGE_HOST_VARS } from "./forges.mjs";
|
|
3
|
+
import { KEYLESS_ENV_NAME } from "./reserved-env.mjs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Take a job's own secrets out of text the agent wrote, before that text leaves the host (issue #643,
|
|
7
|
+
* `DES-LIVE-TRANSCRIPT-STREAM`).
|
|
8
|
+
*
|
|
9
|
+
* The live transcript is agent-writable, so it is untrusted data, and it can hold anything the agent saw: a
|
|
10
|
+
* `printenv`, a token pasted into a curl, a header in a tool's error. `scrubCredentials` (redact.mjs) handles URL
|
|
11
|
+
* userinfo and home names only, and is the wrong tool here: it keeps the useful half of a fault line, where this
|
|
12
|
+
* removes VALUES this worker itself handed the container. Two rules, applied in this order:
|
|
13
|
+
*
|
|
14
|
+
* 1. Every secret VALUE of at least `MIN_SECRET_LENGTH` characters, in three spellings: literal, JSON-escaped
|
|
15
|
+
* (as `JSON.stringify` writes it inside a string, which is how a tool call's arguments reach the stream) and
|
|
16
|
+
* URL-encoded (`encodeURIComponent`, both hex cases, which is how a token in a query string looks). Longest
|
|
17
|
+
* first, so a value that contains another is taken whole rather than leaving its tail behind.
|
|
18
|
+
* 2. Known token SHAPES, for a secret this job was never handed (a token the agent found in a repository, or one
|
|
19
|
+
* an operator forwarded under a name this rule cannot see): GitHub's five prefixes and fine-grained PATs,
|
|
20
|
+
* GitLab PATs, Anthropic and OpenAI style keys, Slack bot and user tokens, this project's own console tokens,
|
|
21
|
+
* AWS access key ids, the credential half of a `Bearer` or `Authorization:` header, and the userinfo of a
|
|
22
|
+
* URL (`scheme://user:password@`).
|
|
23
|
+
*
|
|
24
|
+
* A match becomes `[redacted]`. A header keeps its scheme (`Authorization: Basic [redacted]`), because the scheme
|
|
25
|
+
* is what tells a reader which kind of credential was there, and it is never itself secret.
|
|
26
|
+
*
|
|
27
|
+
* WHY THE LENGTH FLOOR. A value shorter than eight characters is a port, a flag or a word, and replacing every
|
|
28
|
+
* occurrence of it would make the transcript unreadable while protecting nothing a guess could not reach. Real
|
|
29
|
+
* credentials are longer: the shortest this worker hands out is a minted forge token.
|
|
30
|
+
*
|
|
31
|
+
* NOT DEFENDED, named here and in SECURITY.md: a secret the agent TRANSFORMS (base64, reversed, split across two
|
|
32
|
+
* tool calls, one character per line) is not caught by either rule, and cannot be by any rule that does not know
|
|
33
|
+
* the transformation. Redaction bounds an accident; it is not a boundary against an agent trying to leak.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** The shortest value rule 1 takes. Shorter values pass through untouched. */
|
|
37
|
+
export const MIN_SECRET_LENGTH = 8;
|
|
38
|
+
|
|
39
|
+
/** What every match becomes. */
|
|
40
|
+
export const REDACTED = "[redacted]";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The token shapes of rule 2. Each body has a floor long enough that an ordinary identifier with the same prefix
|
|
44
|
+
* (`sk-learn`, `ghp_test`) is left alone, and each is anchored so a prefix inside a longer word (`task-...`,
|
|
45
|
+
* `disk-...`) does not match.
|
|
46
|
+
*/
|
|
47
|
+
const TOKEN_SHAPES = [
|
|
48
|
+
/(?<![A-Za-z0-9_])gh[pousr]_[A-Za-z0-9]{16,}/g,
|
|
49
|
+
/(?<![A-Za-z0-9_])github_pat_[A-Za-z0-9_]{16,}/g,
|
|
50
|
+
/(?<![A-Za-z0-9_])glpat-[A-Za-z0-9_-]{16,}/g,
|
|
51
|
+
// `sk-ant-` is matched by the `sk-` shape too; it is listed so the intent survives a later tightening of that one.
|
|
52
|
+
/(?<![A-Za-z0-9_])sk-ant-[A-Za-z0-9_-]{16,}/g,
|
|
53
|
+
/(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]{16,}/g,
|
|
54
|
+
/(?<![A-Za-z0-9_])xox[bp]-[A-Za-z0-9-]{10,}/g,
|
|
55
|
+
/(?<![A-Za-z0-9_])pdc_[A-Za-z0-9_-]{16,}/g,
|
|
56
|
+
/(?<![A-Za-z0-9])AKIA[0-9A-Z]{16}(?![0-9A-Z])/g,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/** `Authorization: <scheme> <credential>`, any case, quoted or not; the scheme is kept. */
|
|
60
|
+
const AUTHORIZATION_HEADER = /(authorization["']?\s*[:=]\s*["']?)([A-Za-z][A-Za-z0-9_-]*)(\s+["']?)([^\s"',;]+)/gi;
|
|
61
|
+
/** An `Authorization` header with no scheme: the whole value is the credential (`Authorization: <credential>`). */
|
|
62
|
+
const AUTHORIZATION_BARE = /(authorization["']?\s*[:=]\s*["']?)([^\s"',;]{8,})(?=\s*(?:["',;]|$))/gim;
|
|
63
|
+
/** A bare `Bearer <credential>`, where no `Authorization` precedes it (a curl `-H` split, a log line). */
|
|
64
|
+
const BEARER = /(\bbearer\s+)([A-Za-z0-9._~+/=-]{8,})/gi;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The userinfo of a URL, `scheme://user:password@` (or a token alone before the `@`), which becomes
|
|
68
|
+
* `scheme://[redacted]@`: a proxy URL an operator forwarded with credentials, a clone URL with a token in it. The
|
|
69
|
+
* URL grammar of `scrubCredentials` (redact.mjs), with the scheme REQUIRED: there a fault line is short and the
|
|
70
|
+
* cost of eating an e-mail address is small, while a transcript is full of addresses and `user@host` prose that
|
|
71
|
+
* carry no credential. The run stops at the first `/`, `?` or `#` (the host's end), so an `@` in a path or a query
|
|
72
|
+
* is never taken, and takes up to the LAST `@` before it, so a password holding a raw `@` goes whole.
|
|
73
|
+
*/
|
|
74
|
+
const URL_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/?#"'`<>]*@/gi;
|
|
75
|
+
|
|
76
|
+
/** A string's three spellings that rule 1 matches. */
|
|
77
|
+
function spellingsOf(value) {
|
|
78
|
+
const json = JSON.stringify(value).slice(1, -1);
|
|
79
|
+
const url = encodeURIComponent(value);
|
|
80
|
+
const lowerUrl = url.replace(/%[0-9A-F]{2}/g, (pct) => pct.toLowerCase());
|
|
81
|
+
return [value, json, url, lowerUrl];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `makeRedactor({ values, extraPatterns? })` returns `(text) => text`. `values` is every secret this job holds
|
|
88
|
+
* (`secretValuesOf` below builds it from the container env); non-strings and values under the floor are dropped.
|
|
89
|
+
* `extraPatterns` are further global regexps whose whole match is redacted. Never throws: a non-string input is
|
|
90
|
+
* stringified first.
|
|
91
|
+
*/
|
|
92
|
+
export function makeRedactor({ values = [], extraPatterns = [] } = {}) {
|
|
93
|
+
const forms = new Set();
|
|
94
|
+
for (const value of values) {
|
|
95
|
+
if (typeof value !== "string" || value.length < MIN_SECRET_LENGTH) continue;
|
|
96
|
+
// Every spelling is at least as long as the value, so the floor above covers them all.
|
|
97
|
+
for (const form of spellingsOf(value)) forms.add(form);
|
|
98
|
+
}
|
|
99
|
+
// Longest first, so the alternation takes the longest value at any position: JavaScript's alternation is
|
|
100
|
+
// ordered, not leftmost-longest, so `abcdefgh|abcdefghij` would leave `ij` behind.
|
|
101
|
+
const sorted = [...forms].sort((a, b) => b.length - a.length);
|
|
102
|
+
const literal = sorted.length > 0 ? new RegExp(sorted.map(escapeRegExp).join("|"), "g") : null;
|
|
103
|
+
const shapes = [...TOKEN_SHAPES, ...extraPatterns.filter((p) => p instanceof RegExp && p.global)];
|
|
104
|
+
return function redact(text) {
|
|
105
|
+
let out = String(text ?? "");
|
|
106
|
+
if (literal) out = out.replace(literal, REDACTED);
|
|
107
|
+
out = out.replace(AUTHORIZATION_HEADER, (_, head, scheme, gap, credential) => (credential === REDACTED ? `${head}${scheme}${gap}${credential}` : `${head}${scheme}${gap}${REDACTED}`));
|
|
108
|
+
out = out.replace(AUTHORIZATION_BARE, (_, head, credential) => (credential === REDACTED ? `${head}${credential}` : `${head}${REDACTED}`));
|
|
109
|
+
out = out.replace(BEARER, (_, head, credential) => (credential === REDACTED ? `${head}${credential}` : `${head}${REDACTED}`));
|
|
110
|
+
out = out.replace(URL_USERINFO, (_, scheme) => `${scheme}${REDACTED}@`);
|
|
111
|
+
for (const shape of shapes) out = out.replace(shape, REDACTED);
|
|
112
|
+
return out;
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The container env keys that carry NO secret: everything `buildContainerEnv` (env-allowlist.mjs) writes from a
|
|
118
|
+
* structural input, so a path, a flag, a model name or a count is never redacted out of a transcript.
|
|
119
|
+
*
|
|
120
|
+
* THE RULE IS INVERTED ON PURPOSE: every key NOT listed here is secret-bearing. That covers, by construction, the
|
|
121
|
+
* minted forge token under each of its forge's names, the provider credential under whatever name pi reads it,
|
|
122
|
+
* every `PI_FORWARD_ENV` variable, every resolved trigger secret and every model-endpoint key. A variable added to
|
|
123
|
+
* the container env later is redacted until someone lists it here, which is the safe direction: a missing entry
|
|
124
|
+
* here costs readability, a missing entry in an allowlist of secrets would cost a leak.
|
|
125
|
+
*
|
|
126
|
+
* Derived where a source exists: the forge host variables from `FORGE_HOST_VARS`. The rest are `buildContainerEnv`'s
|
|
127
|
+
* own literals, held to it by a test that builds an env with every structural option set and finds no other key
|
|
128
|
+
* outside the credential.
|
|
129
|
+
*
|
|
130
|
+
* NOT the proxy variables (`HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `NODE_USE_ENV_PROXY`). With the egress policy
|
|
131
|
+
* armed the worker writes them itself and they are structural, but with `PI_EGRESS=0` an operator may forward them,
|
|
132
|
+
* and a forwarded proxy URL can carry a password. So they are non-secret only per job, and only with the exact
|
|
133
|
+
* value the worker wrote: `secretValuesOf`'s `workerSet`, which `runContainer` fills from `egressEnv` for that job.
|
|
134
|
+
*/
|
|
135
|
+
export const NON_SECRET_ENV_KEYS = new Set([
|
|
136
|
+
// Issue #693: a memory-derived concurrency count, never a credential; keep it in the structural venue env.
|
|
137
|
+
"PI_CODEMODE_MAX_WORKERS",
|
|
138
|
+
"PI_PROVIDER",
|
|
139
|
+
"PI_MODEL",
|
|
140
|
+
"PI_FORK_ENTRY",
|
|
141
|
+
"PI_FORK_POSITION",
|
|
142
|
+
"PI_THINKING_LEVEL",
|
|
143
|
+
"PI_MAX_TURNS",
|
|
144
|
+
"PI_MAX_TOKENS",
|
|
145
|
+
"PI_MAX_COST_MICROS",
|
|
146
|
+
"PI_JOB_ID",
|
|
147
|
+
"PLAYWRIGHT_BROWSERS_PATH",
|
|
148
|
+
"PLAYWRIGHT_MCP_BROWSER",
|
|
149
|
+
"PLAYWRIGHT_MCP_SANDBOX",
|
|
150
|
+
"PI_GLOBAL_ALLOW_EXTENSIONS",
|
|
151
|
+
"PI_PACKAGES",
|
|
152
|
+
"PI_SESSION_FILE",
|
|
153
|
+
"PI_FLOW",
|
|
154
|
+
"PI_COMMAND",
|
|
155
|
+
"PI_EXCLUDE_TOOLS",
|
|
156
|
+
"PI_EXCLUDE_EXTENSIONS",
|
|
157
|
+
"PI_EXCLUDE_EXTENSION_TOOLS",
|
|
158
|
+
"PI_EXCLUDE_TOOLS_STRICT",
|
|
159
|
+
"PI_MCP_SERVERS",
|
|
160
|
+
"PI_AWAIT_INPUT",
|
|
161
|
+
// Approval env holds declared actions and decision identifiers only; human text stays in event.json.
|
|
162
|
+
"PI_APPROVAL_GATES",
|
|
163
|
+
"PI_APPROVAL_DECISION",
|
|
164
|
+
"PI_REPORT_PROGRESS",
|
|
165
|
+
"PI_RECOVER",
|
|
166
|
+
"PI_RECOVERING",
|
|
167
|
+
"PI_STEER",
|
|
168
|
+
"PI_ALLOWED_MODELS",
|
|
169
|
+
"PI_OFFLINE",
|
|
170
|
+
"PI_EXIT_AUTH",
|
|
171
|
+
// Issue #686: names the transcript channel (`stderr`), never a secret; on openshell it must ride the exec's --env.
|
|
172
|
+
"PI_TRANSCRIPT",
|
|
173
|
+
// Issue #704 part 3: `1`, the bundle env file's flag, never a secret; on openshell it must ride the exec's --env.
|
|
174
|
+
"PI_BUNDLE_ENV",
|
|
175
|
+
// Issue #683: the runner's layout name, `sandbox` on the openshell venue.
|
|
176
|
+
"PI_JOB_LAYOUT",
|
|
177
|
+
// Issue #730: `1`, the readable runner's flag, never a secret; on openshell it must ride the exec's --env.
|
|
178
|
+
"PI_RUNNER_OBSERVABLE",
|
|
179
|
+
"HOME",
|
|
180
|
+
KEYLESS_ENV_NAME,
|
|
181
|
+
...FORGE_HOST_VARS,
|
|
182
|
+
]);
|
|
183
|
+
|
|
184
|
+
/** The variables the worker writes when the egress policy is armed, by name: what `workerSet` may vouch for. */
|
|
185
|
+
export const EGRESS_ENV_KEYS = Object.freeze(Object.keys(egressEnv({ armed: true })));
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The secret values of one job: every container env value whose key is not in `NON_SECRET_ENV_KEYS`, plus
|
|
189
|
+
* `extra` (the minted token and the exit-line key, which `runContainer` holds outside the env: the key never
|
|
190
|
+
* enters it at all). `workerSet` is the env the worker itself wrote for this job's egress (`egressEnv`, empty when
|
|
191
|
+
* the policy is off): a key in it counts as structural only while its value is exactly the one written there.
|
|
192
|
+
* Strings only; the length floor is the redactor's.
|
|
193
|
+
*/
|
|
194
|
+
export function secretValuesOf(env = {}, extra = [], { workerSet = {} } = {}) {
|
|
195
|
+
const values = [];
|
|
196
|
+
for (const [key, value] of Object.entries(env ?? {})) {
|
|
197
|
+
if (NON_SECRET_ENV_KEYS.has(key)) continue;
|
|
198
|
+
if (EGRESS_ENV_KEYS.includes(key) && Object.hasOwn(workerSet ?? {}, key) && workerSet[key] === value) continue;
|
|
199
|
+
if (typeof value === "string" && value !== "") values.push(value);
|
|
200
|
+
}
|
|
201
|
+
for (const value of extra) if (typeof value === "string" && value !== "") values.push(value);
|
|
202
|
+
// The password inside a URL-shaped value (a forwarded proxy, a database URL) is a secret on its own: a tool can
|
|
203
|
+
// print it without its URL (`curl -U user:password`, "the password is ..."). Added raw and percent-decoded.
|
|
204
|
+
for (const value of [...values]) {
|
|
205
|
+
const m = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/@\s]+)@/.exec(value);
|
|
206
|
+
if (!m) continue;
|
|
207
|
+
const colon = m[1].indexOf(":");
|
|
208
|
+
if (colon < 0) continue;
|
|
209
|
+
const password = m[1].slice(colon + 1);
|
|
210
|
+
if (password !== "") values.push(password);
|
|
211
|
+
try {
|
|
212
|
+
const decoded = decodeURIComponent(password);
|
|
213
|
+
if (decoded !== password && decoded !== "") values.push(decoded);
|
|
214
|
+
} catch {
|
|
215
|
+
// not valid percent-encoding: the raw form is already listed
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return values;
|
|
219
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a job will hold when the worker reserves for it (issue #642): the figure the operator console shows before a
|
|
3
|
+
* paid action (a re-run, an answer to a parked run) is confirmed. CONST-BUDGET-BEFORE-TOKENS is unchanged: the worker
|
|
4
|
+
* still reserves at pickup, and this only SAYS beforehand what that reserve will be.
|
|
5
|
+
*
|
|
6
|
+
* The rule is the worker's, imported rather than restated, which is the whole point of this module:
|
|
7
|
+
* - the per-job dollar cap is `effectiveCostCapMicros(trigger's maxCostUsd, deployment's maxCostUsd)` (money.mjs),
|
|
8
|
+
* the smaller of the two, each counted only when set: exactly what `effectiveJobOf` (index.mjs) gives `runJob`;
|
|
9
|
+
* - a job that cannot spend reserves nothing: `zeroRatedFor` below is the processor's own test (its reserve step
|
|
10
|
+
* calls it), a cap of 0 or every callable model served by a declared endpoint and zero-rated (model-endpoints.mjs);
|
|
11
|
+
* - every job takes one job-count slot in each window that applies.
|
|
12
|
+
*
|
|
13
|
+
* The deployment's settings resolve as the worker's do, `overlay > env > default` per key (runtime-settings.mjs over
|
|
14
|
+
* config.mjs), with an empty env value unset (`usdSetting`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { effectiveCostCapMicros } from "./money.mjs";
|
|
18
|
+
import { splitModelEntry } from "./model-ref.mjs";
|
|
19
|
+
import { zeroRatedVerdict } from "./model-endpoints.mjs";
|
|
20
|
+
import { DEFAULT_MODEL, DEFAULT_PROVIDER, allowedModelsFrom } from "./config.mjs";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The models a job may call (issue #501, #503 part 7), as `{ provider, id }` refs: its main model, and every entry of
|
|
24
|
+
* its effective allowed-model list when it has one. A list entry that does not split is kept as `null`, which the
|
|
25
|
+
* zero-rated check reads as "not zero-rated" (fail closed). Moved here from processor.mjs (issue #642), which imports
|
|
26
|
+
* it, so the estimate and the reserve judge the same refs.
|
|
27
|
+
*/
|
|
28
|
+
export function callableModelRefs(job) {
|
|
29
|
+
const refs = [{ provider: job.provider, id: job.model }];
|
|
30
|
+
for (const entry of Array.isArray(job.models) ? job.models : []) {
|
|
31
|
+
const ref = splitModelEntry(entry);
|
|
32
|
+
refs.push(ref === null ? null : { provider: ref.provider, id: ref.model });
|
|
33
|
+
}
|
|
34
|
+
return refs;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Can this EFFECTIVE job (provider, model, models and `maxCostMicros` resolved) spend nothing? `{ zeroRated: true }`
|
|
39
|
+
* for a cap that is already 0 (nothing to reserve), else `zeroRatedVerdict` over its callable models and the pickup's
|
|
40
|
+
* endpoint snapshot `{ models, endpoints }` (null: none, so never zero-rated). The processor's reserve step and the
|
|
41
|
+
* console's estimate both call this, so the two cannot disagree on the rule.
|
|
42
|
+
*/
|
|
43
|
+
export function zeroRatedFor(job, { modelEndpoints = null, builtinModel = () => null } = {}) {
|
|
44
|
+
if (job.maxCostMicros === 0) return { zeroRated: true };
|
|
45
|
+
return zeroRatedVerdict({ models: modelEndpoints?.models ?? null, endpoints: modelEndpoints?.endpoints ?? [], refs: callableModelRefs(job), builtinModel });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** An env dollar value as config.mjs's `usdSetting` reads it: unset or empty is null. Validated by the cap below. */
|
|
49
|
+
function envUsd(raw) {
|
|
50
|
+
return raw === undefined || raw === "" ? null : raw;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The reserve for `jobData` (the job's data as it will be queued), against the deployment's settings overlay and env:
|
|
55
|
+
* `{ slots: 1, dollarsMicros, capMicros, zeroRated }`, or `{ invalid }` when the deployment's own dollar setting does
|
|
56
|
+
* not parse (the worker refuses every job then, `settings-overlay-invalid`).
|
|
57
|
+
*
|
|
58
|
+
* - `capMicros`: the per-job cap the runner enforces before every priced call, or null for none.
|
|
59
|
+
* - `dollarsMicros`: what the worker holds in each dollar window that applies (the deployment's, a scoped or model
|
|
60
|
+
* row's): 0 for a zero-rated job, else the cap; null when there is no cap (then no window can be set either, the
|
|
61
|
+
* dollar invariant). With no dollar window set the worker holds nothing in dollars, and the runner still stops
|
|
62
|
+
* the job at `capMicros`.
|
|
63
|
+
* - `zeroRated`: whether the job can spend nothing at all.
|
|
64
|
+
*
|
|
65
|
+
* `modelEndpoints` (`{ models, endpoints }`, the worker's pickup snapshot) and `builtinModel` are the zero-rated
|
|
66
|
+
* check's inputs; without them a job is judged not zero-rated, the fail-closed direction (the estimate then names the
|
|
67
|
+
* larger figure, never a smaller one than the worker holds).
|
|
68
|
+
*/
|
|
69
|
+
export function estimateReserve(jobData, { overlay = {}, env = {}, modelEndpoints = null, builtinModel = () => null } = {}) {
|
|
70
|
+
const data = jobData ?? {};
|
|
71
|
+
const o = overlay ?? {};
|
|
72
|
+
// The deployment's allowed-model list (PI_ALLOWED_MODELS, env only, never the overlay), the worker's `effectiveJobOf`
|
|
73
|
+
// fallback when the job names none: with it, every listed model must be zero-rated too, since the job may switch to
|
|
74
|
+
// any of them. An env list the worker would refuse at boot cannot be judged at all, and judging it as "no list" would
|
|
75
|
+
// NARROW the refs to the main model alone (a smaller figure than a list could give), so it is judged not zero-rated:
|
|
76
|
+
// the estimate then names the cap, never less than a worker could hold.
|
|
77
|
+
let allowed = null;
|
|
78
|
+
let allowedInvalid = false;
|
|
79
|
+
try {
|
|
80
|
+
allowed = allowedModelsFrom(env ?? {});
|
|
81
|
+
} catch {
|
|
82
|
+
allowedInvalid = true;
|
|
83
|
+
}
|
|
84
|
+
const models = data.models ?? allowed ?? null;
|
|
85
|
+
let capMicros;
|
|
86
|
+
try {
|
|
87
|
+
capMicros = effectiveCostCapMicros(data.maxCostUsd, o.maxCostUsd ?? envUsd(env?.PI_MAX_COST_USD));
|
|
88
|
+
} catch (e) {
|
|
89
|
+
return { invalid: e?.message ?? String(e) };
|
|
90
|
+
}
|
|
91
|
+
const job = {
|
|
92
|
+
...data,
|
|
93
|
+
provider: data.provider ?? o.provider ?? env?.PI_PROVIDER ?? DEFAULT_PROVIDER,
|
|
94
|
+
model: data.model ?? o.model ?? env?.PI_MODEL ?? DEFAULT_MODEL,
|
|
95
|
+
...(models !== null ? { models } : {}),
|
|
96
|
+
maxCostMicros: capMicros,
|
|
97
|
+
};
|
|
98
|
+
if (capMicros === null) return { slots: 1, dollarsMicros: null, capMicros: null, zeroRated: false };
|
|
99
|
+
const zero = allowedInvalid && (data.models ?? null) === null ? { zeroRated: false } : zeroRatedFor(job, { modelEndpoints, builtinModel });
|
|
100
|
+
return { slots: 1, dollarsMicros: zero.zeroRated ? 0 : capMicros, capMicros, zeroRated: zero.zeroRated === true };
|
|
101
|
+
}
|
package/src/reserved-env.mjs
CHANGED
|
@@ -32,8 +32,16 @@ export const KEYLESS_ENV_NAME = "PI_DISPATCH_KEYLESS";
|
|
|
32
32
|
* a variable added to the closed map and not to this list fails there rather than becoming a hole here.
|
|
33
33
|
*/
|
|
34
34
|
export const CONTAINER_ENV_NAMES = new Set([
|
|
35
|
+
// Issue #730: the worker's choice of the readable runner node on the OpenShell venues (image/entrypoint.sh). Reserved,
|
|
36
|
+
// so neither `run.secrets` nor `PI_FORWARD_ENV` can make a job's key holders readable on any venue.
|
|
37
|
+
"PI_RUNNER_OBSERVABLE",
|
|
38
|
+
// Issue #693: derived from the resolved job memory, never chosen by a trigger or forwarded host value.
|
|
39
|
+
"PI_CODEMODE_MAX_WORKERS",
|
|
35
40
|
"PI_PROVIDER",
|
|
36
41
|
"PI_MODEL",
|
|
42
|
+
"PI_FORK_ENTRY",
|
|
43
|
+
"PI_FORK_POSITION",
|
|
44
|
+
"PI_THINKING_LEVEL",
|
|
37
45
|
"PI_MAX_TURNS",
|
|
38
46
|
"PI_MAX_TOKENS",
|
|
39
47
|
"PI_JOB_ID",
|
|
@@ -43,17 +51,44 @@ export const CONTAINER_ENV_NAMES = new Set([
|
|
|
43
51
|
"PI_FLOW",
|
|
44
52
|
"PI_COMMAND",
|
|
45
53
|
"PI_EXCLUDE_TOOLS",
|
|
46
|
-
|
|
54
|
+
// Issue #800: a trigger binding one through run.secrets could name what its own exclusions leave out, or stop a job.
|
|
55
|
+
"PI_EXCLUDE_EXTENSIONS",
|
|
56
|
+
"PI_EXCLUDE_EXTENSION_TOOLS",
|
|
57
|
+
"PI_EXCLUDE_TOOLS_STRICT",
|
|
58
|
+
"PI_AWAIT_INPUT", "PI_APPROVAL_GATES", "PI_APPROVAL_DECISION",
|
|
59
|
+
// Issue #644: a trigger binding it through run.secrets would offer itself a tool the operator did not arm.
|
|
60
|
+
"PI_REPORT_PROGRESS",
|
|
61
|
+
// Issue #645: a trigger binding either through run.secrets would arm checkpoints the operator did not, or make a fresh
|
|
62
|
+
// run believe it is a continuation and resume a checkpoint that is not its own.
|
|
63
|
+
"PI_RECOVER",
|
|
64
|
+
"PI_RECOVERING",
|
|
65
|
+
// Issue #646: a trigger binding it through run.secrets would tell the runner to watch an inbox the operator did not
|
|
66
|
+
// arm, on a run the worker never opened one for.
|
|
67
|
+
"PI_STEER",
|
|
68
|
+
// Issue #683: which file layout the runner reads. A trigger binding it through run.secrets would point the runner at
|
|
69
|
+
// a tree the worker never wrote, or, on a copying venue, back at /job, where nothing was uploaded.
|
|
70
|
+
"PI_JOB_LAYOUT",
|
|
47
71
|
"PI_MCP_SERVERS",
|
|
48
72
|
// Issues #501, #502: the per-job dollar cap (written by the worker since #501, from the trigger's and the
|
|
49
73
|
// deployment's caps) and the allowed-model list (reserved until #502 writes it). Neither can be bound through
|
|
50
74
|
// `run.secrets`: a trigger setting its own cap, or its own list, would be the policy choosing itself.
|
|
51
75
|
"PI_MAX_COST_MICROS",
|
|
52
76
|
"PI_ALLOWED_MODELS",
|
|
77
|
+
"PI_MODEL_ROLES_FILE",
|
|
78
|
+
"PI_AGENTS_FILE",
|
|
53
79
|
// Issue #545: tells the runner the exit line's key waits on stdin. A trigger setting it would block its own runner
|
|
54
80
|
// on a stdin no one writes, or turn the signed line off.
|
|
55
81
|
"PI_EXIT_AUTH",
|
|
82
|
+
// Issue #686: tells the runner to mirror its session file over stderr. A trigger setting it would send records no
|
|
83
|
+
// worker reads into the job log, or, with the image's key, stream a transcript the operator did not arm.
|
|
84
|
+
"PI_TRANSCRIPT",
|
|
85
|
+
// Issue #704 part 3: tells the runner to read a project secret bundle's plain variables from the job directory's
|
|
86
|
+
// bundle-env.json. A trigger setting it would make a runner refuse on a file nobody wrote, or read one it should not.
|
|
87
|
+
"PI_BUNDLE_ENV",
|
|
56
88
|
"PI_OFFLINE",
|
|
89
|
+
// Issue #796: names a canary's candidate. A trigger setting it would make a run report on a path nobody installed.
|
|
90
|
+
"PI_PACKAGE_CANARY",
|
|
91
|
+
"PI_SUBSCRIPTION_AUTH",
|
|
57
92
|
"PLAYWRIGHT_BROWSERS_PATH",
|
|
58
93
|
"PLAYWRIGHT_MCP_BROWSER",
|
|
59
94
|
"PLAYWRIGHT_MCP_SANDBOX",
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long the queue keeps a finished job, and how many runs the shared run index holds (issue #761). A LEAF with no
|
|
3
|
+
* imports: the worker, the receiver and the admin read the same values from here, so a value changed in one place
|
|
4
|
+
* cannot leave another reading the old one.
|
|
5
|
+
*
|
|
6
|
+
* `FORGE_JOB_RETENTION_*` is the dedup window of `REQ-DEDUP-BY-DELIVERY-GUID`: a forge job's id is the delivery's own
|
|
7
|
+
* id, and BullMQ's dedup is only "does the job key still exist", so the time a finished job is kept IS how long a
|
|
8
|
+
* redelivery is refused. 31 days is comfortably longer than any forge's redelivery window: GitHub documents "You can
|
|
9
|
+
* redeliver webhook deliveries that occurred in the past 3 days" and "GitHub does not automatically redeliver failed
|
|
10
|
+
* deliveries". Every marker that stands in for a job key once it is gone (a steered delivery's marker, the poller's
|
|
11
|
+
* cursor, the approval's finished mark, the console trail's claim) is derived from it, so shortening it shortens all of
|
|
12
|
+
* them together, and lengthening it cannot leave one of them shorter than the job it guards. So is the life of a finished
|
|
13
|
+
* job's model attempt key (`MODEL_ATTEMPT_FINISHED_TTL_MS`, below), which must outlive the finished job itself.
|
|
14
|
+
*
|
|
15
|
+
* BullMQ's `removeOnComplete.age` / `removeOnFail.age` are in SECONDS and a Valkey `PX` is in milliseconds, so both are
|
|
16
|
+
* exported and each site takes the one its call wants, never a conversion of its own.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The forge job retention in days: the delivery dedup window (`REQ-DEDUP-BY-DELIVERY-GUID`). */
|
|
20
|
+
export const FORGE_JOB_RETENTION_DAYS = 31;
|
|
21
|
+
/** The same, in seconds: BullMQ's `removeOnComplete.age` / `removeOnFail.age`, a Valkey `EX`. */
|
|
22
|
+
export const FORGE_JOB_RETENTION_S = FORGE_JOB_RETENTION_DAYS * 86_400;
|
|
23
|
+
/** The same, in milliseconds: a Valkey `PX`, an age compared with `Date.now()`. */
|
|
24
|
+
export const FORGE_JOB_RETENTION_MS = FORGE_JOB_RETENTION_S * 1000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How long a finished job's model attempt key is kept, in MILLISECONDS (the `PEXPIRE` of model-attempts.mjs, issue #812):
|
|
28
|
+
* a day past a forge job's retention, the longest age the queue keeps a finished job for, so the key outlives every
|
|
29
|
+
* finished job a console retry can still bring back to read it.
|
|
30
|
+
*/
|
|
31
|
+
export const MODEL_ATTEMPT_FINISHED_TTL_MS = FORGE_JOB_RETENTION_MS + 86_400_000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A local or cron job's retention, in seconds. Local jobs are not redelivered, so their window only has to cover a
|
|
35
|
+
* repeated `pi-dispatch run` and the console's re-run, not a forge's redelivery. Named here so the console can say how
|
|
36
|
+
* long a re-run's data is kept by the same values the queue uses.
|
|
37
|
+
*/
|
|
38
|
+
export const LOCAL_JOB_KEPT_COMPLETED_S = 24 * 3600;
|
|
39
|
+
export const LOCAL_JOB_KEPT_FAILED_S = 7 * 24 * 3600;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The shared run index's member cap (`PI_RUN_MIRROR_MAX_RUNS`). Every writer trims the index to its OWN cap, so in a
|
|
43
|
+
* fleet the lowest setting wins, and each trim raises the horizon, so a reader sees a cut rather than idle time.
|
|
44
|
+
* The floor is the console's widest read (1,000 runs): a smaller index would cut that read short. The ceiling bounds the
|
|
45
|
+
* portfolio snapshot's single read of the whole index.
|
|
46
|
+
*/
|
|
47
|
+
export const RUN_MIRROR_MAX_RUNS_DEFAULT = 5_000;
|
|
48
|
+
export const RUN_MIRROR_MAX_RUNS_MIN = 1_000;
|
|
49
|
+
export const RUN_MIRROR_MAX_RUNS_MAX = 20_000;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* `PI_RUN_MIRROR_MAX_RUNS` read leniently, for a reader that must not throw (the admin's paths): unset or empty is the
|
|
53
|
+
* default; a value outside the bounds, or not a plain integer, is the default with `problem` naming why. The worker
|
|
54
|
+
* reads the same key strictly (`config.mjs`) and refuses to start on a bad value, so a lenient reader only ever meets
|
|
55
|
+
* one the worker already refused.
|
|
56
|
+
*/
|
|
57
|
+
export function runMirrorMaxRunsOf(raw) {
|
|
58
|
+
if (raw === undefined || raw === null || raw === "") return { value: RUN_MIRROR_MAX_RUNS_DEFAULT };
|
|
59
|
+
// The worker's own rule (`boundedInt`): a plain integer as written, so "05000" or "1e4" is refused there and the
|
|
60
|
+
// default here, never two answers to one value.
|
|
61
|
+
const text = String(raw).trim();
|
|
62
|
+
const n = /^\d+$/.test(text) ? Number(text) : Number.NaN;
|
|
63
|
+
if (!Number.isSafeInteger(n) || String(n) !== text || n < RUN_MIRROR_MAX_RUNS_MIN || n > RUN_MIRROR_MAX_RUNS_MAX) {
|
|
64
|
+
return { value: RUN_MIRROR_MAX_RUNS_DEFAULT, problem: `not an integer ${RUN_MIRROR_MAX_RUNS_MIN}-${RUN_MIRROR_MAX_RUNS_MAX}` };
|
|
65
|
+
}
|
|
66
|
+
return { value: n };
|
|
67
|
+
}
|