@edgehero/pi-dispatch 2.0.0 → 3.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 +44 -7
- package/README.md +14 -6
- package/deploy/com.pi-dispatch.worker.plist +1 -1
- package/deploy/docker-compose.yml +12 -0
- package/deploy/egress-proxy.conf +28 -3
- package/deploy/pi-dispatch-egress-proxy.container +8 -2
- package/deploy/worker-env-wrapper.cmd +1 -1
- package/deploy/worker-env-wrapper.sh +3 -3
- package/package.json +9 -2
- package/src/allocation.mjs +731 -0
- package/src/backends.mjs +243 -0
- package/src/budget.mjs +40 -4
- package/src/cli.mjs +222 -11
- package/src/config.mjs +126 -5
- package/src/daemon-facts.mjs +3 -0
- package/src/deployment-venue.mjs +1 -0
- package/src/doctor.mjs +2316 -183
- package/src/dollar-budget.mjs +373 -0
- package/src/dollar-fingerprint.mjs +83 -0
- package/src/egress-cli.mjs +316 -0
- package/src/egress-proxy-state.mjs +35 -5
- package/src/egress.mjs +16 -3
- package/src/env-allowlist.mjs +142 -18
- package/src/env-file.mjs +194 -25
- package/src/envelope.mjs +413 -0
- package/src/exit-code.mjs +22 -0
- package/src/fleet-lease.mjs +85 -25
- package/src/get-token.mjs +16 -5
- package/src/git-dirty.mjs +67 -0
- package/src/github-app-setup.mjs +6 -3
- package/src/github-host.mjs +5 -3
- package/src/host-pi.mjs +19 -3
- package/src/identity.mjs +2 -1
- package/src/image-preflight.mjs +98 -24
- package/src/image-ref.mjs +37 -0
- package/src/import-pi.mjs +4 -2
- package/src/index.mjs +407 -62
- package/src/init.mjs +18 -0
- package/src/job-id.mjs +26 -3
- package/src/live-probes.mjs +24 -9
- package/src/model-catalog.mjs +297 -0
- package/src/model-endpoints.mjs +649 -0
- package/src/model-ref.mjs +151 -0
- package/src/models-json.mjs +262 -0
- package/src/money.mjs +144 -0
- package/src/octokit-log.mjs +65 -0
- package/src/outbox-plan.mjs +218 -0
- package/src/outbox.mjs +29 -9
- package/src/output-cap.mjs +157 -0
- package/src/packages.mjs +2 -2
- package/src/pause-windows.mjs +81 -2
- package/src/pi-model-loader.mjs +77 -0
- package/src/podman-stack.mjs +16 -3
- package/src/portfolio-snapshot.mjs +304 -0
- package/src/prepare-local.mjs +247 -12
- package/src/prepare.mjs +35 -3
- package/src/pricing.mjs +9 -5
- package/src/priorities.mjs +569 -0
- package/src/processor.mjs +603 -173
- package/src/project-id.mjs +17 -0
- package/src/projects.mjs +238 -0
- package/src/provider-key.mjs +32 -7
- package/src/provider-steering.mjs +214 -59
- package/src/queue.mjs +111 -6
- package/src/reserved-env.mjs +30 -0
- package/src/run-container.mjs +59 -5
- package/src/run-history.mjs +379 -24
- package/src/run-mirror.mjs +30 -0
- package/src/runtime-settings.mjs +104 -9
- package/src/schedules.mjs +33 -1
- package/src/scoped-limits.mjs +447 -27
- package/src/secrets.mjs +2 -1
- package/src/service.mjs +15 -4
- package/src/session-store.mjs +131 -6
- package/src/start.mjs +528 -40
- package/src/subscriptions.mjs +7 -3
- package/src/triggers-file.mjs +65 -4
- package/src/triggers.mjs +140 -9
- package/src/up.mjs +308 -34
- package/src/valkey-endpoint.mjs +3 -2
package/src/env-allowlist.mjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Build the EXACT environment a job container receives. Never a pass-through.
|
|
3
3
|
*
|
|
4
|
-
* `no-broad-env-into-container` is a BLOCKER, and for good reason: `ANTHROPIC_OAUTH_TOKEN`
|
|
5
|
-
* silently outranks `ANTHROPIC_API_KEY`, so one
|
|
6
|
-
* credential every job spends, with no error and no log line.
|
|
4
|
+
* `no-broad-env-into-container` is a BLOCKER, and for good reason: `ANTHROPIC_OAUTH_TOKEN` (and, from
|
|
5
|
+
* the pi 0.99.1 pin on, `ANTHROPIC_AUTH_TOKEN` ahead of it) silently outranks `ANTHROPIC_API_KEY`, so one
|
|
6
|
+
* stray host variable would redirect which credential every job spends, with no error and no log line.
|
|
7
|
+
* So we forward a closed set.
|
|
7
8
|
*
|
|
8
9
|
* The provider key variable is DERIVED from pi's own table, not hardcoded. Deriving it means any of
|
|
9
|
-
* pi's ~
|
|
10
|
+
* pi's ~40 providers works with no code change here, and the list cannot drift when pi adds one, as a
|
|
10
11
|
* hand-copied table would. `getApiKeyEnvVars` (that table) is intentionally NOT exported by pi, which
|
|
11
12
|
* is why `providerKeyCandidates` below has to recover it a different way.
|
|
12
13
|
*
|
|
@@ -32,6 +33,8 @@ import { egressEnv } from "./egress.mjs";
|
|
|
32
33
|
import { forgeSpec } from "./forges.mjs";
|
|
33
34
|
import { apiKeyVariable } from "./provider-key.mjs";
|
|
34
35
|
import { isDeterminateFsCode } from "./transient.mjs";
|
|
36
|
+
import { KEYLESS_HOW, keylessVerdict } from "./model-endpoints.mjs";
|
|
37
|
+
import { KEYLESS_ENV_NAME } from "./reserved-env.mjs";
|
|
35
38
|
|
|
36
39
|
function configError(message) {
|
|
37
40
|
const error = new Error(message);
|
|
@@ -89,10 +92,12 @@ export function providerKeyCandidates(provider) {
|
|
|
89
92
|
* `getProviders` from "/compat", which is a @deprecated alias for this exact function and reaching it
|
|
90
93
|
* means loading compat's module-scope provider registration.
|
|
91
94
|
*
|
|
92
|
-
* Only ever consulted AFTER `providerKeyCandidates` comes back empty
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
95
|
+
* Only ever consulted AFTER `providerKeyCandidates` comes back empty. At the 0.80.7 pin that order was
|
|
96
|
+
* load-bearing: `radius` was a purely dynamic provider with a real key variable and no catalog entry, so
|
|
97
|
+
* asking this first would have called a working configuration unknown. At the 0.99.1 pin (issue #509)
|
|
98
|
+
* radius is a builtin provider (key variable RADIUS_API_KEY) and the catalog covers every id pi reads a key
|
|
99
|
+
* for; env-allowlist.test.mjs pins that, and the order stays because it costs nothing and is the safe one
|
|
100
|
+
* the day the two diverge again.
|
|
96
101
|
*/
|
|
97
102
|
export function piProviders() {
|
|
98
103
|
return getBuiltinProviders();
|
|
@@ -108,8 +113,15 @@ export function piProviders() {
|
|
|
108
113
|
* API-key credentials only; an OAuth/subscription login is refused (it expires, the container cannot refresh
|
|
109
114
|
* it, and it is not the credential for an unattended service). Throws a config-tagged error (pre-spend
|
|
110
115
|
* refusal) when neither source yields a credential.
|
|
116
|
+
*
|
|
117
|
+
* Third way in (issue #503), only for a provider pi does not know: a custom provider in the overlay `models.json`
|
|
118
|
+
* whose every model is served by a declared `keyless` model endpoint passes with no credential, and the answer is
|
|
119
|
+
* `{ PI_DISPATCH_KEYLESS: "keyless" }`, the fixed value its `"apiKey": "$PI_DISPATCH_KEYLESS"` resolves to in the job.
|
|
120
|
+
* `modelEndpoints` is the pickup's snapshot `{ endpoints, models }` (never a read of its own), so the free gate and
|
|
121
|
+
* the container env decide on the same declaration. A builtin provider whose baseUrl an operator pointed at a local
|
|
122
|
+
* server is NOT keyless (a named residual): pi knows it, so it still needs its key.
|
|
111
123
|
*/
|
|
112
|
-
export function resolveProviderCredential({ provider, hostEnv, authFromPi = false, agentDir, readFile = readFileSync, forwardEnv = [] }) {
|
|
124
|
+
export function resolveProviderCredential({ provider, hostEnv, authFromPi = false, agentDir, readFile = readFileSync, forwardEnv = [], modelEndpoints = null }) {
|
|
113
125
|
// The NAMES come from pi, the VALUES from `hostEnv`, and this asks each question of the thing that can
|
|
114
126
|
// answer it (issue #311). It used to be one `providerKeyVars(provider, hostEnv)` call, which conflates
|
|
115
127
|
// them: that is `findEnvKeys`, whose presence test falls back to the REAL `process.env` for any name the
|
|
@@ -126,9 +138,52 @@ export function resolveProviderCredential({ provider, hostEnv, authFromPi = fals
|
|
|
126
138
|
// is the DI seam that was dishonest, which is worth fixing where the seam is the whole test surface.
|
|
127
139
|
// ONE read per name, checked and returned, for the same reason: two reads could decide on one value and
|
|
128
140
|
// ship another.
|
|
129
|
-
const
|
|
141
|
+
const candidates = providerKeyCandidates(provider);
|
|
142
|
+
// Issue #503: a provider pi does not know at all. The same two questions, in the same order, as the auth.json
|
|
143
|
+
// refusal below and doctor's `noKeyVariableCheck`: no key variable, and not in pi's catalog. Such a provider can
|
|
144
|
+
// never take a key through this gate (there is no variable to write it under), so it is answered HERE, before
|
|
145
|
+
// auth.json is read: either it is keyless, or it is refused with both ways in named. That used to be decided
|
|
146
|
+
// after an auth.json read, which could only end in a refusal too, and whose wording ("set the key in .env, or run
|
|
147
|
+
// `pi login`") was advice no custom provider can follow.
|
|
148
|
+
if (candidates.length === 0 && !piProviders().includes(provider)) {
|
|
149
|
+
const ids = keylessEndpointsFor(provider, modelEndpoints);
|
|
150
|
+
// The one variable this branch writes, fixed and non-secret. It is the credential's slot in the closed map,
|
|
151
|
+
// so buildContainerEnv carries it exactly when this gate passes keyless, and on no other job.
|
|
152
|
+
if (ids !== null) return { [KEYLESS_ENV_NAME]: KEYLESS_VALUE };
|
|
153
|
+
// The overlay could not be read at this pickup for a TRANSIENT reason (index.mjs, `modelsUnreadable`): the provider
|
|
154
|
+
// may well be keyless, so this is no verdict at all. UNTAGGED and marked transient, never `piDispatchConfig`: a
|
|
155
|
+
// config refusal is permanent, refunded and commented publicly, and the same job may pass on the next attempt. The
|
|
156
|
+
// processor turns this into an infra retry (CONST-RETRY-INFRA-ONLY); absent or invalid JSON stays determinate.
|
|
157
|
+
const unreadable = modelEndpoints?.modelsUnreadable;
|
|
158
|
+
if (unreadable && Array.isArray(modelEndpoints?.endpoints) && modelEndpoints.endpoints.length > 0) {
|
|
159
|
+
const error = new Error(`the overlay models.json could not be read at this pickup (${unreadable.code}), so whether provider "${provider}" is keyless is not known yet`);
|
|
160
|
+
error.piDispatchTransient = true;
|
|
161
|
+
error.code = unreadable.code;
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
throw configError(unknownProviderMessage(provider));
|
|
165
|
+
}
|
|
166
|
+
const held = candidates
|
|
130
167
|
.map((name) => [name, hostEnv[name]])
|
|
131
168
|
.filter(([, value]) => value);
|
|
169
|
+
// EVERY held candidate is forwarded, in pi's order, including the two that are not API keys: a host
|
|
170
|
+
// ANTHROPIC_OAUTH_TOKEN and (from the 0.99.1 pin, issue #509) a host ANTHROPIC_AUTH_TOKEN, which pi reads
|
|
171
|
+
// FIRST among the variables and sends as `Authorization: Bearer`. Forwarded rather than refused, and the
|
|
172
|
+
// choice is the same one for both: the operator put the variable in the worker's environment, a gateway
|
|
173
|
+
// bearer token is a legitimate service credential, and the job env should carry what the worker's own
|
|
174
|
+
// environment says rather than a filtered version of it.
|
|
175
|
+
//
|
|
176
|
+
// That does NOT make the job agree with pi on this host in every case, and the case where it does not
|
|
177
|
+
// is stated rather than implied: the ENVIRONMENT is this function's first source and auth.json only its
|
|
178
|
+
// fallback, while pi itself takes a stored api_key credential FIRST (pi-ai/dist/auth/helpers.js, "a
|
|
179
|
+
// stored credential key wins") and the environment only without one. So with an api_key login in
|
|
180
|
+
// auth.json AND a host token set, the job spends the token while `pi` on the host would spend the stored
|
|
181
|
+
// key. Kept that way because the env-first order is this function's documented contract (its header)
|
|
182
|
+
// and the token is the operator's explicit setting; `doctor` is where it is said out loud, a warning
|
|
183
|
+
// that names the API key the token shadows and, in this case, that the auth.json key is not used while
|
|
184
|
+
// the token is set.
|
|
185
|
+
// What is NEVER done is WRITING an auth.json key under either token name: that choice is
|
|
186
|
+
// `apiKeyVariable`'s, below.
|
|
132
187
|
if (held.length > 0) {
|
|
133
188
|
return Object.fromEntries(held);
|
|
134
189
|
}
|
|
@@ -136,7 +191,26 @@ export function resolveProviderCredential({ provider, hostEnv, authFromPi = fals
|
|
|
136
191
|
const { name, value } = credentialFromPiAuth(provider, agentDir ?? defaultAgentDir(hostEnv), readFile, { hostEnv, forwardEnv });
|
|
137
192
|
return { [name]: value };
|
|
138
193
|
}
|
|
139
|
-
throw configError(`provider ${provider} has no configured credential in the worker environment
|
|
194
|
+
throw configError(`provider ${provider} has no configured credential in the worker environment. Set its key there, or, ${KEYLESS_HOW}.`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The value of `PI_DISPATCH_KEYLESS`: fixed and non-secret, since the model server takes no key. */
|
|
198
|
+
export const KEYLESS_VALUE = "keyless";
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The keyless endpoint ids serving this provider, or null (issue #503). Asked only for a provider pi does not know (the
|
|
202
|
+
* caller's predicate), against the pickup's snapshot `{ endpoints, models }` (index.mjs, one read per pickup). No
|
|
203
|
+
* snapshot, or no endpoint declared, is null: with nothing declared the overlay is never read, and nothing is keyless.
|
|
204
|
+
*/
|
|
205
|
+
export function keylessEndpointsFor(provider, modelEndpoints) {
|
|
206
|
+
const endpoints = modelEndpoints?.endpoints;
|
|
207
|
+
if (!Array.isArray(endpoints) || endpoints.length === 0) return null;
|
|
208
|
+
const verdict = keylessVerdict({ models: modelEndpoints.models, provider, endpoints });
|
|
209
|
+
return verdict.keyless ? verdict.endpoints : null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function unknownProviderMessage(provider) {
|
|
213
|
+
return `pi has no provider "${provider}", so there is no key variable to give it a key. Use one of pi's provider ids with its key in the worker environment or in pi's auth.json, or, ${KEYLESS_HOW}.`;
|
|
140
214
|
}
|
|
141
215
|
|
|
142
216
|
function defaultAgentDir(hostEnv) {
|
|
@@ -215,8 +289,8 @@ function credentialFromPiAuth(provider, agentDir, readFile, { hostEnv = {}, forw
|
|
|
215
289
|
const name = resolveEnvName(provider);
|
|
216
290
|
if (!name) {
|
|
217
291
|
// Two different facts, and they need different fixes -- the same split `doctor` makes, in the same
|
|
218
|
-
// order (candidates first, catalog second: `radius`
|
|
219
|
-
// asking membership first would
|
|
292
|
+
// order (candidates first, catalog second: at 0.80.7 `radius` had a key variable and no catalog entry,
|
|
293
|
+
// so asking membership first would have called a working configuration unknown). The old message said "set it
|
|
220
294
|
// in the worker environment manually" for BOTH, which for the first is advice `doctor` correctly
|
|
221
295
|
// calls impossible: there is no variable to set.
|
|
222
296
|
if (piProviders().includes(provider)) {
|
|
@@ -224,7 +298,9 @@ function credentialFromPiAuth(provider, agentDir, readFile, { hostEnv = {}, forw
|
|
|
224
298
|
`pi authenticates "${provider}" without an API-key environment variable (an AWS profile or an OAuth login), and the container env is a closed set of variables, so it has no way in. Configure a provider whose credential is a single environment variable.`,
|
|
225
299
|
);
|
|
226
300
|
}
|
|
227
|
-
|
|
301
|
+
// Unreachable through resolveProviderCredential, which answers an unknown provider before auth.json is read. Kept
|
|
302
|
+
// as the backstop with the same words, so a later caller cannot reopen the old advice.
|
|
303
|
+
throw configError(unknownProviderMessage(provider));
|
|
228
304
|
}
|
|
229
305
|
return { name, value: cred.key };
|
|
230
306
|
}
|
|
@@ -239,7 +315,7 @@ function credentialFromPiAuth(provider, agentDir, readFile, { hostEnv = {}, forw
|
|
|
239
315
|
* plus a conventional `<PROVIDER>_API_KEY`/`_KEY`. That convention is the part that drifts, and it was
|
|
240
316
|
* wrong for 13 of the 34 provider ids that have a key variable at the pin -- `google` is
|
|
241
317
|
* `GEMINI_API_KEY`, `huggingface` is `HF_TOKEN`, `moonshotai` is `MOONSHOT_API_KEY`, `github-copilot` is
|
|
242
|
-
* `COPILOT_GITHUB_TOKEN`, `radius`
|
|
318
|
+
* `COPILOT_GITHUB_TOKEN`, `radius` was `PI_GATEWAY_API_KEY` (RADIUS_API_KEY at 0.99.1) -- so for those pi recognized nothing and a
|
|
243
319
|
* valid `pi login` refused every job. The convention happening to be right for the other 21 is what let
|
|
244
320
|
* this survive: `anthropic` and `openai` are both in that set.
|
|
245
321
|
*
|
|
@@ -251,7 +327,9 @@ function credentialFromPiAuth(provider, agentDir, readFile, { hostEnv = {}, forw
|
|
|
251
327
|
* environment where every name is present -- which also closes a hermeticity hole the synthetic object
|
|
252
328
|
* had: pi's `getProviderEnvValue` falls back to the real `process.env`, so on a host that exported
|
|
253
329
|
* `ANTHROPIC_OAUTH_TOKEN` the old code resolved to THAT name. `apiKeyVariable` then picks the same
|
|
254
|
-
* variable `doctor` names, from the same module, so the two cannot diverge again.
|
|
330
|
+
* variable `doctor` names, from the same module, so the two cannot diverge again. It skips both
|
|
331
|
+
* non-API-key kinds: the OAuth token, and (from the 0.99.1 pin, issue #509) the bearer token
|
|
332
|
+
* ANTHROPIC_AUTH_TOKEN, which pi lists first and sends as `Authorization: Bearer`.
|
|
255
333
|
*/
|
|
256
334
|
function resolveEnvName(provider) {
|
|
257
335
|
return apiKeyVariable(providerKeyCandidates(provider));
|
|
@@ -273,12 +351,14 @@ function resolveEnvName(provider) {
|
|
|
273
351
|
* `allowGlobalExtensions` defaults to TRUE here, matching loadConfig's default (REQ-GLOBAL-PI-OVERLAY): a
|
|
274
352
|
* caller that says nothing gets the operator's staged setup, and only an explicit `false` withholds it.
|
|
275
353
|
*/
|
|
276
|
-
export function buildContainerEnv({ provider, model, maxTurns, maxTokens, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync }) {
|
|
354
|
+
export function buildContainerEnv({ provider, model, maxTurns, maxTokens, maxCostMicros = null, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], secrets = {}, sessionFile = null, flow = null, command = null, excludeTools = [], allowedModels = null, authFromPi = false, egress = false, egressProxy, agentDir, home = null, readFile = readFileSync, modelEndpoints = null, exitAuth = false }) {
|
|
277
355
|
// The provider credential(s), by pi's expected variable name(s) -- from the worker env, or (when
|
|
278
356
|
// PI_AUTH_FROM_PI is set and the env has none) host-side from pi's auth.json. Throws (config) if
|
|
279
357
|
// neither source yields one, which the processor turns into a policy refusal that refunds any reserve
|
|
280
358
|
// (issue #310); the same call is made by its free credential gate, before anything is reserved at all.
|
|
281
|
-
|
|
359
|
+
// `modelEndpoints` is the pickup's snapshot (issue #503): the keyless branch answers `PI_DISPATCH_KEYLESS` in this
|
|
360
|
+
// map, so the variable is set exactly when the gate passed keyless and is absent on every other job.
|
|
361
|
+
const credEnv = resolveProviderCredential({ provider, hostEnv, authFromPi, agentDir, readFile, forwardEnv, modelEndpoints });
|
|
282
362
|
|
|
283
363
|
const env = {
|
|
284
364
|
PI_PROVIDER: provider,
|
|
@@ -333,6 +413,13 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, jobId,
|
|
|
333
413
|
// second-validator rule directly above). Absent means the full pinned default set, never an empty
|
|
334
414
|
// string, for PI_PACKAGES' reason.
|
|
335
415
|
PI_EXCLUDE_TOOLS: excludeTools.length > 0 ? excludeTools.join(",") : undefined,
|
|
416
|
+
// The job's EFFECTIVE allowed-model list (issue #502): its trigger's `run.models`, else the deployment's
|
|
417
|
+
// PI_ALLOWED_MODELS. Comma-joined `provider/model` entries, which the runner splits at each entry's first `/`
|
|
418
|
+
// (image/runner/src/config.mjs). Comma is safe because both list parsers refuse an entry carrying one. NULL is
|
|
419
|
+
// unrestricted and emits NO variable, never an empty string: the runner refuses an empty value as a config
|
|
420
|
+
// error, because an empty allow list read as "unset" would fail open. An image too old to enforce a list is
|
|
421
|
+
// refused before this is ever built (`modelPolicy`, CAPABILITY_GATES).
|
|
422
|
+
PI_ALLOWED_MODELS: Array.isArray(allowedModels) && allowedModels.length > 0 ? allowedModels.join(",") : undefined,
|
|
336
423
|
// Kill switch for job-time package installation, UNCONDITIONAL for every job. pi's resolver shells out
|
|
337
424
|
// to a REAL `npm install` for any npm:/git: source unless offline mode is on, and `~/.pi/agent` IS
|
|
338
425
|
// writable in the container. We emit only local paths, so nothing should reach that branch -- this
|
|
@@ -408,6 +495,43 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, jobId,
|
|
|
408
495
|
// env-internal HOME: written into the job's closed env map here, never read from the worker's environment.
|
|
409
496
|
if (typeof home === "string" && home !== "") env.HOME = home;
|
|
410
497
|
|
|
498
|
+
// Issue #503: PI_DISPATCH_KEYLESS is the credential gate's answer and nothing else's, so it is settled AFTER the
|
|
499
|
+
// PI_FORWARD_ENV and secrets loops, as HOME and the egress variables are: both lists refuse the name upstream, and
|
|
500
|
+
// this is the backstop that keeps a value from either one from replacing it, or from appearing on a keyed job.
|
|
501
|
+
if (credEnv[KEYLESS_ENV_NAME] === KEYLESS_VALUE) env[KEYLESS_ENV_NAME] = KEYLESS_VALUE;
|
|
502
|
+
else delete env[KEYLESS_ENV_NAME];
|
|
503
|
+
|
|
504
|
+
// Issue #501: the per-job dollar cap is the worker's answer and nothing else's, so it is settled again AFTER the
|
|
505
|
+
// PI_FORWARD_ENV and secrets loops, PI_DISPATCH_KEYLESS's backstop. Both lists refuse the name upstream (config.mjs
|
|
506
|
+
// refuses every CONTAINER_ENV_NAMES member in PI_FORWARD_ENV at boot, #502; the loader refuses it in run.secrets);
|
|
507
|
+
// this is the line that holds if either refusal is ever bypassed. A forwarded or secret PI_MAX_COST_MICROS would
|
|
508
|
+
// otherwise replace the computed cap (measured: a host value of 1e12 beat a computed 500000), and on a job with
|
|
509
|
+
// no cap it would send one past the `costCap` gate, which only runs for capped jobs, to an image that may ignore it.
|
|
510
|
+
// It is set ONLY here, once, after both loops. `!== null`, never truthiness: 0 is a real cap (no priced call at
|
|
511
|
+
// all, which a fail-closed job value reads as, `effectiveCostCapMicros`), and a truthy test would send NO cap for
|
|
512
|
+
// it, the widest one there is. Absent (null or undefined) leaves no variable, so a job with no cap carries the env
|
|
513
|
+
// it always did, and never an empty string (the runner refuses one).
|
|
514
|
+
// env-internal PI_MAX_COST_MICROS: written into the job's closed env map here, never read from the worker's environment.
|
|
515
|
+
if (maxCostMicros === null || maxCostMicros === undefined) delete env.PI_MAX_COST_MICROS;
|
|
516
|
+
else env.PI_MAX_COST_MICROS = String(maxCostMicros);
|
|
517
|
+
|
|
518
|
+
// Issue #545: the exit line's key waits on the container's stdin, and this variable tells the runner to read it.
|
|
519
|
+
// The value names the channel and is never the key: the environment is readable from /proc/1/environ by every
|
|
520
|
+
// process in the container (measured), which is why the key does not travel here. Settled after both loops like
|
|
521
|
+
// the cap above, and ONLY for a run the worker actually hands a key, so a runner never blocks on a stdin no one
|
|
522
|
+
// writes. `=== true`, so only run-container's own boolean asks for it.
|
|
523
|
+
// env-internal PI_EXIT_AUTH: written into the job's closed env map here, never read from the worker's environment.
|
|
524
|
+
if (exitAuth === true) env.PI_EXIT_AUTH = "stdin";
|
|
525
|
+
else delete env.PI_EXIT_AUTH;
|
|
526
|
+
|
|
527
|
+
// Issue #500: the runner sets these two in its own environment, for its child processes, and no value from outside
|
|
528
|
+
// may arrive first. Both lists refuse them upstream (config.mjs for PI_FORWARD_ENV, the triggers loader for
|
|
529
|
+
// run.secrets, through RUNNER_ENV_NAMES); this is the line that holds if either refusal is ever bypassed.
|
|
530
|
+
// env-internal PI_DISPATCH_CHILD_LEDGER: set by the runner inside the container, never by the worker, so removed here.
|
|
531
|
+
delete env.PI_DISPATCH_CHILD_LEDGER;
|
|
532
|
+
// env-internal PI_DISPATCH_RUNNER_PID: set by the runner inside the container, never by the worker, so removed here.
|
|
533
|
+
delete env.PI_DISPATCH_RUNNER_PID;
|
|
534
|
+
|
|
411
535
|
// Forge-backed jobs, and local cron jobs that opted in via run.github. Other local-folder jobs have
|
|
412
536
|
// no token (CONST-TOKEN-SCOPED-PER-JOB). The mint goes into BOTH of its forge's variables because
|
|
413
537
|
// each CLI has its own preference -- gh prefers GH_TOKEN over GITHUB_TOKEN, glab prefers GITLAB_TOKEN
|
package/src/env-file.mjs
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* Deliberately dependency-free (node:fs only, and only in the thin wrapper): it must stay importable
|
|
18
18
|
* from any future setup command without dragging worker config or queue deps along.
|
|
19
19
|
*/
|
|
20
|
-
import { chmodSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { chmodSync, chownSync, closeSync, constants as fsConstants, fchmodSync, fchownSync, fstatSync, fsyncSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* Pure transform over .env TEXT: set `key` to `value` only where nothing is set yet.
|
|
@@ -104,6 +104,15 @@ function replacementLines(key, value, bare, wasComment, opts) {
|
|
|
104
104
|
* character, a `"`, a `%`, a `!`, a `^`, a character outside ASCII, and an `=` at the start of the value. Each
|
|
105
105
|
* row there says whether it is cmd's documented behaviour or a cautious refusal. `'` is ordinary there.
|
|
106
106
|
*/
|
|
107
|
+
/**
|
|
108
|
+
* The `code` on every error `renderEnvValue` throws (issue #522): the VALUE cannot be written, so a caller may add advice
|
|
109
|
+
* about the value (`up` says where its path came from). Every other refusal of the writer is about the FILE and carries
|
|
110
|
+
* its own fix, and `up` once gave those this one's advice, telling a gid mismatch to move the deployment somewhere
|
|
111
|
+
* "without that character in its path".
|
|
112
|
+
*/
|
|
113
|
+
export const ENV_VALUE_UNWRITABLE = "ENV_VALUE_UNWRITABLE";
|
|
114
|
+
const unwritableValue = (message) => Object.assign(new Error(message), { code: ENV_VALUE_UNWRITABLE });
|
|
115
|
+
|
|
107
116
|
export function renderEnvValue(value, { platform = process.platform } = {}) {
|
|
108
117
|
const v = String(value);
|
|
109
118
|
// The Windows loader keeps surrounding quotes as part of the value ("Values MUST be UNQUOTED", its own
|
|
@@ -111,16 +120,16 @@ export function renderEnvValue(value, { platform = process.platform } = {}) {
|
|
|
111
120
|
// refused rather than dressed in quotes that become part of a path.
|
|
112
121
|
if (platform === "win32") {
|
|
113
122
|
const bad = cmdValueRefusal(v);
|
|
114
|
-
if (bad !== null) throw
|
|
123
|
+
if (bad !== null) throw unwritableValue(`cannot write this value into a .env on Windows: it contains ${bad}, and the .cmd wrapper (deploy/worker-env-wrapper.cmd) cannot be shown to read that back as written. Choose a value without it, or give the service this key through its own environment (pi-dispatch service install --env-setup)`);
|
|
115
124
|
return v;
|
|
116
125
|
}
|
|
117
126
|
if (UNQUOTED_PLAIN.test(v)) return v;
|
|
118
|
-
if (v.includes("'") || /[\n\r]/.test(v)) throw
|
|
127
|
+
if (v.includes("'") || /[\n\r]/.test(v)) throw unwritableValue(`cannot write this value into a .env safely: ${v.includes("'") ? "it contains a single quote" : "it contains a newline"}`);
|
|
119
128
|
// Gate round 2 of PR #478: a control or invisible character is read alike by every loader inside single quotes, but
|
|
120
129
|
// the reader never vouches for it (`QUOTED_CONTROL`: printing it rewrites a terminal), so writing it quoted gave doctor
|
|
121
130
|
// a line it calls unread. Refused instead, naming the character, never the value.
|
|
122
131
|
const hidden = invisibleCharacter(v);
|
|
123
|
-
if (hidden !== null) throw
|
|
132
|
+
if (hidden !== null) throw unwritableValue(`cannot write this value into a .env safely: it contains ${hidden}, which doctor would not show back. Remove it`);
|
|
124
133
|
return `'${v}'`;
|
|
125
134
|
}
|
|
126
135
|
|
|
@@ -869,6 +878,104 @@ export function envFileEditCheck(content, path, key, value, opts = {}) {
|
|
|
869
878
|
return planEnvEdit(content, path, key, value, opts).error ?? null;
|
|
870
879
|
}
|
|
871
880
|
|
|
881
|
+
/**
|
|
882
|
+
* The group-mismatch refusal (issue #522), its own sentence with its own fix. The group is named where `/etc/group`
|
|
883
|
+
* names it, because "gid 0" sends an operator to look it up and `wheel` does not. It is reached only where the writer
|
|
884
|
+
* could not keep the group (`updateEnvFile` gives the new file the old one wherever this account is in it), and only
|
|
885
|
+
* for a file this account OWNS (the owner refusal comes first), so "run it as another account" is no fix: any other
|
|
886
|
+
* account, a member of that group or root, gets the owner refusal instead. The two fixes are this account joining that
|
|
887
|
+
* group (a new login picks it up), or a group this account is in: `own`, this process's primary group, which an
|
|
888
|
+
* owner can always give its file, and which every later rewrite then keeps. The second is the operator's decision where
|
|
889
|
+
* a service reads the file through its present group, so the sentence says so rather than issuing the chgrp.
|
|
890
|
+
*/
|
|
891
|
+
export function groupRefusal(target, group, made, own, groupName = groupNameOf) {
|
|
892
|
+
const named = (id) => {
|
|
893
|
+
const name = groupName(id);
|
|
894
|
+
return name ? `${name} (gid ${id})` : `gid ${id}`;
|
|
895
|
+
};
|
|
896
|
+
const fix = own === null ? "" : `; or, if nothing reads this file through its present group, run \`chgrp ${groupName(own) ?? own} ${quotedForShell(target)}\` and run this again`;
|
|
897
|
+
return `refusing to edit ${target}: its group is ${named(group)}, which this account could not give the new file, and a new file written beside it gets ${named(made)}, so rewriting it (a new file renamed over it) would change its group, which is how a .env the service reads through its group stops being readable. To fix it, add this account to ${groupName(group) ?? `gid ${group}`} and log in again${fix}; otherwise edit the file by hand. Nothing was written`;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** A group's name from `/etc/group`, or `null` (no such line, no such file, or not a POSIX host). */
|
|
901
|
+
export function groupNameOf(id, read = () => readFileSync("/etc/group", "utf8")) {
|
|
902
|
+
try {
|
|
903
|
+
for (const line of String(read()).split("\n")) {
|
|
904
|
+
if (line.startsWith("#")) continue;
|
|
905
|
+
const [name, , gid] = line.split(":");
|
|
906
|
+
// A digits-only field, so an empty one (which `Number` reads as 0) never names gid 0.
|
|
907
|
+
if (name && /^\d+$/.test(gid ?? "") && Number(gid) === id) return name;
|
|
908
|
+
}
|
|
909
|
+
} catch {
|
|
910
|
+
// No group database to read: the number alone is said.
|
|
911
|
+
}
|
|
912
|
+
return null;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** `p` as one shell word: bare when it is plainly safe, single-quoted otherwise. */
|
|
916
|
+
function quotedForShell(p) {
|
|
917
|
+
return /^[A-Za-z0-9_./@%+=:,-]+$/.test(p) ? p : `'${String(p).replace(/'/g, "'\\''")}'`;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Every call `updateEnvFile` makes, for a production caller's fs seam to spread (issue #522's review). The descriptor
|
|
922
|
+
* calls are optional on the seam, because a test fake has none, so a seam that drops one does not fail: the writer
|
|
923
|
+
* quietly goes back to chowning and chmodding the tmp BY PATH, which a swapped tmp redirects. One list, spread by
|
|
924
|
+
* `up`, `service install` and `setup github`, and pinned by a test that reads all four objects.
|
|
925
|
+
*/
|
|
926
|
+
export const ENV_WRITER_FS = Object.freeze({ readFileSync, writeFileSync, renameSync, statSync, chmodSync, chownSync, realpathSync, unlinkSync, openSync, fstatSync, fchownSync, fchmodSync, fsyncSync, closeSync });
|
|
927
|
+
|
|
928
|
+
/** `open(2)` flags for the tmp: created here or refused, never through a link (`O_NOFOLLOW` is POSIX-only; 0 elsewhere). */
|
|
929
|
+
const EXCLUSIVE_CREATE = fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0);
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* The tmp, created exclusively at 0600 with `content`, and the four things the writer does to it. Through a DESCRIPTOR
|
|
933
|
+
* where the seam has one (every production seam), so a path swapped after the create is never what is chowned or
|
|
934
|
+
* chmodded; a seam without `openSync` (a test fake) gets the same calls by path, still with the exclusive create.
|
|
935
|
+
*/
|
|
936
|
+
function createTmp(fs, tmp, content) {
|
|
937
|
+
if (typeof fs.openSync === "function") {
|
|
938
|
+
const fd = fs.openSync(tmp, EXCLUSIVE_CREATE, 0o600);
|
|
939
|
+
try {
|
|
940
|
+
fs.writeFileSync(fd, content);
|
|
941
|
+
} catch (err) {
|
|
942
|
+
fs.closeSync(fd);
|
|
943
|
+
try {
|
|
944
|
+
fs.unlinkSync?.(tmp);
|
|
945
|
+
} catch {
|
|
946
|
+
// The write's error is the one to report.
|
|
947
|
+
}
|
|
948
|
+
throw err;
|
|
949
|
+
}
|
|
950
|
+
let open = true;
|
|
951
|
+
const shut = () => {
|
|
952
|
+
if (open) fs.closeSync(fd);
|
|
953
|
+
open = false;
|
|
954
|
+
};
|
|
955
|
+
return {
|
|
956
|
+
gid: () => fs.fstatSync(fd).gid,
|
|
957
|
+
chgrp: (g) => fs.fchownSync(fd, -1, g),
|
|
958
|
+
chmod: (m) => fs.fchmodSync(fd, m),
|
|
959
|
+
close: () => {
|
|
960
|
+
try {
|
|
961
|
+
fs.fsyncSync?.(fd);
|
|
962
|
+
} finally {
|
|
963
|
+
shut();
|
|
964
|
+
}
|
|
965
|
+
},
|
|
966
|
+
abandon: shut,
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
fs.writeFileSync(tmp, content, { mode: 0o600, flag: "wx" });
|
|
970
|
+
return {
|
|
971
|
+
gid: () => fs.statSync(tmp).gid,
|
|
972
|
+
chgrp: (g) => fs.chownSync(tmp, -1, g),
|
|
973
|
+
chmod: (m) => fs.chmodSync(tmp, m),
|
|
974
|
+
close: () => {},
|
|
975
|
+
abandon: () => {},
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
|
|
872
979
|
/**
|
|
873
980
|
* Read → transform → write back ATOMICALLY (tmp + rename, the same shape as the admin's
|
|
874
981
|
* writeTriggers), so a watcher or a concurrent reader never sees a half-written .env. When the
|
|
@@ -895,7 +1002,7 @@ export function updateEnvFile(path, key, value, deps = {}) {
|
|
|
895
1002
|
// and the cmd wrapper kept the quotes, making the path the worker loads wrong behind a ✓ on the key
|
|
896
1003
|
// that decides forge auth. A rendering rule that every writer of this file must remember is a rule one
|
|
897
1004
|
// of them will forget.
|
|
898
|
-
const { fs =
|
|
1005
|
+
const { fs = ENV_WRITER_FS, overwrite = false, platform = process.platform, narrow = false } = deps;
|
|
899
1006
|
// Every refusal is `planEnvEdit`'s (bytes first, never clobber, read back), made before anything is written.
|
|
900
1007
|
const plan = planEnvEdit(fs.readFileSync(path), path, key, value, { overwrite, platform, verify: deps.verify });
|
|
901
1008
|
if (plan.error) throw new Error(plan.error);
|
|
@@ -911,42 +1018,104 @@ export function updateEnvFile(path, key, value, deps = {}) {
|
|
|
911
1018
|
} catch {
|
|
912
1019
|
// Not resolvable (a dangling link, a fs without the call): edit the path we were given.
|
|
913
1020
|
}
|
|
914
|
-
// OWNERSHIP, before
|
|
1021
|
+
// OWNERSHIP, before the file is replaced. `renameSync` makes a new inode owned by whoever runs this, so
|
|
915
1022
|
// a root- or `pi`-owned `.env` at 0640 that the service reads through its group comes back owned by the
|
|
916
1023
|
// operator: the service account loses read access, and `deploy/worker.service` uses a bare
|
|
917
1024
|
// `EnvironmentFile=` (fatal, not `-`), so the unit stops starting. Widening the mode to compensate
|
|
918
1025
|
// would publish a file holding WEBHOOK_SECRET. Refusing is the only honest third option, and the caller
|
|
919
|
-
// turns it into a line rather than a stack trace.
|
|
1026
|
+
// turns it into a line rather than a stack trace. Each refusal carries its own fix (issue #522).
|
|
920
1027
|
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
921
1028
|
const gid = typeof process.getgid === "function" ? process.getgid() : null;
|
|
1029
|
+
let group = null;
|
|
922
1030
|
if (uid !== null) {
|
|
923
1031
|
try {
|
|
924
|
-
const { uid: owner, gid:
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
// rename still makes a new inode with the writer's primary gid, and the `pi` service loses read
|
|
928
|
-
// access exactly as it would have on a uid mismatch.
|
|
929
|
-
if (typeof owner === "number" && owner !== uid) throw new Error(`refusing to edit ${target}: it is owned by uid ${owner} and this process is uid ${uid}, and rewriting it would hand it to the wrong account`);
|
|
930
|
-
if (typeof group === "number" && gid !== null && group !== gid) throw new Error(`refusing to edit ${target}: its group is gid ${group} and this process is gid ${gid}, and rewriting it would hand it to the wrong group, which is how a 0640 .env stops being readable by the service`);
|
|
1032
|
+
const { uid: owner, gid: had } = fs.statSync(target);
|
|
1033
|
+
if (typeof owner === "number" && owner !== uid) throw new Error(`refusing to edit ${target}: it is owned by uid ${owner} and this process is uid ${uid}, and rewriting it (a new file renamed over it) would give it to this account, which is how a .env the service reads stops being readable. To fix it, run this as the account that owns it, or edit the file by hand. Nothing was written`);
|
|
1034
|
+
if (typeof had === "number") group = had;
|
|
931
1035
|
} catch (err) {
|
|
932
1036
|
if (err instanceof Error && err.message.startsWith("refusing to edit")) throw err;
|
|
933
1037
|
// Cannot stat: fall through to the write, which will fail on its own terms if it must.
|
|
934
1038
|
}
|
|
935
1039
|
}
|
|
936
1040
|
const tmp = `${target}.tmp`;
|
|
937
|
-
|
|
1041
|
+
// CREATED HERE, exclusively, or not at all (issue #522's review). The folder may be one another account can write (a
|
|
1042
|
+
// setgid 2770 folder shared with the service's group), and a `.env.tmp` planted there as a symlink was followed by
|
|
1043
|
+
// the write, by the chown that keeps the group, and by the chmod: content at the link's target, and that file's
|
|
1044
|
+
// group and mode changed. `O_EXCL` refuses any existing path, a symlink included (POSIX: not followed), and
|
|
1045
|
+
// `O_NOFOLLOW` says so twice where the platform has it; everything after the create goes through the descriptor, so
|
|
1046
|
+
// a tmp swapped after the create is not what is chowned or chmodded. An existing tmp is refused and left alone: it is
|
|
1047
|
+
// not this writer's to remove.
|
|
1048
|
+
let handle;
|
|
938
1049
|
try {
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1050
|
+
handle = createTmp(fs, tmp, next);
|
|
1051
|
+
} catch (err) {
|
|
1052
|
+
if (err?.code === "EEXIST" || err?.code === "ELOOP") throw new Error(`refusing to edit ${target}: ${tmp} already exists (left by an edit that was interrupted, or put there by another account that can write this folder), and this writer only writes through a file it has just created. Remove it, then run this again. Nothing was written`);
|
|
1053
|
+
throw err;
|
|
1054
|
+
}
|
|
1055
|
+
// GROUP, as well as owner, because the layout this protects is a `.env` at 0640 read by the service THROUGH ITS GROUP:
|
|
1056
|
+
// with `bob:pi 0640` and the operator in group `pi`, the uid matches and the rename can still change the group.
|
|
1057
|
+
// MEASURED on the tmp, not predicted from this process's gid (issue #522). Which group a new file gets is the
|
|
1058
|
+
// folder's business: on macOS and the BSDs it is always the folder's group, and on Linux it is the folder's group
|
|
1059
|
+
// where the folder is setgid (or mounted `grpid`), else this process's. The prediction refused every edit to a
|
|
1060
|
+
// `.env` that `init` had just made in a folder of group `wheel` (gid 0, as `/private/tmp` is): the file took the
|
|
1061
|
+
// folder's group, this process's was `staff`, and the rewrite would have kept `wheel` all along. The tmp holds the
|
|
1062
|
+
// new content at 0600 and is removed before the refusal, so nothing the operator reads has changed.
|
|
1063
|
+
// EVERYTHING after the create removes the tmp on any failure (issue #522's review, round 2): a flush or a rename that
|
|
1064
|
+
// throws (EPERM from a Windows scanner holding the file, a full disk at fsync) left this writer's OWN tmp behind, and
|
|
1065
|
+
// every later edit then refused it as one that "already exists". Removing it here is safe where removing one found at
|
|
1066
|
+
// the create is not: this one was created by this call, exclusively. The original error is rethrown; where the tmp
|
|
1067
|
+
// cannot be removed, its message says where the new content was left instead of claiming nothing was written.
|
|
1068
|
+
try {
|
|
1069
|
+
if (group !== null) {
|
|
1070
|
+
let made = null;
|
|
1071
|
+
try {
|
|
1072
|
+
made = handle.gid();
|
|
1073
|
+
} catch {
|
|
1074
|
+
// Cannot stat what was just written: predict as a folder without setgid would make it.
|
|
1075
|
+
}
|
|
1076
|
+
if (typeof made !== "number") made = gid;
|
|
1077
|
+
// KEPT where this account may keep it: an owner may give a file any group it is in, so the tmp takes the file's
|
|
1078
|
+
// group and the rewrite changes nothing (`bob:pi` with the operator in `pi`, or a `staff` file in a `wheel`
|
|
1079
|
+
// folder). Refused only where that fails, which is a group this account is not in.
|
|
1080
|
+
if (made !== null && made !== group) {
|
|
1081
|
+
try {
|
|
1082
|
+
handle.chgrp(group);
|
|
1083
|
+
made = handle.gid();
|
|
1084
|
+
} catch {
|
|
1085
|
+
// EPERM, a group this account is not in: the refusal below says so.
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
if (made !== null && made !== group) {
|
|
1089
|
+
throw new Error(groupRefusal(target, group, made, gid, deps.groupName ?? groupNameOf));
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
try {
|
|
1093
|
+
// The operator's mode, whatever it is, not just 0600. `.env` holds WEBHOOK_SECRET and provider
|
|
1094
|
+
// keys, and a rename from a fresh tmp lands at the process umask: 0640 and 0400 both came back
|
|
1095
|
+
// 0644, world-readable, on the one file this project says must never reach a scrollback.
|
|
1096
|
+
// `narrow` keeps only the owner's bits of it.
|
|
1097
|
+
const mode = fs.statSync(target).mode & 0o7777;
|
|
1098
|
+
handle.chmod(narrow ? mode & 0o7700 : mode);
|
|
1099
|
+
} catch {
|
|
1100
|
+
// The file vanished between read and write, or the fs cannot stat: the tmp keeps the 0600 it was
|
|
1101
|
+
// created with rather than failing an edit that is otherwise sound.
|
|
1102
|
+
}
|
|
1103
|
+
handle.close();
|
|
1104
|
+
fs.renameSync(tmp, target);
|
|
1105
|
+
} catch (err) {
|
|
1106
|
+
handle.abandon();
|
|
1107
|
+
let removed = typeof fs.unlinkSync === "function";
|
|
1108
|
+
try {
|
|
1109
|
+
if (removed) fs.unlinkSync(tmp);
|
|
1110
|
+
} catch {
|
|
1111
|
+
removed = false;
|
|
1112
|
+
}
|
|
1113
|
+
if (!removed && err instanceof Error) {
|
|
1114
|
+
const left = `${target} itself was not changed, but the new content was left in ${tmp} (mode 0600), which could not be removed: remove it`;
|
|
1115
|
+
err.message = err.message.endsWith(". Nothing was written") ? `${err.message.slice(0, -"Nothing was written".length)}${left}` : `${err.message}. ${left}`;
|
|
1116
|
+
}
|
|
1117
|
+
throw err;
|
|
948
1118
|
}
|
|
949
|
-
fs.renameSync(tmp, target);
|
|
950
1119
|
return { changed: true, ...(narrow ? { narrowed: true } : {}) };
|
|
951
1120
|
}
|
|
952
1121
|
|