@edgehero/pi-dispatch 1.10.3 → 2.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 +303 -150
- package/README.md +52 -0
- package/deploy/com.pi-dispatch.worker.plist +10 -4
- package/deploy/docker-compose.yml +49 -16
- package/deploy/egress-proxy.conf +32 -2
- package/deploy/nssm-install.cmd +12 -6
- package/deploy/pi-dispatch-egress-out.network +10 -0
- package/deploy/pi-dispatch-egress-proxy.container +50 -0
- package/deploy/pi-dispatch-netns-keeper.container +80 -0
- package/deploy/pi-dispatch-netns-keeper.network +18 -0
- package/deploy/pi-dispatch-valkey.container +51 -0
- package/deploy/pi-dispatch-valkey.network +16 -0
- package/deploy/receiver.service +6 -0
- package/deploy/worker-env-wrapper.cmd +12 -1
- package/deploy/worker-env-wrapper.sh +63 -37
- package/deploy/worker.service +18 -8
- package/package.json +15 -5
- package/src/azure-host.mjs +19 -0
- package/src/azure-identity.mjs +18 -2
- package/src/backend-conformance.mjs +71 -18
- package/src/backend-local.mjs +637 -21
- package/src/backend-podman.mjs +1168 -0
- package/src/backend-registry.mjs +86 -3
- package/src/backends.mjs +489 -37
- package/src/branch.mjs +7 -2
- package/src/cancel-cli.mjs +174 -0
- package/src/cancel-state.mjs +125 -0
- package/src/cli.mjs +188 -90
- package/src/config.mjs +503 -43
- package/src/connection.mjs +374 -8
- package/src/container-spec.mjs +102 -7
- package/src/daemon-facts.mjs +167 -0
- package/src/deployment-venue.mjs +158 -0
- package/src/docker-run.mjs +146 -15
- package/src/doctor.mjs +4756 -394
- package/src/egress-conf-copy.mjs +166 -0
- package/src/egress-proxy-state.mjs +151 -0
- package/src/egress.mjs +456 -25
- package/src/entry.mjs +27 -0
- package/src/env-allowlist.mjs +245 -40
- package/src/env-file.mjs +1869 -33
- package/src/exit-code.mjs +15 -0
- package/src/flow-gate.mjs +5 -3
- package/src/forgejo-host.mjs +19 -0
- package/src/forgejo-identity.mjs +21 -2
- package/src/get-token.mjs +67 -18
- package/src/git-dirty.mjs +9 -1
- package/src/git-hardening.mjs +33 -0
- package/src/github-app-setup.mjs +29 -12
- package/src/github-prompt.mjs +4 -1
- package/src/gitlab-host.mjs +19 -0
- package/src/gitlab-identity.mjs +19 -2
- package/src/host-pi.mjs +19 -3
- package/src/host-registry.mjs +29 -2
- package/src/identity.mjs +29 -4
- package/src/image-preflight.mjs +46 -11
- package/src/image-ref.mjs +21 -0
- package/src/index.mjs +363 -13
- package/src/init.mjs +197 -38
- package/src/job-user.mjs +252 -0
- package/src/json-duplicates.mjs +204 -0
- package/src/live-probes.mjs +1020 -0
- package/src/materialize.mjs +4 -11
- package/src/netns-keeper.mjs +264 -0
- package/src/on-failure.mjs +119 -0
- package/src/outbox.mjs +7 -0
- package/src/packages.mjs +2 -2
- package/src/podman-stack.mjs +1304 -0
- package/src/prepare-github.mjs +6 -6
- package/src/prepare-local.mjs +51 -17
- package/src/prepare.mjs +27 -6
- package/src/pricing.mjs +9 -5
- package/src/processor.mjs +506 -26
- package/src/provider-key.mjs +66 -0
- package/src/provider-steering.mjs +185 -0
- package/src/queue.mjs +35 -8
- package/src/redact.mjs +84 -0
- package/src/reserved-env.mjs +7 -3
- package/src/retention-sweep.mjs +178 -0
- package/src/run-container.mjs +181 -14
- package/src/run-history.mjs +105 -16
- package/src/runtime-observations.mjs +1152 -0
- package/src/runtime-settings.mjs +13 -8
- package/src/sandbox-cli.mjs +100 -95
- package/src/sandbox-store.mjs +612 -45
- package/src/sandbox.mjs +1459 -37
- package/src/schedules.mjs +16 -3
- package/src/secret-profiles.mjs +2 -1
- package/src/secrets.mjs +24 -6
- package/src/service-env.mjs +247 -0
- package/src/service.mjs +618 -28
- package/src/session-store.mjs +678 -53
- package/src/start.mjs +1348 -326
- package/src/subscriptions.mjs +7 -3
- package/src/transient.mjs +240 -0
- package/src/triggers-file.mjs +71 -15
- package/src/triggers.mjs +179 -19
- package/src/up.mjs +1399 -85
- package/src/valkey-auth.mjs +529 -0
- package/src/valkey-endpoint.mjs +367 -0
- package/src/watch-closer.mjs +158 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which of a provider's credential variables is the one to write, and to name (issue #311).
|
|
3
|
+
*
|
|
4
|
+
* pi's list for a provider is pi's to give: `providerKeyCandidates` in env-allowlist.mjs recovers it from
|
|
5
|
+
* `findEnvKeys` and no copy of it exists anywhere. What pi's list does NOT say is which of its entries is a
|
|
6
|
+
* subscription login rather than a service credential, and that distinction is needed in two places at once:
|
|
7
|
+
* `doctor` has to NAME a variable for an operator to set, and the worker has to CHOOSE one to inject an
|
|
8
|
+
* `auth.json` api-key login under. Those two must never answer differently -- a doctor line naming a
|
|
9
|
+
* variable the worker does not write is exactly the drift issue #286 was about.
|
|
10
|
+
*
|
|
11
|
+
* A SEPARATE MODULE with no imports at all, on the reserved-env.mjs precedent, because the import
|
|
12
|
+
* direction rules out both of the obvious homes. `env-allowlist.mjs` cannot import `doctor.mjs`: that
|
|
13
|
+
* would drag doctor's whole graph into the job path to read one regex. `doctor.mjs` cannot statically
|
|
14
|
+
* import `env-allowlist.mjs` either -- it reaches it through a DYNAMIC import on purpose, so that pi is
|
|
15
|
+
* not loaded before doctor's own Node-floor check has run and printed. A third module with no
|
|
16
|
+
* dependencies is the only shape that lets both have this for free.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// The ONE credential fact this project holds itself, and it has to hold one: this is a statement ABOUT a
|
|
20
|
+
// credential that must NOT be used, so it cannot come from pi's table of credentials that DO work --
|
|
21
|
+
// that table says which variables pi reads, never which of them is a subscription login.
|
|
22
|
+
// Checked and rejected: pi's provider descriptors carry an `auth.oauth` block, but it is per-PROVIDER,
|
|
23
|
+
// not per-variable -- `github-copilot` has one and exactly ONE key variable, so "this provider supports
|
|
24
|
+
// oauth" cannot name WHICH variable is the token.
|
|
25
|
+
// A suffix rule rather than a one-name set, because the expensive direction is the false green: the day
|
|
26
|
+
// pi adds a second provider's OAuth variable a set would silently bless it. Pinned against pi in
|
|
27
|
+
// worker/test/provider-key.test.mjs -- never against a second copy of a table.
|
|
28
|
+
export const OAUTH_KEY_RE = /_OAUTH_TOKEN$/;
|
|
29
|
+
|
|
30
|
+
// The second fact of the same kind, added with the pi 0.99.1 bump (issue #509): a variable pi reads for a
|
|
31
|
+
// provider that is NOT an API key, because pi sends it as a different header. At 0.99.1 pi lists
|
|
32
|
+
// ANTHROPIC_AUTH_TOKEN FIRST for `anthropic` (pi-ai/dist/env-api-keys.js getApiKeyEnvVars), and its
|
|
33
|
+
// resolver (pi-ai/dist/providers/anthropic.js) sends it as `Authorization: Bearer <value>` AHEAD of both
|
|
34
|
+
// ANTHROPIC_OAUTH_TOKEN and ANTHROPIC_API_KEY. Without this rule `apiKeyVariable` picked it: an auth.json
|
|
35
|
+
// API key would have been written into the container under the bearer name, so pi would send it as
|
|
36
|
+
// `Authorization: Bearer` rather than as the `x-api-key` an API key travels in, and doctor would have told
|
|
37
|
+
// an operator to set the bearer variable.
|
|
38
|
+
// pi's own `getEnvApiKey` skips it for the same reason, which is the evidence that the distinction is real
|
|
39
|
+
// rather than ours. A suffix rule, for OAUTH_KEY_RE's reason: a set naming one variable would silently
|
|
40
|
+
// bless the next provider's bearer variable. Pinned against pi in worker/test/provider-key.test.mjs.
|
|
41
|
+
export const BEARER_KEY_RE = /_AUTH_TOKEN$/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Why a variable pi reads is not an API key, or null when it is one. The two answers need different
|
|
45
|
+
* advice, so the caller is told which: an OAuth token is a subscription login that expires, a bearer token
|
|
46
|
+
* is a credential pi sends in another header and reads BEFORE the API key.
|
|
47
|
+
*/
|
|
48
|
+
export function nonApiKeyKind(name) {
|
|
49
|
+
if (OAUTH_KEY_RE.test(name)) return "oauth";
|
|
50
|
+
if (BEARER_KEY_RE.test(name)) return "bearer";
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The api-key variable to write and to name, given pi's candidate list for a provider in pi's own
|
|
56
|
+
* precedence order. Never the OAuth token and never the bearer token, whatever that precedence says: pi
|
|
57
|
+
* returns ANTHROPIC_AUTH_TOKEN then ANTHROPIC_OAUTH_TOKEN before ANTHROPIC_API_KEY, "set your subscription
|
|
58
|
+
* login" is wrong advice for an unattended service, and an api-key credential written under either name
|
|
59
|
+
* would be a value whose variable lies about what it is (under the bearer name pi would also send it in
|
|
60
|
+
* the wrong header). Falls back to the first candidate only for a provider with no API-key variable at
|
|
61
|
+
* all, which is no provider pi has today; `null` for a provider pi reads no key variable for, which is the
|
|
62
|
+
* caller's cue to refuse rather than to guess.
|
|
63
|
+
*/
|
|
64
|
+
export function apiKeyVariable(candidates) {
|
|
65
|
+
return candidates.find((name) => nonApiKeyKind(name) === null) ?? candidates[0] ?? null;
|
|
66
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The environment variables pi and its provider SDKs read to CONFIGURE a provider: where the request goes,
|
|
3
|
+
* and which credentials it carries (issue #314).
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS A REFUSAL. `run.secrets` lets a trigger name an environment variable, and the existing gates
|
|
6
|
+
* refuse the names the worker writes and the ones pi reads the resolved provider's KEY from. They refuse
|
|
7
|
+
* nothing that says where the request goes, and that is the worse of the two: substitution spends the
|
|
8
|
+
* trigger author's money, redirection sends the OPERATOR'S credential to a host the trigger chose.
|
|
9
|
+
*
|
|
10
|
+
* Measured against the 0.80.7 pin with a stubbed fetch and no real key, three providers, three routes (the
|
|
11
|
+
* derivation below was re-run at the 0.99.1 pin, issue #509):
|
|
12
|
+
* AZURE_OPENAI_BASE_URL -> the request goes to the named host carrying `api-key`
|
|
13
|
+
* GOOGLE_GEMINI_BASE_URL -> ... carrying `x-goog-api-key`
|
|
14
|
+
* AWS_ENDPOINT_URL -> ... carrying a SigV4 signature over the operator's Bedrock credential
|
|
15
|
+
*
|
|
16
|
+
* The azure case is not even a shadowed default: every azure model ships `baseUrl: ""`, so there the
|
|
17
|
+
* environment is the PRIMARY source, ahead of the resource name and ahead of the model.
|
|
18
|
+
*
|
|
19
|
+
* WHAT THIS SET DOES NOT CONTAIN, and the reason is the whole shape of the design: **a provider's KEY
|
|
20
|
+
* variables**. Those are `providerKeyCandidates`' business, refused PRE-SPEND against the job's own
|
|
21
|
+
* resolved provider, and the bound that gate keeps is deliberate and documented -- an `anthropic` job may
|
|
22
|
+
* bind `OPENAI_API_KEY` for a flow that talks to OpenAI itself, because refusing it would be this project
|
|
23
|
+
* claiming a namespace it does not own. Including them here would have broken that bound ARBITRARILY: only
|
|
24
|
+
* five of pi's thirty-eight key variables (at the 0.99.1 pin) happen to appear as literals in a scanned
|
|
25
|
+
* artifact, so `OPENAI_API_KEY` would refuse while `GROQ_API_KEY` and `HF_TOKEN` stayed bindable, and the
|
|
26
|
+
* documented rule would be false for reasons no operator could predict. They are subtracted, and the bolt
|
|
27
|
+
* subtracts them the same way rather than by hand. ONE is kept, by name and for a stated reason, in
|
|
28
|
+
* `RETAINED_KEY_VARIABLES` below.
|
|
29
|
+
* The AWS credential variables STAY, and they are not an exception: pi's key table has no entry for
|
|
30
|
+
* `amazon-bedrock` at all (`providerKeyCandidates("amazon-bedrock")` is empty), so nothing else reserves
|
|
31
|
+
* them and they are read by the SDK as provider configuration, which is exactly what this set is.
|
|
32
|
+
*
|
|
33
|
+
* DERIVED, AND THE DERIVATION IS BOLTED IN BOTH DIRECTIONS by `worker/test/provider-steering.test.mjs`,
|
|
34
|
+
* from the pinned artifacts rather than from a second copy of them: pi's own `dist`, plus every package pi
|
|
35
|
+
* imports a client from, discovered from pi's own import statements rather than from a list here -- so a
|
|
36
|
+
* pi bump that adds an SDK fails the bolt too. Four accessor spellings are matched, because the SDKs do
|
|
37
|
+
* not agree on one: `getProviderEnvValue`, `readEnv`, `getEnv` and `process.env["NAME"]`, the last of
|
|
38
|
+
* which is the only way `AZURE_OPENAI_ENDPOINT` is read.
|
|
39
|
+
*
|
|
40
|
+
* That derivation is why the set holds names nobody would have written down.
|
|
41
|
+
* `AWS_CONTAINER_CREDENTIALS_FULL_URI` makes the AWS SDK fetch credentials from a URL of the trigger's
|
|
42
|
+
* choosing, `AWS_WEB_IDENTITY_TOKEN_FILE` and `GOOGLE_APPLICATION_CREDENTIALS` are paths to credential
|
|
43
|
+
* files, `AWS_BEDROCK_SKIP_AUTH` is an auth bypass, and `GOOGLE_GENAI_USE_VERTEXAI` moves the request to a
|
|
44
|
+
* different Google product.
|
|
45
|
+
*
|
|
46
|
+
* THREE LIMITS, stated because a set like this is only worth what its boundary is honest about.
|
|
47
|
+
*
|
|
48
|
+
* The scan is ONE `node_modules` HOP DEEP: pi's own dist and the packages pi imports directly. It does not
|
|
49
|
+
* follow those packages' dependencies, and the AWS variables are read one level further in, inside
|
|
50
|
+
* `@smithy/core`. The names measured to matter from that layer are in the residual list below; the rest of
|
|
51
|
+
* that closure (`AWS_EC2_METADATA_SERVICE_ENDPOINT`, `AWS_ROLE_ARN`, `GCE_METADATA_HOST` and some forty
|
|
52
|
+
* more) is NOT covered. Recursing the whole closure would reserve most of the AWS and Google SDK surface
|
|
53
|
+
* and take a large bite out of what an operator may legitimately bind, so the boundary is deliberate.
|
|
54
|
+
*
|
|
55
|
+
* `NODE_OPTIONS`, `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE` and `NODE_TLS_REJECT_UNAUTHORIZED` are NOT here.
|
|
56
|
+
* They subvert any provider call, but they are properties of the RUNTIME rather than of a provider, they
|
|
57
|
+
* predate this gate, and `NODE_OPTIONS` additionally needs a file the attacker can place. They belong to
|
|
58
|
+
* whatever closes the runtime-hijack question, not to a set derived from what pi reads about providers.
|
|
59
|
+
* The same holds for `HOME`, `PATH`, `APPDATA`, `USERPROFILE` and `XDG_CONFIG_HOME`, which the scan DOES
|
|
60
|
+
* reach from the 0.99.1 pin on (issue #509): the Anthropic SDK reads the four directory variables only to
|
|
61
|
+
* locate its default config directory (`core/credentials`), and `PATH` inside its agent toolset, which pi
|
|
62
|
+
* does not use. The bolt subtracts them by name, with that reason, and asserts the scan still finds each,
|
|
63
|
+
* so a subtraction that stopped being needed is removed rather than carried. `HOME` is reserved anyway,
|
|
64
|
+
* by `reserved-env.mjs`, because the worker writes it (issue #341). The Anthropic-specific switch for the
|
|
65
|
+
* same directory, `ANTHROPIC_CONFIG_DIR`, IS in the set: it names a credential location outright.
|
|
66
|
+
*
|
|
67
|
+
* And a bound on the whole family, which no document here stated before: a trigger author picks the
|
|
68
|
+
* variable NAME and a vault REFERENCE, never a value. Exploiting any of these needs the operator's own
|
|
69
|
+
* vault to hold a useful string at a reference the author is allowed to name. That is equally true of
|
|
70
|
+
* `AZURE_OPENAI_BASE_URL`, the variable this issue was filed about, so it bounds the severity of the whole
|
|
71
|
+
* set rather than distinguishing parts of it.
|
|
72
|
+
*
|
|
73
|
+
* IMPORT-FREE, like `reserved-env.mjs` and `provider-key.mjs` beside it: `triggers.mjs` is the shared
|
|
74
|
+
* validator, the receiver loads it, and `admin/build.mjs` inlines it into the published console.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The steering variables a literal scan of the packages pi imports cannot reach.
|
|
80
|
+
*
|
|
81
|
+
* Named rather than quietly absent, because "derived, never curated" would otherwise be a claim the bolt
|
|
82
|
+
* cannot keep. Two reasons they are unreachable, and the test asserts BOTH still hold.
|
|
83
|
+
*
|
|
84
|
+
* The AWS four are read inside `@smithy/core`, one dependency hop past this scan's boundary, and two of
|
|
85
|
+
* them are additionally read through a key the resolver builds at runtime
|
|
86
|
+
* (`AWS_ENDPOINT_URL_<SERVICEID>`). They matter because pi stops pinning the Bedrock endpoint itself as
|
|
87
|
+
* soon as `AWS_REGION` or `AWS_PROFILE` is present, which is the ordinary way to configure Bedrock. Both
|
|
88
|
+
* measured: `AWS_ENDPOINT_URL` redirects the call, and `AWS_SHARED_CREDENTIALS_FILE` replaces the
|
|
89
|
+
* credential it is signed with. `AWS_CONFIG_FILE` does both, and can also name a `credential_process`
|
|
90
|
+
* shell command.
|
|
91
|
+
*
|
|
92
|
+
* The proxy spellings are read by pi's own `getProxyEnv`, which lowercases and uppercases the key it is
|
|
93
|
+
* given and asks for both. `EGRESS_ENV_VARS` owns `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` and keeps
|
|
94
|
+
* them; what is added here is the spellings it does not have. pi reads the LOWERCASE form FIRST, so
|
|
95
|
+
* `https_proxy` outranks the egress policy's own variable in pi's reader. Only the schemes a provider call
|
|
96
|
+
* can use are listed: `ws_proxy` and the rest are reachable in `getProxyEnv` but not from an HTTPS request.
|
|
97
|
+
*/
|
|
98
|
+
/**
|
|
99
|
+
* Provider KEY variables that stay reserved here although the bolt subtracts key variables in general.
|
|
100
|
+
*
|
|
101
|
+
* `ANTHROPIC_AUTH_TOKEN` was in this set from issue #314 on, because at 0.80.7 it was read only by the
|
|
102
|
+
* Anthropic SDK and was no key variable of pi's. At the 0.99.1 pin pi lists it FIRST among anthropic's key
|
|
103
|
+
* variables and sends it as `Authorization: Bearer` ahead of the API key (issue #509), which would move it
|
|
104
|
+
* to the per-provider pre-spend gate and make it bindable again for every non-anthropic job. Kept instead,
|
|
105
|
+
* because a version bump must not widen what a trigger may bind without an operator deciding it, and no
|
|
106
|
+
* deployment can be relying on binding a name that has been refused at load since #314. The bolt asserts it
|
|
107
|
+
* is still both a key variable pi reads and a name the scan finds, so the day either stops holding this
|
|
108
|
+
* list is revisited rather than carried.
|
|
109
|
+
*/
|
|
110
|
+
const RETAINED_KEY_VARIABLES = ["ANTHROPIC_AUTH_TOKEN"];
|
|
111
|
+
|
|
112
|
+
const UNREACHABLE_BY_SCAN = [
|
|
113
|
+
"AWS_CONFIG_FILE",
|
|
114
|
+
"AWS_ENDPOINT_URL",
|
|
115
|
+
"AWS_ENDPOINT_URL_BEDROCK_RUNTIME",
|
|
116
|
+
"AWS_SHARED_CREDENTIALS_FILE",
|
|
117
|
+
"ALL_PROXY",
|
|
118
|
+
"all_proxy",
|
|
119
|
+
"http_proxy",
|
|
120
|
+
"https_proxy",
|
|
121
|
+
"no_proxy",
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
export const PROVIDER_STEERING_VARS = new Set([
|
|
125
|
+
"ANTHROPIC_BASE_URL",
|
|
126
|
+
"ANTHROPIC_CONFIG_DIR",
|
|
127
|
+
"ANTHROPIC_CUSTOM_HEADERS",
|
|
128
|
+
"ANTHROPIC_ENVIRONMENT_ID",
|
|
129
|
+
"ANTHROPIC_ENVIRONMENT_KEY",
|
|
130
|
+
"ANTHROPIC_FEDERATION_RULE_ID",
|
|
131
|
+
"ANTHROPIC_IDENTITY_TOKEN",
|
|
132
|
+
"ANTHROPIC_IDENTITY_TOKEN_FILE",
|
|
133
|
+
"ANTHROPIC_LOG",
|
|
134
|
+
"ANTHROPIC_ORGANIZATION_ID",
|
|
135
|
+
"ANTHROPIC_PROFILE",
|
|
136
|
+
"ANTHROPIC_SCOPE",
|
|
137
|
+
"ANTHROPIC_SERVICE_ACCOUNT_ID",
|
|
138
|
+
"ANTHROPIC_SESSION_ID",
|
|
139
|
+
"ANTHROPIC_WEBHOOK_SIGNING_KEY",
|
|
140
|
+
"ANTHROPIC_WORKSPACE_ID",
|
|
141
|
+
"ANTHROPIC_WORK_ID",
|
|
142
|
+
"ANTHROPIC_WORK_SECRET",
|
|
143
|
+
"AWS_ACCESS_KEY_ID",
|
|
144
|
+
"AWS_BEARER_TOKEN_BEDROCK",
|
|
145
|
+
"AWS_BEDROCK_BASE_URL",
|
|
146
|
+
"AWS_BEDROCK_FORCE_CACHE",
|
|
147
|
+
"AWS_BEDROCK_FORCE_HTTP1",
|
|
148
|
+
"AWS_BEDROCK_SKIP_AUTH",
|
|
149
|
+
"AWS_CONTAINER_CREDENTIALS_FULL_URI",
|
|
150
|
+
"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
|
|
151
|
+
"AWS_DEFAULT_REGION",
|
|
152
|
+
"AWS_PROFILE",
|
|
153
|
+
"AWS_REGION",
|
|
154
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
155
|
+
"AWS_SESSION_TOKEN",
|
|
156
|
+
"AWS_WEB_IDENTITY_TOKEN_FILE",
|
|
157
|
+
"AZURE_OPENAI_API_VERSION",
|
|
158
|
+
"AZURE_OPENAI_BASE_URL",
|
|
159
|
+
"AZURE_OPENAI_DEPLOYMENT_NAME_MAP",
|
|
160
|
+
"AZURE_OPENAI_ENDPOINT",
|
|
161
|
+
"AZURE_OPENAI_RESOURCE_NAME",
|
|
162
|
+
"GCLOUD_PROJECT",
|
|
163
|
+
"GOOGLE_API_KEY",
|
|
164
|
+
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
165
|
+
"GOOGLE_CLOUD_LOCATION",
|
|
166
|
+
"GOOGLE_CLOUD_PROJECT",
|
|
167
|
+
"GOOGLE_GEMINI_BASE_URL",
|
|
168
|
+
"GOOGLE_GENAI_USE_ENTERPRISE",
|
|
169
|
+
"GOOGLE_GENAI_USE_VERTEXAI",
|
|
170
|
+
"GOOGLE_VERTEX_BASE_URL",
|
|
171
|
+
"KIMI_CODE_OAUTH_HOST",
|
|
172
|
+
"KIMI_OAUTH_HOST",
|
|
173
|
+
"OPENAI_ADMIN_KEY",
|
|
174
|
+
"OPENAI_API_VERSION",
|
|
175
|
+
"OPENAI_BASE_URL",
|
|
176
|
+
"OPENAI_CUSTOM_HEADERS",
|
|
177
|
+
"OPENAI_LOG",
|
|
178
|
+
"OPENAI_ORG_ID",
|
|
179
|
+
"OPENAI_PROJECT_ID",
|
|
180
|
+
"OPENAI_WEBHOOK_SECRET",
|
|
181
|
+
"PI_CACHE_RETENTION",
|
|
182
|
+
"PI_OAUTH_CALLBACK_HOST",
|
|
183
|
+
...RETAINED_KEY_VARIABLES,
|
|
184
|
+
...UNREACHABLE_BY_SCAN,
|
|
185
|
+
]);
|
package/src/queue.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Queue } from "bullmq";
|
|
2
|
+
import { assertJudgedConnection, onValkeyError } from "./connection.mjs";
|
|
2
3
|
import { chainedJobId, localJobId, deliveryJobId, gitlabDeliveryJobId, forgeDeliveryJobId } from "./job-id.mjs";
|
|
3
4
|
import { targetSeparator } from "./forges.mjs";
|
|
4
5
|
import { PR_CLOSE_ACTIONS } from "./triggers.mjs";
|
|
@@ -68,7 +69,12 @@ export { chainedJobId, localJobId, deliveryJobId, gitlabDeliveryJobId, forgeDeli
|
|
|
68
69
|
* single-host deployment never names anything else.
|
|
69
70
|
*/
|
|
70
71
|
export function makeQueue(connection, { name = QUEUE } = {}) {
|
|
71
|
-
|
|
72
|
+
// Issue #464: only a connection `parseConnection` built, which judges and pins the Valkey it dials.
|
|
73
|
+
assertJudgedConnection(connection);
|
|
74
|
+
const queue = new Queue(name, { connection });
|
|
75
|
+
// Issue #468: an error of this queue is one line, its message, never BullMQ's console.error of the whole object.
|
|
76
|
+
onValkeyError(queue, `queue ${name}`);
|
|
77
|
+
return queue;
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
/**
|
|
@@ -78,7 +84,7 @@ export function makeQueue(connection, { name = QUEUE } = {}) {
|
|
|
78
84
|
* removeOnComplete keeps the dedup window ~= the retention. Unlike webhooks, local jobs are not
|
|
79
85
|
* redelivered, so a modest window is enough.
|
|
80
86
|
*/
|
|
81
|
-
export async function enqueueLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, image, backend, skillsDir, secrets, secretsProfile, chainDepth, parentJobId, jobId, now = new Date() }) {
|
|
87
|
+
export async function enqueueLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, image, backend, excludeTools, skillsDir, secrets, secretsProfile, chainDepth, parentJobId, jobId, now = new Date() }) {
|
|
82
88
|
const minute = now.toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM -- the dedup window
|
|
83
89
|
// A caller-supplied jobId (the outbox collector's retry-idempotent chainedJobId) wins; otherwise the
|
|
84
90
|
// minute-windowed localJobId is the dedup key. A command job (issue #189) fills the flow slot with
|
|
@@ -110,6 +116,10 @@ export async function enqueueLocalJob(queue, { folder, flow, task, command, prov
|
|
|
110
116
|
// there becomes agent-visible input, and where the box was built is the worker's business rather
|
|
111
117
|
// than the agent's.
|
|
112
118
|
...(backend !== undefined && { backend }),
|
|
119
|
+
// #291. WHAT the session's agent may not do. Conditional like `backend`, and at JOB level for the
|
|
120
|
+
// same reason: `trigger` is copied verbatim into /job/event.json, and a permission boundary is the
|
|
121
|
+
// worker's business, never agent-visible input to reason about.
|
|
122
|
+
...(excludeTools !== undefined && { excludeTools }),
|
|
113
123
|
// The host directory of operator-authored skills this trigger injects (REQ-PER-TRIGGER-SKILLS).
|
|
114
124
|
// Conditional like `image`, so an unflagged job's data stays byte-identical, and at JOB level rather
|
|
115
125
|
// than inside `trigger` because a worker-host path is an execution knob, not a fact about the
|
|
@@ -138,9 +148,13 @@ export async function enqueueLocalJob(queue, { folder, flow, task, command, prov
|
|
|
138
148
|
const SEMANTIC_WINDOW_MS = 10 * 60 * 1000;
|
|
139
149
|
|
|
140
150
|
/**
|
|
141
|
-
* Enqueue a GitHub-triggered job. Returns
|
|
142
|
-
*
|
|
143
|
-
*
|
|
151
|
+
* Enqueue a GitHub-triggered job. Returns `{ jobId, deduplicated, survivingJobId? }` -- the computed
|
|
152
|
+
* per-delivery id, plus whether the SEMANTIC window swallowed this delivery (issue #289; the GUID
|
|
153
|
+
* layer's replays are deliberately invisible, see the comparison below). `enqueueLocalJob` keeps its
|
|
154
|
+
* plain string return: it carries no `deduplication` option, so there is nothing the comparison could
|
|
155
|
+
* see, and its return is consumed as a bare id by the CLI and the outbox. The data shape is what
|
|
156
|
+
* prepare/runJob consumes for the github kind. No `sha` field: the commit is resolved fresh in prepare
|
|
157
|
+
* (C1), so baking a possibly-stale sha here would only race the branch head.
|
|
144
158
|
*
|
|
145
159
|
* `target` is the discriminated subject of the job -- `{ type:"issue"|"pull_request", number, title,
|
|
146
160
|
* body, ... }` -- built by the receiver's filter from the INT-WEBHOOK-PAYLOAD-SUBSET fields. Its `number`
|
|
@@ -197,7 +211,7 @@ export async function enqueueGitLabJob(queue, fields) {
|
|
|
197
211
|
* window, replicas never coalesce against each other, and an unflagged job's dedup id is the same string it
|
|
198
212
|
* has always been.
|
|
199
213
|
*/
|
|
200
|
-
export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, target, flow, command, trigger, provider, model, maxTurns, packages, image, backend, skillsDir, instructions, resume, secrets, secretsProfile, waitFor, replica, replicas }) {
|
|
214
|
+
export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, target, flow, command, trigger, provider, model, maxTurns, packages, image, backend, excludeTools, skillsDir, instructions, resume, secrets, secretsProfile, waitFor, replica, replicas }) {
|
|
201
215
|
const jobId = forgeDeliveryJobId(kind, trigger?.deliveryId, replica);
|
|
202
216
|
// `packages` (whether to load the operator-staged pi packages) and `image` (which container image to run)
|
|
203
217
|
// come off the MATCHED trigger (INT-TRIGGERS-FILE-CONTRACT / REQ-GLOBAL-PI-OVERLAY) and land on `data`
|
|
@@ -231,6 +245,10 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
|
|
|
231
245
|
// there becomes agent-visible input, and where the box was built is the worker's business rather
|
|
232
246
|
// than the agent's.
|
|
233
247
|
...(backend !== undefined && { backend }),
|
|
248
|
+
// #291. WHAT the session's agent may not do. Conditional like `backend`, and at JOB level for the
|
|
249
|
+
// same reason: `trigger` is copied verbatim into /job/event.json, and a permission boundary is the
|
|
250
|
+
// worker's business, never agent-visible input to reason about.
|
|
251
|
+
...(excludeTools !== undefined && { excludeTools }),
|
|
234
252
|
// The host directory of operator-authored skills this trigger injects (REQ-PER-TRIGGER-SKILLS).
|
|
235
253
|
// Conditional like `image`, so an unflagged job's data stays byte-identical, and at JOB level rather
|
|
236
254
|
// than inside `trigger` because a worker-host path is an execution knob, not a fact about the
|
|
@@ -279,7 +297,7 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
|
|
|
279
297
|
// narrow to the matched action word, like the PR half already does.
|
|
280
298
|
const isCloseJob = matched?.type === "issue" || (matched?.type === "pull_request" && PR_CLOSE_WORDS.has(matched?.action));
|
|
281
299
|
const flowSlot = `${isCloseJob ? "closed:" : ""}${command !== undefined ? `cmd:${command}` : flow}`;
|
|
282
|
-
await queue.add(kind, data, {
|
|
300
|
+
const added = await queue.add(kind, data, {
|
|
283
301
|
jobId,
|
|
284
302
|
// A command job (issue #189) fills the semantic key's flow slot with `cmd:<command>`: a command
|
|
285
303
|
// trigger carries no flow, so the slot would otherwise read `undefined` for every command and one
|
|
@@ -293,7 +311,16 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
|
|
|
293
311
|
removeOnComplete: { age: 31 * 24 * 3600 }, // age in seconds -- do not cross units with the ms ttl above
|
|
294
312
|
removeOnFail: { age: 31 * 24 * 3600 },
|
|
295
313
|
});
|
|
296
|
-
return
|
|
314
|
+
// Issue #289: WHICH of the two dedup layers spoke is readable off `queue.add`'s return, and only one
|
|
315
|
+
// of them is. Verified at bullmq 5.80.4's own Lua: a jobId collision (the GUID shield,
|
|
316
|
+
// REQ-DEDUP-BY-DELIVERY-GUID) returns THE SAME id -- structurally invisible here, and correctly so,
|
|
317
|
+
// because a forge retry of a delivery that IS queued deserves the answer "queued" -- while the
|
|
318
|
+
// `deduplication` option (the 10-minute semantic window) returns the EXISTING job's DIFFERENT id.
|
|
319
|
+
// So `added.id !== jobId` means this delivery was swallowed by the window and created nothing, which
|
|
320
|
+
// the receiver used to log as `enqueued` and answer as success. A defensive undefined (a fake queue
|
|
321
|
+
// predating the comparison) reads as not-deduplicated, today's behaviour exactly.
|
|
322
|
+
if (added?.id && added.id !== jobId) return { jobId, deduplicated: true, survivingJobId: added.id };
|
|
323
|
+
return { jobId, deduplicated: false };
|
|
297
324
|
}
|
|
298
325
|
|
|
299
326
|
/**
|
package/src/redact.mjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Take credentials out of a subprocess's own words, so a fault line can carry them (issue #339).
|
|
3
|
+
*
|
|
4
|
+
* IMPORT-FREE, and it has to stay that way. `backend-registry.mjs` and `retention-sweep.mjs` import nothing
|
|
5
|
+
* at all today, and this is their first import; a heavy one would pull the docker adapter into every graph
|
|
6
|
+
* that reaches `reapAll`. Same shape as `transient.mjs`, which says the same thing about itself.
|
|
7
|
+
*
|
|
8
|
+
* WHY A SCRUBBER AND NOT A FIXED TOKEN, which is the other shape this repo already uses
|
|
9
|
+
* (`classifyEndpointFailure` classifies on values and never reads stderr). The twelve call sites are not one
|
|
10
|
+
* kind of error. Five carry filesystem faults where the message IS the diagnosis (`EACCES: permission
|
|
11
|
+
* denied, scandir '/var/lib/...'`), and two exist to catch a throwing FACTORY, where `makeSandboxReaperFn is
|
|
12
|
+
* not a function` is the entire value of the line. A token would delete the diagnosis at most sites to
|
|
13
|
+
* protect three. A byte COUNTER (`secrets.mjs`) is right where the text is always secret-adjacent; here it
|
|
14
|
+
* is usually not.
|
|
15
|
+
*
|
|
16
|
+
* THE RULE IS THE URL GRAMMAR, never any runtime's prose, so Podman's different wording and its own
|
|
17
|
+
* connection URIs (`ssh://user@host:22/run/user/1000/podman/podman.sock`) are covered by construction.
|
|
18
|
+
* Nothing here mentions docker, a daemon, a context or an exit code.
|
|
19
|
+
*
|
|
20
|
+
* WHAT IT DOES NOT CATCH, stated rather than implied. A credential holding whitespace or a bare quote that
|
|
21
|
+
* the runtime printed UNQUOTED: both docker and podman print an unparseable endpoint through Go's `%q`,
|
|
22
|
+
* which quotes and escapes it, and the quoted rule covers that exactly. Closing the unquoted case needs a
|
|
23
|
+
* rule that crosses whitespace, and such a rule eats the useful half of every line holding an unrelated `@`.
|
|
24
|
+
* It also does not catch a secret that is not in a userinfo position: a bearer token echoed as a bare word,
|
|
25
|
+
* a registry auth blob, the contents of a key. And it deliberately keeps the HOST, which is the fact an
|
|
26
|
+
* operator needs and is never credential material.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `scheme://userinfo@host` inside a Go `%q`-quoted region, where `\X` is an escape rather than a delimiter.
|
|
31
|
+
* Both runtimes print an endpoint they could not parse this way, and it is the only form that survives a
|
|
32
|
+
* credential containing whitespace or a quote.
|
|
33
|
+
*/
|
|
34
|
+
const QUOTED_USERINFO = /"([a-z][a-z0-9+.-]*:\/\/)?(?:[^"\\\n]|\\.)*@/gi;
|
|
35
|
+
/** The same, unquoted: a run of non-delimiter characters up to and including the LAST `@` in it. */
|
|
36
|
+
const BARE_USERINFO = /([a-z][a-z0-9+.-]*:\/\/)?[^\s"'`<>]*@/gi;
|
|
37
|
+
/** The account segment of a home path, which an identity-file error carries (no-pii-in-logs). */
|
|
38
|
+
const POSIX_HOME = /(\/(?:home|Users)\/)[^/\s"'`<>:,;]+/g;
|
|
39
|
+
const WINDOWS_HOME = /([A-Za-z]:\\Users\\)[^\\\s"'`<>,;]+/g;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The cap. Applied AFTER scrubbing, never before: a cut that lands inside a credential leaves its prefix,
|
|
43
|
+
* which is the whole failure mode this file exists to prevent.
|
|
44
|
+
*/
|
|
45
|
+
export const SCRUBBED_MAX = 300;
|
|
46
|
+
|
|
47
|
+
/** Any `"..."` region, so a fragment repeated outside its URL can be checked against what was just taken. */
|
|
48
|
+
const QUOTED_REGION = /"((?:[^"\\\n]|\\.)*)"/g;
|
|
49
|
+
/** Below this, a fragment is too short to redact elsewhere without eating the diagnosis around it. */
|
|
50
|
+
const MIN_ECHOED = 2;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A subprocess's message with anything in a userinfo position, and any home-directory account, replaced.
|
|
54
|
+
*
|
|
55
|
+
* TWO PASSES, because one is not enough against a MEASURED error. Go's URL parser quotes the offending
|
|
56
|
+
* fragment a second time in its own words: `parse "ssh://bob:pa?ss@remote": invalid port ":pa" after host`.
|
|
57
|
+
* The first pass takes the userinfo inside the URL and leaves `":pa"` standing beside it. So whatever the
|
|
58
|
+
* first pass removed is treated as a known secret for the rest of the string, and any QUOTED region that is
|
|
59
|
+
* a substring of it goes too.
|
|
60
|
+
*
|
|
61
|
+
* QUOTED regions only, and a length floor, because the safe direction here has a cost: a one-character
|
|
62
|
+
* password would otherwise redact every occurrence of that character and destroy the diagnosis this file
|
|
63
|
+
* exists to preserve. A fragment shorter than two characters, or one echoed UNQUOTED, survives -- stated
|
|
64
|
+
* rather than claimed away, and neither runtime is known to echo one that way.
|
|
65
|
+
*/
|
|
66
|
+
export function scrubCredentials(value) {
|
|
67
|
+
if (typeof value !== "string" || value === "") return "unknown";
|
|
68
|
+
const taken = [];
|
|
69
|
+
const remember = (m, scheme) => {
|
|
70
|
+
taken.push(m);
|
|
71
|
+
return `${scheme ?? ""}[redacted]@`;
|
|
72
|
+
};
|
|
73
|
+
let out = value
|
|
74
|
+
.replace(QUOTED_USERINFO, (m, scheme) => `"${remember(m.slice(1), scheme)}`)
|
|
75
|
+
.replace(BARE_USERINFO, remember)
|
|
76
|
+
.replace(POSIX_HOME, "$1[redacted]")
|
|
77
|
+
.replace(WINDOWS_HOME, "$1[redacted]");
|
|
78
|
+
if (taken.length > 0) {
|
|
79
|
+
out = out.replace(QUOTED_REGION, (m, inner) =>
|
|
80
|
+
inner.length >= MIN_ECHOED && taken.some((t) => t.includes(inner)) ? '"[redacted]"' : m,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return out.length > SCRUBBED_MAX ? `${out.slice(0, SCRUBBED_MAX)}\u2026` : out;
|
|
84
|
+
}
|
package/src/reserved-env.mjs
CHANGED
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
* have it for free.
|
|
14
14
|
*
|
|
15
15
|
* Only the STATIC names live here. The rest of the closed map is deployment state and cannot be known
|
|
16
|
-
* from a triggers file at all: the provider
|
|
17
|
-
*
|
|
18
|
-
* processor, where the resolved provider and the
|
|
16
|
+
* from a triggers file at all: the provider's credential variable names come from `providerKeyCandidates`
|
|
17
|
+
* once the job's provider is resolved, and `PI_FORWARD_ENV` is an operator env list. Those two are refused
|
|
18
|
+
* PRE-SPEND, in the processor, where the resolved provider and the operator's forward list are in hand. `MINTED_TOKEN_VARS` and
|
|
19
19
|
* `FORGE_HOST_VARS` (forges.mjs) and `EGRESS_ENV_VARS`/`WORKER_ONLY_SECRET_VARS` (config.mjs) stay in
|
|
20
20
|
* their own modules and are imported by the validator beside this one, never copied into it.
|
|
21
21
|
*
|
|
@@ -33,8 +33,12 @@ export const CONTAINER_ENV_NAMES = new Set([
|
|
|
33
33
|
"PI_SESSION_FILE",
|
|
34
34
|
"PI_FLOW",
|
|
35
35
|
"PI_COMMAND",
|
|
36
|
+
"PI_EXCLUDE_TOOLS",
|
|
36
37
|
"PI_OFFLINE",
|
|
37
38
|
"PLAYWRIGHT_BROWSERS_PATH",
|
|
38
39
|
"PLAYWRIGHT_MCP_BROWSER",
|
|
39
40
|
"PLAYWRIGHT_MCP_SANDBOX",
|
|
41
|
+
// Issue #341: set beside `--user` so a uid with no passwd entry has a writable home. A new reservation, so a
|
|
42
|
+
// triggers file binding a secret named HOME is now refused at parse (worker, receiver and admin alike).
|
|
43
|
+
"HOME",
|
|
40
44
|
]);
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// The one import this module takes, and it is otherwise an import-free leaf (issue #339).
|
|
2
|
+
import { scrubCredentials } from "./redact.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The periodic retention sweep (issue #292, OQ-007).
|
|
6
|
+
*
|
|
7
|
+
* Every reaper in this project swept ONCE, at boot: the run history (`makeLogReaper`), the retained
|
|
8
|
+
* sandboxes (`makeSandboxReaper`) and the session store (`sessionStore.reapSessions`). The supported
|
|
9
|
+
* deployment shape is a service unit that restarts only on failure, so the healthy worker was exactly
|
|
10
|
+
* the one that never re-swept. Thirty days of uptime held thirty days of GROWTH, not thirty days of
|
|
11
|
+
* retention, and the three windows an operator configured described nothing.
|
|
12
|
+
*
|
|
13
|
+
* This is one `.unref()`'d interval that re-runs the three closures boot already built. It rebuilds
|
|
14
|
+
* nothing, so one configuration read serves boot and every tick after it and the two cannot drift.
|
|
15
|
+
*
|
|
16
|
+
* A MODULE rather than a few lines inline in `start.mjs`, for a reason that is about testing and not
|
|
17
|
+
* about tidiness: `worker/test/start-wiring.test.mjs` skips its entire file unless `VALKEY_TEST_URL` is
|
|
18
|
+
* set, so anything living in the boot function is exercised only in CI. The sharp edges of an interval
|
|
19
|
+
* -- re-entrancy, an idempotent close, a sweep still running when the next tick fires -- are precisely
|
|
20
|
+
* what wants pinning on a laptop, and here they get a plain test file with no queue in it.
|
|
21
|
+
*
|
|
22
|
+
* REJECTED: a `while (!stopped) { await sweep(); await sleep(ms); }` loop on `receiver/src/poller.mjs`'s
|
|
23
|
+
* pattern. It gets non-overlap for free and is already proven testable in this repo, but its `setTimeout`
|
|
24
|
+
* is not unref'd and would hold the worker's event loop open for up to a day, and `close()` becomes a
|
|
25
|
+
* promise handshake rather than a `clearInterval`. The poller IS its process's main loop and wants to
|
|
26
|
+
* hold the loop open; a background sweep must not.
|
|
27
|
+
*
|
|
28
|
+
* The interval idiom is `host-registry.mjs`'s, deliberately, down to the ordering inside `close()`. Two
|
|
29
|
+
* divergences from it are marked at their sites, because a reader who knows that file will otherwise
|
|
30
|
+
* "fix" them back.
|
|
31
|
+
*
|
|
32
|
+
* WHAT THIS COSTS THAT THE BOOT SWEEP DID NOT, and it is the one genuinely new failure mode here. All
|
|
33
|
+
* three reapers delete SYNCHRONOUSLY (`rmSync`, `unlinkSync`), which was free at boot because nothing was
|
|
34
|
+
* in flight, and is not free on a timer beside draining jobs: a long delete blocks the event loop, and
|
|
35
|
+
* `index.mjs` sets `maxStalledCount: 0` against BullMQ's 30s lock, so a loop blocked past the renewal
|
|
36
|
+
* window FAILS a paid job rather than silently re-running it. The direction is safe and the outcome is
|
|
37
|
+
* still new. Two things bound it: this loop yields between stores, and the sandbox reaper -- the only one
|
|
38
|
+
* that deletes whole trees, each a repository clone -- yields between entries. What remains is one
|
|
39
|
+
* directory's own `rmSync`, which is why that residual is stated here rather than implied.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** The default cadence. A named constant on HOST_BEAT_MS's precedent, not a literal at the call site. */
|
|
43
|
+
export const SWEEP_INTERVAL_HOURS = 24;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* How long `close()` will wait for an in-flight sweep before giving up on it.
|
|
47
|
+
*
|
|
48
|
+
* `index.mjs`'s shutdown says a closer that never settles blocks `process.exit(0)`, "which no closer
|
|
49
|
+
* here does" -- and an unbounded drain would have made that sentence false. The obvious defence, that
|
|
50
|
+
* the sweep's own docker call is capped at 5s, is NOT true: `listRunningSandboxes` uses
|
|
51
|
+
* `execFile`'s `timeout`, which only sends SIGTERM and then still waits for the child's `close`, so a
|
|
52
|
+
* `docker ps` wedged on a dead daemon socket never settles at all.
|
|
53
|
+
*
|
|
54
|
+
* NOT unref'd, on `host-registry`'s own reasoning: an unref'd timer does not fire when nothing else
|
|
55
|
+
* holds the loop, which is exactly the shutdown this bound exists for. The cost is that a hung sweep
|
|
56
|
+
* delays exit by at most this long, which is the point.
|
|
57
|
+
*/
|
|
58
|
+
const SWEEP_DRAIN_TIMEOUT_MS = 10_000;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The ceiling, and it is not decoration. `setInterval` clamps a delay above 2^31-1 ms (about 24.85 days)
|
|
62
|
+
* to 1ms, so a fat-fingered `PI_SWEEP_INTERVAL_HOURS=1000` would silently become a hot loop sweeping the
|
|
63
|
+
* filesystem as fast as the event loop allows. One week is the refusal point rather than Node's own
|
|
64
|
+
* ceiling, because an interval longer than a week is not a sweep, it is `0` with extra steps, and `0`
|
|
65
|
+
* already says that clearly.
|
|
66
|
+
*/
|
|
67
|
+
export const SWEEP_INTERVAL_MAX_HOURS = 168;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build the sweep. `reapers` is an ORDERED array of `{ name, reap }` -- logs, sandboxes, sessions, the
|
|
71
|
+
* same order boot runs them in, so a tick's log lines read like a boot's. An object would read as
|
|
72
|
+
* unordered, and this one is not.
|
|
73
|
+
*
|
|
74
|
+
* `setIntervalFn`/`clearIntervalFn` are injected. No production caller passes them; they exist because
|
|
75
|
+
* this repo has no fake-timer infrastructure at all and its stated doctrine is to inject a seam rather
|
|
76
|
+
* than reach for a global. The alternative -- a 1ms interval and a real `sleep` in the test -- would be a
|
|
77
|
+
* wall-clock-dependent test, which is the exact class issue #293 exists to eliminate, landing in the same
|
|
78
|
+
* change that adds #293's guard.
|
|
79
|
+
*/
|
|
80
|
+
export function makeRetentionSweep({ reapers, intervalMs, log = () => {}, setIntervalFn = setInterval, clearIntervalFn = clearInterval }) {
|
|
81
|
+
let timer = null;
|
|
82
|
+
let closed = false;
|
|
83
|
+
let inFlight = null;
|
|
84
|
+
|
|
85
|
+
async function sweepOnce() {
|
|
86
|
+
if (closed) return;
|
|
87
|
+
// DIVERGENCE from host-registry, which has no overlap guard: a beat is three bounded Redis writes
|
|
88
|
+
// against a 15s interval and two overlapping beats write the same values, while a sweep is
|
|
89
|
+
// unbounded filesystem I/O with `rmSync` in it. At the 24h default this is close to unreachable --
|
|
90
|
+
// `listRunningSandboxes` alone is capped at 5s by its own exec timeout -- but it is what keeps a
|
|
91
|
+
// short PI_SWEEP_INTERVAL_HOURS against a slow network filesystem from stacking sweeps that race
|
|
92
|
+
// each other's deletes.
|
|
93
|
+
if (inFlight) {
|
|
94
|
+
log("retention_sweep_overlapped", {});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const startedAt = Date.now();
|
|
98
|
+
inFlight = (async () => {
|
|
99
|
+
for (const entry of reapers) {
|
|
100
|
+
try {
|
|
101
|
+
// Destructured INSIDE the try: a malformed entry must be one log line like any other
|
|
102
|
+
// reaper fault, not a rejection out of a function the interval calls with `void`.
|
|
103
|
+
const { name, reap } = entry;
|
|
104
|
+
await reap();
|
|
105
|
+
// Between stores, so one tick is never a single uninterruptible block. See the note on
|
|
106
|
+
// synchronous deletes in the module docblock.
|
|
107
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
108
|
+
} catch (err) {
|
|
109
|
+
// The existing per-store event names, reused rather than invented: an operator greps one
|
|
110
|
+
// name and gets both the boot sweep and every tick. Fault isolation per store, so one
|
|
111
|
+
// broken reaper cannot stop the other two.
|
|
112
|
+
// SCRUBBED here as well as at each store's own catch, and that is not belt-and-braces: this line
|
|
113
|
+
// re-emits every one of those families on the TIMER, so a fix confined to the boot paths would
|
|
114
|
+
// leave every tick uncovered (issue #339).
|
|
115
|
+
log(`${entry?.name}_reaper_skipped`, { reason: scrubCredentials(err?.message) });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
})();
|
|
119
|
+
try {
|
|
120
|
+
await inFlight;
|
|
121
|
+
log("retention_sweep", { ms: Date.now() - startedAt });
|
|
122
|
+
} catch {
|
|
123
|
+
// Unreachable today: every reaper is wrapped above. Here because the interval calls this with
|
|
124
|
+
// `void`, so ANY rejection is an unhandled rejection, and Node kills the process for one by
|
|
125
|
+
// default. A sweep must never be able to take the worker down.
|
|
126
|
+
} finally {
|
|
127
|
+
inFlight = null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
sweepOnce,
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Arm the interval. DIVERGENCE from `host-registry.start`, which beats immediately: boot has just
|
|
136
|
+
* run all three reapers, so an immediate sweep would double the boot work and break the call-order
|
|
137
|
+
* pins in start-wiring. The first tick lands one full interval after boot.
|
|
138
|
+
*/
|
|
139
|
+
start() {
|
|
140
|
+
if (closed || timer) return; // a second start would leak the first interval
|
|
141
|
+
// A non-positive interval must arm NOTHING. setInterval(fn, 0) is a hot loop, and the caller
|
|
142
|
+
// already gates on the knob being > 0; this is what turns a future mis-wire into a no-op rather
|
|
143
|
+
// than a disk-melting one.
|
|
144
|
+
if (!(intervalMs > 0)) return;
|
|
145
|
+
timer = setIntervalFn(() => void sweepOnce(), intervalMs);
|
|
146
|
+
timer?.unref?.();
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* `closed` is set FIRST, for host-registry's own reason: a sweep already past the top-of-function
|
|
151
|
+
* check must not `rmSync` after the closer has reported done. Then the in-flight sweep is drained,
|
|
152
|
+
* so a shutdown never leaves a half-deleted retained workspace behind a worker that has already
|
|
153
|
+
* said it stopped cleanly.
|
|
154
|
+
*
|
|
155
|
+
* IDEMPOTENCE COMES FROM NULLING THE TIMER, not from an `if (closed) return` at the top. There was
|
|
156
|
+
* one here, and a mutation pass proved it pinned nothing: with `timer` already null the second call
|
|
157
|
+
* clears nothing, awaits a settled promise and returns. Issue #295 reached the identical conclusion
|
|
158
|
+
* about the watcher closers and removed the guard rather than documenting it, and a guard with
|
|
159
|
+
* nothing behind it is worse than none, because it invites a reader to trust it for something.
|
|
160
|
+
*/
|
|
161
|
+
async close() {
|
|
162
|
+
closed = true;
|
|
163
|
+
if (timer) clearIntervalFn(timer);
|
|
164
|
+
timer = null;
|
|
165
|
+
if (!inFlight) return;
|
|
166
|
+
await new Promise((resolve) => {
|
|
167
|
+
const t = setTimeout(() => {
|
|
168
|
+
log("retention_sweep_drain_timeout", { ms: SWEEP_DRAIN_TIMEOUT_MS });
|
|
169
|
+
resolve();
|
|
170
|
+
}, SWEEP_DRAIN_TIMEOUT_MS);
|
|
171
|
+
inFlight.catch(() => {}).then(() => {
|
|
172
|
+
clearTimeout(t);
|
|
173
|
+
resolve();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|