@edgehero/pi-dispatch 5.0.0 → 5.2.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/README.md +1 -0
- package/package.json +4 -3
- package/src/cancel-cli.mjs +102 -14
- package/src/cancel-state.mjs +9 -3
- package/src/cli.mjs +8 -0
- package/src/connection.mjs +7 -0
- package/src/console-audit.mjs +1 -1
- package/src/console-bootstrap.mjs +211 -45
- package/src/console-devices.mjs +372 -0
- package/src/console-install.mjs +239 -0
- package/src/console-open.mjs +99 -0
- package/src/doctor-bundles.mjs +6 -2
- package/src/doctor-model-proposals.mjs +4 -2
- package/src/doctor.mjs +223 -30
- package/src/envelope.mjs +5 -3
- package/src/mcp-config.mjs +3 -1
- package/src/models-json.mjs +8 -1
- package/src/pause-windows.mjs +19 -3
- package/src/setup-host-entry.mjs +4 -1
- package/src/up.mjs +6 -4
package/src/doctor.mjs
CHANGED
|
@@ -134,6 +134,7 @@ import { DOLLAR_ENV_NAMES, DOLLAR_SETTING_KEYS, checkDollarInvariant, effectiveC
|
|
|
134
134
|
import { cronPlacement, envelopeJobPaths } from "./schedules.mjs";
|
|
135
135
|
import { envelopeDigest, envelopeInsideJobPaths, loadEnvelopeChecked } from "./envelope.mjs";
|
|
136
136
|
import { keptTokens, operatorCount, readTokensFile, tokensFilePath } from "./console-tokens.mjs";
|
|
137
|
+
import { consoleDevicesDir, devicesState } from "./console-devices.mjs";
|
|
137
138
|
import { RUN_MIRROR_MAX_RUNS_DEFAULT, RUN_MIRROR_MAX_RUNS_MAX, RUN_MIRROR_MAX_RUNS_MIN, runMirrorMaxRunsOf } from "./retention.mjs";
|
|
138
139
|
import { verifyConsoleAudit } from "./console-audit.mjs";
|
|
139
140
|
import { OTHER } from "./priorities.mjs";
|
|
@@ -389,7 +390,8 @@ export async function runDoctor(shellVars = process.env, deps = {}) {
|
|
|
389
390
|
// Measured: a CONTAINERS_CONF in it naming a containers.conf with a `conmon_path` made doctor's own `podman info` run
|
|
390
391
|
// that program. So from such a file no value is taken at all (the venue keys included), and that is a ✗: the service's
|
|
391
392
|
// loader has no such rule, so the deployment runs on a file someone else chose. The read above still happens, for the
|
|
392
|
-
// two-subject
|
|
393
|
+
// BOOT_FILES lines' two-subject reading (`envFileKeys`), which only decides what doctor SAYS; from such a file
|
|
394
|
+
// `bootFileEnv` takes nothing (issue #859, `envUntrusted`), so the other checks judge this shell's values as the ✗ says.
|
|
393
395
|
const envUntrusted = envText !== null ? envRead.untrusted : null;
|
|
394
396
|
const envValues = envUntrusted ? null : envText;
|
|
395
397
|
let envTextUnused = envText !== null;
|
|
@@ -453,7 +455,7 @@ export async function runDoctor(shellVars = process.env, deps = {}) {
|
|
|
453
455
|
return { ...(await valkeyAuthState(url, { context, withoutPassword })), passwordSet: Boolean(sent.password), from: sent.from };
|
|
454
456
|
}
|
|
455
457
|
: null;
|
|
456
|
-
const seams = { setupReadOnly: setupReadOnly === true, ...(subscriptionFactory ? { subscriptionFactory } : {}), ...(modelProposals ? { modelProposals } : {}), secrets: secrets === true, ...(bundleSpawn ? { bundleSpawn } : {}), openshell: openshell === true, ...(openshellRun ? { openshellRun } : {}), cwd, out, spawn, probeValkey, valkeyAuth: valkeyAuthSeam, readHosts, ...(modelCatalog ? { modelCatalog } : {}), ...(piModelLoader ? { piModelLoader } : {}), ...(dollarKeysExist ? { dollarKeysExist } : {}), ...(readAppliedSplit ? { readAppliedSplit } : {}), ...(readCapacity ? { readCapacity } : {}), ...(readDollarRecords ? { readDollarRecords } : {}), ...(readDollarCounters ? { readDollarCounters } : {}), ...(olderStateFindings ? { olderStateFindings } : {}), ...(consoleProbe ? { consoleProbe } : {}), fileExists, nodeVersion, mkdir, chmod, rm, agentDir, platform, home, providerOracle, facts, jobUserIdentity, stat, passwd, readUnit, readEnvFile: readEnvFileShared, observationFs, flowFs, jobsDirFs, jobsDirUid, valkeyOwner: valkeyOwnerSeam, isAlive, pid, runTimeouts, live: live === true, wallClock, ...(readRunRecords ? { readRunRecords } : {}), venueChecks, userName, proxyFilesExist, proxyFileIsDirectory, ...(includeNeeds ? { includeNeeds } : {}), ...(declaredEndpoints ? { declaredEndpoints } : {}), ...(readOverlayFile ? { readOverlayFile } : {}), ...(lstatOverlayFile ? { lstatOverlayFile } : {}), ...(hostAddresses ? { hostAddresses } : {}), ...(readProxyConf ? { readProxyConf } : {}), ...(readPackagedConf ? { readPackagedProxyConf: readPackagedConf } : {}), ...(readPodmanService ? { readPodmanService } : {}), serviceEnvFile: envValues === null ? null : serviceEnvFileOf(envValues, envPath, serviceEnvLoader(platform)) };
|
|
458
|
+
const seams = { setupReadOnly: setupReadOnly === true, ...(subscriptionFactory ? { subscriptionFactory } : {}), ...(modelProposals ? { modelProposals } : {}), secrets: secrets === true, ...(bundleSpawn ? { bundleSpawn } : {}), openshell: openshell === true, ...(openshellRun ? { openshellRun } : {}), cwd, out, spawn, probeValkey, valkeyAuth: valkeyAuthSeam, readHosts, ...(modelCatalog ? { modelCatalog } : {}), ...(piModelLoader ? { piModelLoader } : {}), ...(dollarKeysExist ? { dollarKeysExist } : {}), ...(readAppliedSplit ? { readAppliedSplit } : {}), ...(readCapacity ? { readCapacity } : {}), ...(readDollarRecords ? { readDollarRecords } : {}), ...(readDollarCounters ? { readDollarCounters } : {}), ...(olderStateFindings ? { olderStateFindings } : {}), ...(consoleProbe ? { consoleProbe } : {}), fileExists, nodeVersion, mkdir, chmod, rm, agentDir, platform, home, providerOracle, facts, jobUserIdentity, stat, passwd, readUnit, readEnvFile: readEnvFileShared, observationFs, flowFs, jobsDirFs, jobsDirUid, valkeyOwner: valkeyOwnerSeam, isAlive, pid, runTimeouts, live: live === true, wallClock, ...(readRunRecords ? { readRunRecords } : {}), venueChecks, userName, proxyFilesExist, proxyFileIsDirectory, ...(includeNeeds ? { includeNeeds } : {}), ...(declaredEndpoints ? { declaredEndpoints } : {}), ...(readOverlayFile ? { readOverlayFile } : {}), ...(lstatOverlayFile ? { lstatOverlayFile } : {}), ...(hostAddresses ? { hostAddresses } : {}), ...(readProxyConf ? { readProxyConf } : {}), ...(readPackagedConf ? { readPackagedProxyConf: readPackagedConf } : {}), ...(readPodmanService ? { readPodmanService } : {}), ...(envUntrusted ? { envUntrusted: true } : {}), serviceEnvFile: envValues === null ? null : serviceEnvFileOf(envValues, envPath, serviceEnvLoader(platform)) };
|
|
457
459
|
// Issue #471: every other service key, resolved ONCE for the whole run (the fix pass's re-collect and `--live` judge the
|
|
458
460
|
// same resolution). THE RULE (PR #474's round cap, after three rounds of trust patches): no program doctor starts is
|
|
459
461
|
// handed anything from `.env`. Every child gets this shell's own environment, the one it had before #471; a `.env`
|
|
@@ -757,11 +759,13 @@ function asText(content, enc) {
|
|
|
757
759
|
* The values `<cwd>/.env` sets for NAMED keys, or `{}`. Doctor's first read of a `.env` (issue #357), and the
|
|
758
760
|
* narrowing is what makes it an addition rather than a reversal. It is NOT the only reader: `up` and `service
|
|
759
761
|
* install` read the venue keys since #430 (`readStackKeys`), and since #453 doctor reads `SERVICE_ENV_KEYS` below,
|
|
760
|
-
* whose values DO steer what doctor spawns, connects to and offers as fixes. For these
|
|
762
|
+
* whose values DO steer what doctor spawns, connects to and offers as fixes. For these five keys:
|
|
761
763
|
*
|
|
762
764
|
* - it reads to decide WHAT DOCTOR SAYS, never to configure anything. No value from here reaches a
|
|
763
765
|
* config, an argv, a container env, or a fix that writes;
|
|
764
|
-
* - the
|
|
766
|
+
* - the keys are the five frozen here and a caller's list can only narrow them (`envFileKeys`), so this cannot grow
|
|
767
|
+
* into "load .env". Since issue #859 what is read for them also reaches the checks that read those files
|
|
768
|
+
* (`bootFileEnv`), still only to decide what doctor says and whether it fails;
|
|
765
769
|
* - it is best effort, and it says WHICH kind of nothing it has. A file that is not there is `{}` and
|
|
766
770
|
* changes no message. Something that is there and cannot be read -- a directory, a pipe, a mode this
|
|
767
771
|
* account cannot open -- is `{ unreadable: true }`, which gets a line of its own, because "the key is
|
|
@@ -1153,7 +1157,7 @@ export function envFileKeys(path, keys, { fileExists, readEnvFile, statFile = st
|
|
|
1153
1157
|
}
|
|
1154
1158
|
// The narrowing is STRUCTURAL rather than a convention the caller keeps. A caller's key list can only
|
|
1155
1159
|
// narrow this further, never widen it: the licence for reading a `.env` at all is that it decides what
|
|
1156
|
-
//
|
|
1160
|
+
// doctor SAYS about five named files, and "the caller passes the right keys" is the kind of rule that holds until the
|
|
1157
1161
|
// third check wants the same softening and adds its own key to an array. A `.env` also holds
|
|
1158
1162
|
// `WEBHOOK_SECRET` and provider keys, and `up.mjs` states the standard for those: a webhook secret in a
|
|
1159
1163
|
// scrollback is a webhook secret in a pastebin. Add a key here and the addition is the review.
|
|
@@ -1254,6 +1258,107 @@ export function envFileKeys(path, keys, { fileExists, readEnvFile, statFile = st
|
|
|
1254
1258
|
}
|
|
1255
1259
|
}
|
|
1256
1260
|
|
|
1261
|
+
/**
|
|
1262
|
+
* The boot-file keys `bootFileEnv` resolves for doctor's other checks: every `ENV_FILE_READABLE_KEYS` key but
|
|
1263
|
+
* `PI_MODEL_ENDPOINTS_FILE`, whose checks keep their own rule (`endpointsFileSetting`: this shell's, else the `.env`'s,
|
|
1264
|
+
* and a disagreement is no endpoints), read where they are used.
|
|
1265
|
+
*/
|
|
1266
|
+
export const BOOT_FACT_KEYS = Object.freeze(ENV_FILE_READABLE_KEYS.filter((key) => key !== "PI_MODEL_ENDPOINTS_FILE"));
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* On the environment `bootFileEnv` returns, the keys whose `.env` value doctor could not read while this shell sets none
|
|
1270
|
+
* (a hazard in the file, a line not in the plain form, a file it could not open). A fact reader says such a file is not
|
|
1271
|
+
* known (`bootKeyUnread`), never that it holds nothing: "no projects" there FAILED a bundle bound to a project, issue
|
|
1272
|
+
* #859's own false ✗ reached another way. A symbol, so no scan of `env.NAME` reads mistakes it for a key.
|
|
1273
|
+
*/
|
|
1274
|
+
export const UNREAD_BOOT_KEYS = Symbol("boot-file keys whose .env value could not be read");
|
|
1275
|
+
|
|
1276
|
+
/** Whether `key` is one whose `.env` value doctor could not read (`UNREAD_BOOT_KEYS`). */
|
|
1277
|
+
function bootKeyUnread(env, key) {
|
|
1278
|
+
return env?.[UNREAD_BOOT_KEYS]?.has(key) === true;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* Issue #859: the boot-file keys (`BOOT_FACT_KEYS`) as doctor's other checks judge them, from this shell and from the one
|
|
1283
|
+
* reading of the deployment `.env` that `envFileKeys` made. Until this, the checks that read the projects, the scoped
|
|
1284
|
+
* limits, the pause windows and the envelope took these keys from this shell alone, and the shell a service deployment's
|
|
1285
|
+
* operator runs doctor from sets none of them: doctor judged a deployment with no projects, FAILED every secret bundle
|
|
1286
|
+
* bound to one as naming a project projects.json does not hold, left the size suggestions out without a word, and
|
|
1287
|
+
* compared "no projects" with healthy peers. Only the BOOT_FILES lines read the file, and they kept what they read.
|
|
1288
|
+
*
|
|
1289
|
+
* These keys stay outside `resolveDoctorEnv` (the BOOT_FILES lines judge two subjects, the service's file and this
|
|
1290
|
+
* shell, and a test pins `SERVICE_ENV_KEYS`), but the precedence is that resolver's: this shell's value where it sets
|
|
1291
|
+
* one, else the file's, and where both are set and name different files that is a ✗ (`differs`), as a disagreement
|
|
1292
|
+
* about any other service key is (`serviceEnvLines`), since the checks then judge files the service does not read. A
|
|
1293
|
+
* key set in this shell alone, beside a `.env` that could be read and does not set it, joins the #471 shell-only line
|
|
1294
|
+
* (`shellOnly`). A BLANK value in this shell has its own ✗ in the BOOT_FILES block and the checks then judge the
|
|
1295
|
+
* service's file. Paths are resolved against the deployment folder, where the service runs and where `loadVerdict`
|
|
1296
|
+
* resolves them, and compared resolved (case-folded on win32).
|
|
1297
|
+
*
|
|
1298
|
+
* What the file gives, and when it gives nothing:
|
|
1299
|
+
* - a value only where `envFileKeys` vouches for its line (plain, no hazard in the file);
|
|
1300
|
+
* - nothing from a `.env` another account can write (`untrusted`): doctor says it took no value from it, and it takes
|
|
1301
|
+
* none here either, so the checks judge this shell's values as that line says;
|
|
1302
|
+
* - "not known" (`UNREAD_BOOT_KEYS`) for a key this shell does not set when the file has a hazard, the key's line is
|
|
1303
|
+
* not in the plain form, or the file could not be read: the BOOT_FILES lines name why, and the checks say the file
|
|
1304
|
+
* is not known rather than empty.
|
|
1305
|
+
*
|
|
1306
|
+
* Read to decide what doctor SAYS, the licence `ENV_FILE_READABLE_KEYS` gives: what this returns goes to the fact readers
|
|
1307
|
+
* and the load verdicts and nowhere else. Returns `{ env, service, differs, shellOnly }`: `env` is `shell` with these keys
|
|
1308
|
+
* as the checks judge them (and `UNREAD_BOOT_KEYS`); `service` the same with the keys as the SERVICE reads them, from the
|
|
1309
|
+
* file alone, or this shell's where the file's line cannot be read (the load verdict of the service's own subject, whose
|
|
1310
|
+
* envelope is judged against the service's projects and limits); `differs` `[{ key, shell, file }]`; `shellOnly` keys.
|
|
1311
|
+
*/
|
|
1312
|
+
export function bootFileEnv(shell, envFile, cwd, platform = process.platform, { untrusted = false } = {}) {
|
|
1313
|
+
const win = platform === "win32";
|
|
1314
|
+
const at = (path) => (win ? win32.resolve(cwd, path) : posix.resolve(cwd, path));
|
|
1315
|
+
// ONE FILE NAMED TWO WAYS IS NO DIFFERENCE: where both are there, by what they are (a symlinked folder, `/tmp` and
|
|
1316
|
+
// `/private/tmp` on a Mac, a case-insensitive disk), else by the resolved text, case-folded on Windows.
|
|
1317
|
+
const identity = (path) => {
|
|
1318
|
+
try {
|
|
1319
|
+
const st = statSync(at(path));
|
|
1320
|
+
return `${st.dev}:${st.ino}`;
|
|
1321
|
+
} catch {
|
|
1322
|
+
return null;
|
|
1323
|
+
}
|
|
1324
|
+
};
|
|
1325
|
+
const same = (a, b) => {
|
|
1326
|
+
const ia = identity(a);
|
|
1327
|
+
const ib = ia === null ? null : identity(b);
|
|
1328
|
+
if (ia !== null && ib !== null) return ia === ib;
|
|
1329
|
+
return win ? at(a).toLowerCase() === at(b).toLowerCase() : at(a) === at(b);
|
|
1330
|
+
};
|
|
1331
|
+
// The SERVICE's subject reads the file whoever can write it, as the BOOT_FILES lines do (they only decide what doctor
|
|
1332
|
+
// says); the checks take nothing from an untrusted one.
|
|
1333
|
+
const whole = envFile ?? {};
|
|
1334
|
+
const file = untrusted ? {} : whole;
|
|
1335
|
+
const out = { env: { ...shell }, service: { ...shell }, differs: [], shellOnly: [], serviceUnread: new Set() };
|
|
1336
|
+
const unread = new Set();
|
|
1337
|
+
for (const key of BOOT_FACT_KEYS) {
|
|
1338
|
+
const own = typeof shell[key] === "string" && shell[key].trim() !== "" ? shell[key] : undefined;
|
|
1339
|
+
const lineOf = (f) => (f.hazard == null && typeof f[key] === "string" && f[key] !== "" ? f[key] : undefined);
|
|
1340
|
+
const unreadIn = (f) => f.unreadable === true || f.hazard != null || f.notPlain?.[key] !== undefined || f.insideValue?.[key] !== undefined;
|
|
1341
|
+
const raw = lineOf(file);
|
|
1342
|
+
const cannotRead = unreadIn(file);
|
|
1343
|
+
const sets = raw !== undefined || cannotRead || file.exported?.[key] !== undefined || file.blankInFile?.[key] === true;
|
|
1344
|
+
const serviceRaw = lineOf(whole);
|
|
1345
|
+
if (serviceRaw !== undefined) out.service[key] = at(serviceRaw);
|
|
1346
|
+
else if (unreadIn(whole)) out.serviceUnread.add(key);
|
|
1347
|
+
else delete out.service[key];
|
|
1348
|
+
if (own !== undefined) {
|
|
1349
|
+
out.env[key] = at(own);
|
|
1350
|
+
if (raw !== undefined && !same(own, raw)) out.differs.push({ key, shell: own, file: raw });
|
|
1351
|
+
else if (!sets && !untrusted && Object.keys(file).length > 0) out.shellOnly.push(key);
|
|
1352
|
+
} else if (raw !== undefined) {
|
|
1353
|
+
out.env[key] = at(raw);
|
|
1354
|
+
} else if (cannotRead) {
|
|
1355
|
+
unread.add(key);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
out.env[UNREAD_BOOT_KEYS] = unread;
|
|
1359
|
+
return out;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1257
1362
|
/**
|
|
1258
1363
|
* The files a worker LOADS at boot, and the one place that knows what each is and how to ask. The model endpoints file
|
|
1259
1364
|
* (issue #503) defaults to the deployment folder's copy where the other three are off when unset.
|
|
@@ -1371,6 +1476,19 @@ export function bootFileId(spec, subject = "service") {
|
|
|
1371
1476
|
return `${spec.scaffold.replace(/\.json$/, "")}.file${subject === "shell" ? "-shell" : ""}`;
|
|
1372
1477
|
}
|
|
1373
1478
|
|
|
1479
|
+
/**
|
|
1480
|
+
* Whether `path` is a regular file or nothing at all, by the real `statSync`: a FIFO or a device is neither, and a
|
|
1481
|
+
* synchronous read of a FIFO never returns. A stat that fails (absent, or a path only a seam knows) is left to the
|
|
1482
|
+
* loader, which says so in its own words.
|
|
1483
|
+
*/
|
|
1484
|
+
function regularOrAbsent(path) {
|
|
1485
|
+
try {
|
|
1486
|
+
return statSync(path).isFile();
|
|
1487
|
+
} catch {
|
|
1488
|
+
return true;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1374
1492
|
/** A key's value when it is a non-empty string, else null: the boot reads an unset key as off. */
|
|
1375
1493
|
function nonEmpty(value) {
|
|
1376
1494
|
return typeof value === "string" && value.trim() !== "" ? value : null;
|
|
@@ -1395,8 +1513,13 @@ function envelopeContextOf(env, io = {}) {
|
|
|
1395
1513
|
return [];
|
|
1396
1514
|
}
|
|
1397
1515
|
};
|
|
1398
|
-
|
|
1399
|
-
|
|
1516
|
+
// A FIFO or a device named here is none, never a read (issue #859): `loadVerdict` guards the envelope's own path, and
|
|
1517
|
+
// these two are read inside the envelope's load, from the `.env` since #859 as well as from this shell.
|
|
1518
|
+
const guarded = (path) => (path !== null && !regularOrAbsent(path) ? () => [] : null);
|
|
1519
|
+
const projectsFile = nonEmpty(env.PI_PROJECTS_FILE);
|
|
1520
|
+
const limitsFile = nonEmpty(env.PI_SCOPED_LIMITS_FILE);
|
|
1521
|
+
const projects = quiet(guarded(projectsFile) ?? (() => loadProjects({ projectsFile }, io)));
|
|
1522
|
+
const limits = quiet(guarded(limitsFile) ?? (() => loadScopedLimits({ scopedLimitsFile: limitsFile }, io)));
|
|
1400
1523
|
let maxCostMicros = null;
|
|
1401
1524
|
try {
|
|
1402
1525
|
const settings = deploymentSettingsOf(env, settingsFilePath(env), (p) => (io.existsSync ?? existsSync)(p));
|
|
@@ -1660,6 +1783,13 @@ export async function collectChecks(shellVars, seams) {
|
|
|
1660
1783
|
// is this shell's, never the file's. The one thing the worker does read there, auth.json, follows the resolution
|
|
1661
1784
|
// (`keyAgentDir`).
|
|
1662
1785
|
const { cwd, spawn, probeValkey, readHosts = defaultReadHosts, fileExists, nodeVersion, platform, agentDir = agentDirFrom(spawnEnv), readHostPiFn = readHostPi, home = safeHomeDir(), underTemp = underOsTempDir, providerOracle = defaultProviderOracle, facts = null, readEnvFile = null, stat: statSeam = statSync, runTimeouts = RUN_TIMEOUTS } = seams;
|
|
1786
|
+
// Issue #859: the deployment `.env`'s reading of the five boot-file keys, made ONCE and here, before the first check that
|
|
1787
|
+
// reads a projects, scoped-limits, pause-windows or envelope file (a test fails a second read). The BOOT_FILES lines
|
|
1788
|
+
// further down judge its two subjects from it, as they did when it was read there; `bootEnv` is what every other
|
|
1789
|
+
// check judges those keys by (`bootFileEnv`), never this shell's value alone.
|
|
1790
|
+
const envFile = envFileKeys(join(cwd, ".env"), BOOT_FILES.map((spec) => spec.key), { fileExists, readEnvFile, platform });
|
|
1791
|
+
const boot = bootFileEnv(env, envFile, cwd, platform, { untrusted: seams.envUntrusted === true });
|
|
1792
|
+
const bootEnv = boot.env;
|
|
1663
1793
|
|
|
1664
1794
|
// The job image as the worker takes it (`config.mjs` `jobImageFrom`: `||`, so an empty value is the default, where
|
|
1665
1795
|
// doctor's `??` inspected an image named ""), and judged BEFORE doctor inspects or runs it (issue #471) by the one
|
|
@@ -1895,10 +2025,17 @@ export async function collectChecks(shellVars, seams) {
|
|
|
1895
2025
|
// drives a need above 1 through the real collection, for a store whose table has no row of its own and at a version no row holds yet.
|
|
1896
2026
|
const versionTables = seams.versionTables ?? {};
|
|
1897
2027
|
const { watchSources: triggerWatchSources, watchRules: triggerWatchRules, modelRoles: triggerModelRoles, secretBundles: triggerBundles, requiring, waiting, listing, waitProfiles, waitAfters, optingOut, resuming, recovering, replicating, instructing, commands, secreting, onceArmed, onceSpent, secretProfiles, localSecretFolders, secretNames, folders, images, imageRoutes, namedBackends, skillsDirs, forges, repositories, flows, excludedNames, costCaps, modelRuns, triggersVersionNeed, jobDataVersionNeed, parseError, parseNewer: triggersFileIsNewer, path: triggersFilePath } = readTriggerFacts(env, fileExists, cwd, declaredWorkerName, versionTables);
|
|
1898
|
-
const modelRoleProjects = readProjectFacts(
|
|
1899
|
-
const scopedLimitFacts = readScopedLimitFacts(
|
|
1900
|
-
const pauseWindowFacts = readPauseWindowFacts(
|
|
2028
|
+
const modelRoleProjects = readProjectFacts(bootEnv, fileExists).projects;
|
|
2029
|
+
const scopedLimitFacts = readScopedLimitFacts(bootEnv, fileExists);
|
|
2030
|
+
const pauseWindowFacts = readPauseWindowFacts(bootEnv, fileExists, versionTables.pauseWindows);
|
|
1901
2031
|
section("triggers");
|
|
2032
|
+
// Issue #859: a boot file the `.env` names on a line doctor could not read is not known, and the checks that read it are
|
|
2033
|
+
// left out rather than judged against nothing; this line says which, once, so none of them goes quiet without a word. A
|
|
2034
|
+
// `.env` that could not be read at all has a line per key already ("cannot be answered here"), so this adds nothing there.
|
|
2035
|
+
const unreadBoot = [...bootEnv[UNREAD_BOOT_KEYS]];
|
|
2036
|
+
if (unreadBoot.length > 0 && envFile.unreadable !== true) {
|
|
2037
|
+
checks.push({ ok: false, warn: true, label: `not judged here, since doctor could not read what the deployment .env sets for ${unreadBoot.join(", ")}: the checks that read ${unreadBoot.length === 1 ? "that file" : "those files"} (a project's model roles, size and bundles, the scoped limits and budgets, the pause windows, the envelope)`, fix: "fix what the boot-file line above names, then re-run doctor" });
|
|
2038
|
+
}
|
|
1902
2039
|
checks.push(...watchDoctorChecks({ sources: triggerWatchSources, rules: triggerWatchRules, env }));
|
|
1903
2040
|
// FIRST, and fail rather than warn: every check below this line reads counts that a parse failure
|
|
1904
2041
|
// zeroed, so a green run here would be reporting on a file nobody could read. The receiver loads this
|
|
@@ -2342,9 +2479,13 @@ export async function collectChecks(shellVars, seams) {
|
|
|
2342
2479
|
// offers. The records are read only when there is a project to suggest for, and not at all when the scoped-limits
|
|
2343
2480
|
// file does not load: every size read without it would be the default, not the project's, and a project that has a
|
|
2344
2481
|
// row would be told to `dispatch_limit_add` one.
|
|
2345
|
-
const sizingProjects = readProjectFacts(
|
|
2482
|
+
const sizingProjects = readProjectFacts(bootEnv, fileExists).projects;
|
|
2346
2483
|
if (sizingProjects.length > 0 && !budgetView.error) {
|
|
2347
|
-
if (scopedLimitFacts.parseError
|
|
2484
|
+
if (scopedLimitFacts.parseError === "unread") {
|
|
2485
|
+
// Issue #859: the `.env` line naming the file could not be read (its own line says why), so which rows the service
|
|
2486
|
+
// has is not known, and a suggestion would tell a project that has one to add it.
|
|
2487
|
+
checks.push({ ok: true, label: "size suggestions: off, because the scoped-limits file the service reads is not known (doctor could not read what the deployment .env sets for it)" });
|
|
2488
|
+
} else if (scopedLimitFacts.parseError !== null) {
|
|
2348
2489
|
checks.push({ ok: true, label: "size suggestions: off until the scoped-limits file loads (a size read without it would be the default, not the project's)" });
|
|
2349
2490
|
} else {
|
|
2350
2491
|
const nowMs = (typeof seams.wallClock === "function" ? seams.wallClock : Date.now)();
|
|
@@ -3139,12 +3280,12 @@ export async function collectChecks(shellVars, seams) {
|
|
|
3139
3280
|
// A malformed list is its own line below; the worker refuses to boot on it.
|
|
3140
3281
|
}
|
|
3141
3282
|
const usdHere = usdFingerprint(dollarsHere, scopedLimitFacts.parseError === null ? scopedLimitFacts.limits : [], envListHere);
|
|
3142
|
-
checks.push(...(await fleetDollarChecks(usdHere, peers, { dollarKeysExist: () => (seams.dollarKeysExist ?? defaultDollarKeysExist)(valkeyTalkUrl) })));
|
|
3283
|
+
if (scopedLimitFacts.parseError !== "unread") checks.push(...(await fleetDollarChecks(usdHere, peers, { dollarKeysExist: () => (seams.dollarKeysExist ?? defaultDollarKeysExist)(valkeyTalkUrl) })));
|
|
3143
3284
|
// Issue #499 part C: each host resolves its jobs' project from its OWN projects.json, while the project rows' counters
|
|
3144
3285
|
// are shared. This host's `fpProjects` from the file the service names, against every peer's published one. SKIPPED
|
|
3145
3286
|
// when this host's file does not load (PR #569's review): the BOOT_FILES line already fails on it, and comparing
|
|
3146
3287
|
// "no projects" against healthy peers would send the operator to the wrong host.
|
|
3147
|
-
const projectFactsHere = readProjectFacts(
|
|
3288
|
+
const projectFactsHere = readProjectFacts(bootEnv, fileExists);
|
|
3148
3289
|
if (projectFactsHere.parseError === null) checks.push(...fleetProjectsChecks(projectsFingerprint(projectFactsHere.projects), peers));
|
|
3149
3290
|
// Issue #596: a peer that predates version 3 keeps its last good scoped-limits file once this one is version 3, so
|
|
3150
3291
|
// no edit to the file applies on it. Judged on the DECLARED version, or the derived one if that is higher (gate
|
|
@@ -3160,7 +3301,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
3160
3301
|
// Issue #504 part B: one applied split, judged on every host against its own envelope. A host whose envelope digest
|
|
3161
3302
|
// differs refuses every governed job as `envelope-mismatch`. SKIPPED when this host's file does not load, for the
|
|
3162
3303
|
// projects check's reason above.
|
|
3163
|
-
const envelopeHere = readEnvelopeFacts(
|
|
3304
|
+
const envelopeHere = readEnvelopeFacts(bootEnv);
|
|
3164
3305
|
if (envelopeHere.parseError === null && appliedSplit === null) checks.push(...fleetEnvelopeChecks(envelopeHere.envelope ? envelopeDigest(envelopeHere.envelope) : NO_ENVELOPE_FINGERPRINT, peers));
|
|
3165
3306
|
} else if (fleet.unreachable) {
|
|
3166
3307
|
// Said, rather than silently absent: "no peers" and "could not ask" are different facts.
|
|
@@ -3185,7 +3326,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
3185
3326
|
// whose envelope differs refuses its governed jobs. Read whenever this command may talk to the Valkey (above), from
|
|
3186
3327
|
// the same Valkey the fleet was read from; with no split there, or no answer, nothing is said.
|
|
3187
3328
|
if (appliedSplit !== null) {
|
|
3188
|
-
const envelopeHere = readEnvelopeFacts(
|
|
3329
|
+
const envelopeHere = readEnvelopeFacts(bootEnv);
|
|
3189
3330
|
if (envelopeHere.parseError === null) checks.push(...appliedSplitChecks(appliedSplit, envelopeHere.envelope ? envelopeDigest(envelopeHere.envelope) : NO_ENVELOPE_FINGERPRINT, workerNameOf(declaredWorkerName), peers));
|
|
3190
3331
|
}
|
|
3191
3332
|
|
|
@@ -3860,7 +4001,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
3860
4001
|
}
|
|
3861
4002
|
}
|
|
3862
4003
|
|
|
3863
|
-
checks.push(...await doctorModelProposalChecks(env, { cwd, credentials: service.extra(["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]).env, now: seams.wallClock ?? Date.now, ...(seams.modelProposals ?? {}) }));
|
|
4004
|
+
checks.push(...await doctorModelProposalChecks(env, { cwd, credentials: service.extra(["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]).env, declaredEndpoints: () => (seams.declaredEndpoints ?? ((a) => declaredEndpointsIn({ ...a, fs: keylessIo })))({ env, cwd: seams.cwd, platform: seams.platform ?? process.platform }), now: seams.wallClock ?? Date.now, ...(seams.modelProposals ?? {}) }));
|
|
3864
4005
|
section("secrets");
|
|
3865
4006
|
// REQ-TRIGGER-SECRETS. Only reported when a trigger actually binds one, on the run.resume block's
|
|
3866
4007
|
// reasoning below: a deployment that uses no secrets should not be told about a variable it has no
|
|
@@ -3996,7 +4137,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
3996
4137
|
// skip reason. Doctor cannot see another host's file, so it warns where the shape allows it: a multi-host worker
|
|
3997
4138
|
// (PI_WORKER_NAME) with projects and no triggers file of its own.
|
|
3998
4139
|
if (!anyBundle && typeof env.PI_WORKER_NAME === "string" && env.PI_WORKER_NAME.trim() !== "" && !fileExists(triggersPath(env, cwd))) {
|
|
3999
|
-
const fleetProjects = readProjectFacts(
|
|
4140
|
+
const fleetProjects = readProjectFacts(bootEnv, fileExists);
|
|
4000
4141
|
if (fleetProjects.parseError === null && fleetProjects.projects.length > 0) {
|
|
4001
4142
|
checks.push({ ok: true, warn: true, label: "this fleet worker has projects but no triggers file, so it applies no project secret bundle: a project another host's triggers.json binds would run here without its environment", fix: "keep the same triggers.json on every host (PI_TRIGGERS_FILE), or declare the bundle profiles here, which makes a missing file a retry instead" });
|
|
4002
4143
|
}
|
|
@@ -4008,7 +4149,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4008
4149
|
} catch {
|
|
4009
4150
|
realExecutablePath = null;
|
|
4010
4151
|
}
|
|
4011
|
-
const projectFacts = readProjectFacts(
|
|
4152
|
+
const projectFacts = readProjectFacts(bootEnv, fileExists);
|
|
4012
4153
|
let resolveBundle = null;
|
|
4013
4154
|
if (seams.secrets === true) {
|
|
4014
4155
|
try {
|
|
@@ -4026,6 +4167,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4026
4167
|
triggersPresent: fileExists(triggersPath(env, cwd)),
|
|
4027
4168
|
projects: projectFacts.projects,
|
|
4028
4169
|
projectsError: projectFacts.parseError,
|
|
4170
|
+
projectsUnread: projectFacts.unread === true,
|
|
4029
4171
|
envRaw: env.PI_SECRET_BUNDLES,
|
|
4030
4172
|
overlayBundles,
|
|
4031
4173
|
roots: delimitedList(env.PI_SECRET_RESOLVER_ROOTS),
|
|
@@ -4232,7 +4374,7 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4232
4374
|
// warning then cries wolf at a correctly configured deployment, and this module's own rule is that a
|
|
4233
4375
|
// check nobody can silence must never do that.
|
|
4234
4376
|
//
|
|
4235
|
-
// So doctor reads `<cwd>/.env` for EXACTLY the
|
|
4377
|
+
// So doctor reads `<cwd>/.env` for EXACTLY the boot-file keys (`ENV_FILE_READABLE_KEYS`), and never to configure anything.
|
|
4236
4378
|
// That narrowing is the whole licence: the project's stance is that nothing parses `.env`
|
|
4237
4379
|
// (`docs/secrets.md`), and `worker/test/service.test.mjs` pins that a `PI_ENV_SETUP` line in `./.env` is
|
|
4238
4380
|
// deliberately NOT honoured. Both stay true.
|
|
@@ -4242,7 +4384,8 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4242
4384
|
// a deployment whose unit exits 1 in a restart loop is not a deployment that is merely unconfigured in
|
|
4243
4385
|
// this shell. Where the file merely configures what this shell does not, the line still says which
|
|
4244
4386
|
// process would honour it, and is still a warning.
|
|
4245
|
-
|
|
4387
|
+
// The read itself (`envFile`) is made once at the top of `collectChecks` since issue #859, so every check that reads
|
|
4388
|
+
// these files, before this block and after it, judges the same reading (`bootFileEnv`).
|
|
4246
4389
|
// ONE RULE FOR BOTH BOOT FILES, and it asks the worker's own loader rather than a second opinion
|
|
4247
4390
|
// (issue #384). Before this there were two hand-written copies of a shell-shaped check for
|
|
4248
4391
|
// `PI_PAUSE_WINDOWS_FILE`, a THIRD rule for a scoped-limits file that does not parse, and no rule at all
|
|
@@ -4263,12 +4406,15 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4263
4406
|
// regular-file guard and the loaders' own two reads. A test drives a whole deployment through these
|
|
4264
4407
|
// without a real file, which is how the fixtures below stay honest about content.
|
|
4265
4408
|
const seamsForLoad = { env, statFile: statSeam, loaderIo: { existsSync: (p) => fileExists(p), readFileSync: readEnvFile ? (p, enc) => asText(readEnvFile(p), enc) : readFileSync } };
|
|
4409
|
+
// Issue #859: the SERVICE's subject is loaded with the boot keys as the service reads them, from the file alone, so an
|
|
4410
|
+
// envelope in `.env` is judged against the projects and limits the service names, never against this shell's.
|
|
4411
|
+
const serviceSeamsForLoad = { ...seamsForLoad, env: boot.service };
|
|
4266
4412
|
// ONCE PER FILE, not once per key (issue #396). This is a fact about the FILE -- one line the reader
|
|
4267
4413
|
// cannot model -- and it was announced inside the per-key loop, so a `.env` with one such line produced
|
|
4268
4414
|
// two near-identical warnings differing only in which key they named. The keys it prevents a verdict
|
|
4269
4415
|
// about are listed IN the line instead, which is what the reader actually knows.
|
|
4270
4416
|
//
|
|
4271
|
-
// ITS LIMIT, stated because the wording would otherwise imply more: this command reads
|
|
4417
|
+
// ITS LIMIT, stated because the wording would otherwise imply more: this command reads five keys, so the
|
|
4272
4418
|
// line says what it cannot answer about THOSE. The same hazard may also stop a sourcing shell reaching
|
|
4273
4419
|
// `WEBHOOK_SECRET`, which the receiver refuses to start without, and nothing here says so -- widening
|
|
4274
4420
|
// the read is how a narrow reader grows into "load the .env", which `envFileKeys`' own docblock and
|
|
@@ -4385,8 +4531,13 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4385
4531
|
// boot key was never opened. `fileRaw` is set only for a line this reader vouches for the TEXT of, so
|
|
4386
4532
|
// there is always a real path here; whether it loads is a fact about the filesystem, not about the
|
|
4387
4533
|
// rest of the file.
|
|
4388
|
-
|
|
4389
|
-
|
|
4534
|
+
// Issue #859: the envelope is judged against the projects and limits the service names; where the `.env` line of one
|
|
4535
|
+
// of those cannot be read, neither can this verdict, and a "no projects" verdict would fail a service that starts.
|
|
4536
|
+
const contextUnread = spec.key === "PI_ENVELOPE_FILE" ? ["PI_PROJECTS_FILE", "PI_SCOPED_LIMITS_FILE"].filter((key) => boot.serviceUnread.has(key)) : [];
|
|
4537
|
+
if (envFile.hazard == null && fileRaw !== undefined && !blankInFile && contextUnread.length > 0) {
|
|
4538
|
+
checks.push({ id: bootFileId(spec), ok: false, warn: true, label: `${spec.key} is set in ${join(cwd, ".env")} (${envValueShown(fileRaw)}), and whether the service loads it cannot be judged: doctor could not read what that file sets for ${contextUnread.join(" and ")}, which it is judged against`, fix: "fix that line (its own line here says why), then re-run doctor" });
|
|
4539
|
+
} else if (envFile.hazard == null && fileRaw !== undefined && !blankInFile) {
|
|
4540
|
+
const verdict = loadVerdict(spec, fileRaw, cwd, serviceSeamsForLoad, platform);
|
|
4390
4541
|
checks.push(
|
|
4391
4542
|
verdict.ok
|
|
4392
4543
|
? { id: bootFileId(spec), ok: true, label: `${spec.key} is set in ${join(cwd, ".env")} (${envValueShown(fileRaw)})${alsoExported === undefined ? "" : ", for this platform's loader,"} and loads: the service reads that file, this shell does not` }
|
|
@@ -4462,6 +4613,19 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4462
4613
|
fix: `set ${fixLineFor(spec.key, scaffolded)} in .env and restart the worker -- unset means ${spec.unsetMeans}${spec.panelWrites === false ? "" : `, while the admin panel defaults to this same file and reports each ${spec.unit} it writes as applied live`}; delete the file if this deployment has no ${spec.nothing}`,
|
|
4463
4614
|
});
|
|
4464
4615
|
}
|
|
4616
|
+
|
|
4617
|
+
// Issue #859: WHICH FILE THE OTHER CHECKS JUDGED, where the two subjects name different ones. A ✗, as a disagreement
|
|
4618
|
+
// about any other service key is (`serviceEnvLines`): the checks then judge files the service does not read, and a
|
|
4619
|
+
// warning let doctor exit 0 on a service its own `.env` stops (a project row the shell's projects hold and the
|
|
4620
|
+
// file's do not passed, while `checkProjectRows` refuses the boot).
|
|
4621
|
+
const differs = boot.differs.find((d) => d.key === spec.key);
|
|
4622
|
+
if (differs !== undefined) {
|
|
4623
|
+
checks.push({
|
|
4624
|
+
ok: false,
|
|
4625
|
+
label: `${spec.key} names different files in this shell (${envValueShown(differs.shell)}) and in ${join(cwd, ".env")} (${envValueShown(differs.file)}): doctor's other checks judged this shell's, which a worker started by hand from this shell runs, while the service runs the file's`,
|
|
4626
|
+
fix: `make them agree: change ${join(cwd, ".env")} (what the service reads), or unset ${spec.key} in this shell, then re-run doctor`,
|
|
4627
|
+
});
|
|
4628
|
+
}
|
|
4465
4629
|
}
|
|
4466
4630
|
|
|
4467
4631
|
// A scope that can only ever be a folder (issue #242's dead-scope rule): a forge repo always contains "/" and never
|
|
@@ -4501,14 +4665,14 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4501
4665
|
// (start.mjs, `checkProjectRows`), so this is a FAILURE naming the row and the id. Skipped when either file does not
|
|
4502
4666
|
// load: that is the BOOT_FILES line's, and a zeroed list here would name every project row as dangling.
|
|
4503
4667
|
if (scopedLimitFacts.parseError === null && scopedLimitFacts.limits.length > 0) {
|
|
4504
|
-
const projectFacts = readProjectFacts(
|
|
4668
|
+
const projectFacts = readProjectFacts(bootEnv, fileExists);
|
|
4505
4669
|
if (projectFacts.parseError === null) checks.push(...projectRowChecks(scopedLimitFacts.limits, projectFacts.projects, scopedLimitFacts.path));
|
|
4506
4670
|
}
|
|
4507
4671
|
|
|
4508
4672
|
section("envelope");
|
|
4509
4673
|
// Issue #504 part B: the envelope's advisories, on the file the service names, when it loads (a file that does not is
|
|
4510
4674
|
// the BOOT_FILES line's). Warnings only: the worker boots on both.
|
|
4511
|
-
const envelopeFactsHere = readEnvelopeFacts(
|
|
4675
|
+
const envelopeFactsHere = readEnvelopeFacts(bootEnv);
|
|
4512
4676
|
if (envelopeFactsHere.envelope) checks.push(...envelopeChecks(envelopeFactsHere.envelope, envelopeFactsHere.projects, envelopeFactsHere.maxCostMicros));
|
|
4513
4677
|
|
|
4514
4678
|
section("console");
|
|
@@ -4812,7 +4976,9 @@ export async function collectChecks(shellVars, seams) {
|
|
|
4812
4976
|
// Nor XDG_CONFIG_HOME at its own default (`~/.config`, which a login shell often exports): unset, every CLI that
|
|
4813
4977
|
// reads it falls back to that same directory, so the service runs with the same value (measured on pd-fedora: the
|
|
4814
4978
|
// named line otherwise flagged the default).
|
|
4815
|
-
|
|
4979
|
+
// Issue #859: a boot-file key set in this shell alone (`bootFileEnv`) is the same fact about the same file, so it joins
|
|
4980
|
+
// this line: the checks judged this shell's file, and the service runs without one.
|
|
4981
|
+
const shellOnly = [...service.shellOnly.filter((k) => !AMBIENT_SERVICE_KEYS.includes(k) && !(k === "XDG_CONFIG_HOME" && typeof home === "string" && env.XDG_CONFIG_HOME === join(home, ".config"))), ...boot.shellOnly];
|
|
4816
4982
|
const unit = shellOnly.length > 0 ? serviceUnitFor(seams, cwd) : null;
|
|
4817
4983
|
if (unit) {
|
|
4818
4984
|
checks.push({
|
|
@@ -5414,6 +5580,7 @@ function parseSecretProfilesSafe(raw) {
|
|
|
5414
5580
|
*/
|
|
5415
5581
|
function readScopedLimitFacts(env, fileExists) {
|
|
5416
5582
|
const none = { limits: [], parseError: null, path: null };
|
|
5583
|
+
if (bootKeyUnread(env, "PI_SCOPED_LIMITS_FILE")) return { ...none, parseError: "unread" };
|
|
5417
5584
|
const path = env.PI_SCOPED_LIMITS_FILE;
|
|
5418
5585
|
if (typeof path !== "string" || path.trim() === "") return none;
|
|
5419
5586
|
if (!fileExists(path)) return { limits: [], parseError: `scoped-limits file does not exist: ${path}`, path };
|
|
@@ -5437,8 +5604,12 @@ function readScopedLimitFacts(env, fileExists) {
|
|
|
5437
5604
|
/**
|
|
5438
5605
|
* The projects facts (issue #499 part B): the parsed projects when PI_PROJECTS_FILE is set, `[]` when it is unset (no
|
|
5439
5606
|
* projects, so every project row dangles), or a parse error when it is set and does not load (the BOOT_FILES line's).
|
|
5607
|
+
* Handed `bootEnv` (issue #859), so the key is the service's where this shell sets none, and `unread` where the `.env`
|
|
5608
|
+
* line that sets it could not be read.
|
|
5440
5609
|
*/
|
|
5441
5610
|
function readProjectFacts(env, fileExists) {
|
|
5611
|
+
// Issue #859: a `.env` line doctor could not read is a file not known, said by its own line, never "no projects".
|
|
5612
|
+
if (bootKeyUnread(env, "PI_PROJECTS_FILE")) return { projects: [], parseError: "unread", unread: true };
|
|
5442
5613
|
const path = env.PI_PROJECTS_FILE;
|
|
5443
5614
|
if (typeof path !== "string" || path.trim() === "") return { projects: [], parseError: null };
|
|
5444
5615
|
try {
|
|
@@ -5552,6 +5723,16 @@ export function consoleChecks(env, { cwd = process.cwd(), fs = nodeFs, uid = pro
|
|
|
5552
5723
|
} else if (operators > 0) {
|
|
5553
5724
|
checks.push({ ok: true, label: `console: tokens file ${shownPath} is owner-only and outside every job folder (${operators} operator${operators === 1 ? "" : "s"}, ${viewers} viewer${viewers === 1 ? "" : "s"})` });
|
|
5554
5725
|
}
|
|
5726
|
+
// Issue #849: the remembered devices, which sit beside the tokens file and are as sensitive as it is about who may sign in.
|
|
5727
|
+
const devicesDir = consoleDevicesDir(path);
|
|
5728
|
+
const devices = devicesState({ dir: devicesDir, fs, uid, platform });
|
|
5729
|
+
if (devices.problem) {
|
|
5730
|
+
checks.push({ ok: false, label: "console: the remembered-device folder cannot be used, so a remembered browser must sign in again", fix: escapeControls(devices.problem) });
|
|
5731
|
+
} else if (!devices.absent) {
|
|
5732
|
+
const devicesInside = consoleTokensInsideJobPaths(devicesDir, env, { cwd, fs, folders, skillsDirs, realpath, directory: true });
|
|
5733
|
+
if (devicesInside.problem) checks.push({ ok: false, label: `console: the remembered-device folder ${quotedShown(devicesDir)} ${devicesInside.problem}`, fix: devicesInside.fix });
|
|
5734
|
+
if (devices.unreadable > 0) checks.push({ ok: false, label: `console: ${devices.unreadable} remembered-device record${devices.unreadable === 1 ? "" : "s"} in ${quotedShown(devicesDir)} cannot be read, and those browsers must sign in again`, fix: `if a newer pi-dispatch wrote ${devices.unreadable === 1 ? "it" : "them"}, upgrade this host; otherwise remove the file${devices.unreadable === 1 ? "" : "s"} whose name${devices.unreadable === 1 ? " starts" : "s start"} with ${devices.unreadableIds.slice(0, 5).join(", ")} from that folder` });
|
|
5735
|
+
}
|
|
5555
5736
|
const kept = keptTokens(read.file).length;
|
|
5556
5737
|
if (kept > 0) {
|
|
5557
5738
|
const said = kept === 1 ? "1 token carries a role this pi-dispatch does not know, so a console of this release signs it in nowhere" : `${kept} tokens carry a role this pi-dispatch does not know, so a console of this release signs them in nowhere`;
|
|
@@ -5567,6 +5748,11 @@ export function consoleChecks(env, { cwd = process.cwd(), fs = nodeFs, uid = pro
|
|
|
5567
5748
|
} else {
|
|
5568
5749
|
checks.push({ ok: true, label: `console: listens on ${escapeControls(listen.shown)}` });
|
|
5569
5750
|
}
|
|
5751
|
+
// Only on an address that really is not loopback: PI_CONSOLE_BIND_PUBLIC=1 alone changes nothing about where a loopback console's cookies travel.
|
|
5752
|
+
if (bindPublic && !listen.refusal && !listen.loopback) {
|
|
5753
|
+
const devices = devicesState({ dir: consoleDevicesDir(path), fs, uid, platform });
|
|
5754
|
+
if (devices.live > 0) checks.push({ ok: false, warn: true, label: `console: ${devices.live} remembered device${devices.live === 1 ? "" : "s"} on a console that listens on a non-loopback address, where cookies cross the network in plain HTTP`, fix: "listen on a loopback address and reach the console through a tunnel, or put it behind a proxy that terminates TLS" });
|
|
5755
|
+
}
|
|
5570
5756
|
if (bindPublic) {
|
|
5571
5757
|
checks.push({ ok: false, warn: true, label: "console: PI_CONSOLE_BIND_PUBLIC=1 lets the console listen on a non-loopback address, where tokens and session cookies cross the network in plain HTTP", fix: "keep it only behind a TLS proxy on a network you trust; otherwise unset PI_CONSOLE_BIND_PUBLIC, listen on 127.0.0.1, [::1] or a unix: socket, and reach it with ssh -L" });
|
|
5572
5758
|
}
|
|
@@ -5653,17 +5839,19 @@ export async function consoleServiceChecks(env, seams = {}) {
|
|
|
5653
5839
|
* relative run root is skipped here (the envelope refuses one; the console has no such rule to borrow), and a path
|
|
5654
5840
|
* that cannot be judged is said, not passed.
|
|
5655
5841
|
*/
|
|
5656
|
-
function consoleTokensInsideJobPaths(path, env, { cwd, fs, folders, skillsDirs, realpath }) {
|
|
5842
|
+
function consoleTokensInsideJobPaths(path, env, { cwd, fs, folders, skillsDirs, realpath, directory = false }) {
|
|
5657
5843
|
const fix = "move the tokens file out of every local trigger's run.folder and run.skillsDir, every PI_DISPATCH_RUN_ROOTS root and PI_GLOBAL_PI_DIR, and point PI_CONSOLE_TOKENS_FILE at its new path";
|
|
5658
5844
|
try {
|
|
5659
5845
|
const st = fs.statSync(path);
|
|
5660
|
-
|
|
5846
|
+
// A folder always has more than one link (its own name, `.`, and each child's `..`), so the count says nothing about one.
|
|
5847
|
+
if (!directory && st.nlink > 1) return { problem: `has ${st.nlink} hard links: another name of the file could sit inside a folder a job can see, so keep exactly one`, fix: `remove the other names (\`find / -samefile ${path}\` lists them)` };
|
|
5661
5848
|
const real = realpath(path);
|
|
5662
5849
|
const runRoots = delimitedList(env.PI_DISPATCH_RUN_ROOTS).filter((r) => isAbsolute(r));
|
|
5663
5850
|
const globalPiDir = typeof env.PI_GLOBAL_PI_DIR === "string" && env.PI_GLOBAL_PI_DIR !== "" ? env.PI_GLOBAL_PI_DIR : null;
|
|
5664
5851
|
// PWD comes from the process environment inside the check, as on doctor's envelope load (`loadEnvelopeChecked`).
|
|
5665
|
-
const inside = envelopeInsideJobPaths(real, { cronFolders: folders, runRoots, skillsDirs, globalPiDir }, { cwd });
|
|
5852
|
+
const inside = envelopeInsideJobPaths(real, { cronFolders: folders, runRoots, skillsDirs, globalPiDir }, { cwd, directory });
|
|
5666
5853
|
if (inside === null) return {};
|
|
5854
|
+
if (directory) return { problem: `lies inside a folder a job container can see (${quotedShown(inside.path)}): a job there could plant a remembered device`, fix: "move the tokens file and this folder out of every folder a job can see, point PI_CONSOLE_TOKENS_FILE at the new path, and remove the old folder" };
|
|
5667
5855
|
const what = { "cron-folder": "a local trigger's run.folder", "run-root": "a PI_DISPATCH_RUN_ROOTS root", "skills-dir": "a trigger's run.skillsDir", "global-pi-dir": "PI_GLOBAL_PI_DIR" }[inside.kind] ?? inside.kind;
|
|
5668
5856
|
return { problem: `lies inside ${what} (${quotedShown(inside.path)}), which a job container can see: a job there could read the token hashes or add a token of its own`, fix };
|
|
5669
5857
|
} catch (err) {
|
|
@@ -5677,9 +5865,13 @@ function consoleTokensInsideJobPaths(path, env, { cwd, fs, folders, skillsDirs,
|
|
|
5677
5865
|
* `envelope` is null when the key is unset.
|
|
5678
5866
|
*/
|
|
5679
5867
|
function readEnvelopeFacts(env) {
|
|
5868
|
+
if (bootKeyUnread(env, "PI_ENVELOPE_FILE")) return { envelope: null, projects: [], maxCostMicros: null, parseError: "unread" };
|
|
5680
5869
|
const path = nonEmpty(env.PI_ENVELOPE_FILE);
|
|
5681
5870
|
if (path === null) return { envelope: null, projects: [], maxCostMicros: null, parseError: null };
|
|
5682
5871
|
const io = { readFileSync, existsSync };
|
|
5872
|
+
// A REGULAR FILE or nothing, as every other reader of these files here (issue #859): since the `.env` names this path
|
|
5873
|
+
// too, a FIFO there hung doctor in a synchronous read no timeout interrupts.
|
|
5874
|
+
if (!regularOrAbsent(path)) return { envelope: null, projects: [], maxCostMicros: null, parseError: `envelope file is not a regular file: ${path}` };
|
|
5683
5875
|
try {
|
|
5684
5876
|
const context = envelopeContextOf(env, io);
|
|
5685
5877
|
return { envelope: loadEnvelopeAsTheWorker(path, io, env), projects: context.projects, maxCostMicros: context.maxCostMicros, parseError: null };
|
|
@@ -5870,6 +6062,7 @@ export function projectRowChecks(limits, projects, path) {
|
|
|
5870
6062
|
* module's own by default.
|
|
5871
6063
|
*/
|
|
5872
6064
|
function readPauseWindowFacts(env, fileExists, versions = {}) {
|
|
6065
|
+
if (bootKeyUnread(env, "PI_PAUSE_WINDOWS_FILE")) return { windows: [], versionNeed: 1, parseError: "unread" };
|
|
5873
6066
|
const path = env.PI_PAUSE_WINDOWS_FILE;
|
|
5874
6067
|
if (typeof path !== "string" || path.trim() === "") return { windows: [], versionNeed: 1, parseError: null };
|
|
5875
6068
|
try {
|
package/src/envelope.mjs
CHANGED
|
@@ -290,7 +290,7 @@ function jobPathCandidates(path, cwd, env, statSync) {
|
|
|
290
290
|
* to write instead, when there is one. The FILE must exist (the check runs with a successful load) and must have one
|
|
291
291
|
* link: a hard link elsewhere is the same file under a name this check cannot see, and it could sit in a job path.
|
|
292
292
|
*/
|
|
293
|
-
function canonicalEnvelopePath(path, realpath, statSync) {
|
|
293
|
+
function canonicalEnvelopePath(path, realpath, statSync, directory = false) {
|
|
294
294
|
const text = String(path);
|
|
295
295
|
if (!isAbsolute(text)) throw configError(`the envelope path ${JSON.stringify(text)} must be absolute: write its canonical path, with no symlink, "." or ".." in it`);
|
|
296
296
|
let real;
|
|
@@ -304,6 +304,8 @@ function canonicalEnvelopePath(path, realpath, statSync) {
|
|
|
304
304
|
}
|
|
305
305
|
const stat = statSync(text, { bigint: true });
|
|
306
306
|
// Before the link count: a directory's nlink counts its subdirectories, so "has 3 hard links" would misname it.
|
|
307
|
+
// `directory`: the console's device store is a folder, judged by the same identity rules. A folder has no link count to speak of.
|
|
308
|
+
if (directory && stat.isDirectory()) return text;
|
|
307
309
|
if (!stat.isFile()) {
|
|
308
310
|
throw configError(`the envelope path ${JSON.stringify(text)} is not a regular file; point it at the envelope file itself`);
|
|
309
311
|
}
|
|
@@ -366,8 +368,8 @@ export function fileIdentity(stat) {
|
|
|
366
368
|
* relative, and when a path cannot be read for a reason other than its absence. `realpathSync`, `statSync`, `cwd` and
|
|
367
369
|
* `env` are injectable for tests.
|
|
368
370
|
*/
|
|
369
|
-
export function envelopeInsideJobPaths(envelopePath, { cronFolders = [], runRoots = [], skillsDirs = [], globalPiDir = null } = {}, { realpathSync = fsRealpathSync.native, statSync = fsStatSync, cwd = process.cwd(), env = process.env } = {}) {
|
|
370
|
-
const real = canonicalEnvelopePath(envelopePath, realpathSync, statSync);
|
|
371
|
+
export function envelopeInsideJobPaths(envelopePath, { cronFolders = [], runRoots = [], skillsDirs = [], globalPiDir = null } = {}, { realpathSync = fsRealpathSync.native, statSync = fsStatSync, cwd = process.cwd(), env = process.env, directory = false } = {}) {
|
|
372
|
+
const real = canonicalEnvelopePath(envelopePath, realpathSync, statSync, directory);
|
|
371
373
|
// A relative run root is refused under an envelope, as `PI_GLOBAL_PI_DIR` and `run.skillsDir` already must be
|
|
372
374
|
// absolute: the worker would judge it against its own working directory, while the admin's `dispatch_run` resolves
|
|
373
375
|
// it in the operator's pi process, so the two could mean different folders.
|
package/src/mcp-config.mjs
CHANGED
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
* name or password. pi's own resolver would run a value starting with `!` and expand bare `$NAME`; the job resolves
|
|
18
18
|
* the references itself, never pi.
|
|
19
19
|
*
|
|
20
|
-
* Accepted is a subset of pi 1.1.0's schema (core/mcp-servers.js `validateMcpServerConfig
|
|
20
|
+
* Accepted is a subset of pi 1.1.0's schema (core/mcp-servers.js `validateMcpServerConfig`; since 1.5.2 pi-mcp's
|
|
21
|
+
* dist/config.js checks the fields MCP clients share and core/mcp-servers.js adds exposure and `auth`, accepting the
|
|
22
|
+
* same entries as before), strict on keys: a key
|
|
21
23
|
* pi-dispatch does not know is refused, so a key a later pi adds is read before a job relies on it. Refused outright:
|
|
22
24
|
* `auth` and `oauth` (a credential flow the container cannot finish, and a token file it must not hold), `cwd` (a job
|
|
23
25
|
* runs its servers in the checkout), and the legacy `sse` type, which pi refuses too. `allowPackageRunner` is
|
package/src/models-json.mjs
CHANGED
|
@@ -154,6 +154,8 @@ const SamplingParams = T.rec(T.unknown());
|
|
|
154
154
|
const SamplingParamsByThinkingLevel = T.obj({ off: opt(SamplingParams), minimal: opt(SamplingParams), low: opt(SamplingParams), medium: opt(SamplingParams), high: opt(SamplingParams), xhigh: opt(SamplingParams), max: opt(SamplingParams) });
|
|
155
155
|
const KwargValue = T.union(T.str(), T.num(), T.bool(), T.nul(), T.obj({ $var: req(T.union(T.lit("thinking.enabled"), T.lit("thinking.effort"), T.lit("thinking.budget"))), omitWhenOff: opt(T.bool()) }));
|
|
156
156
|
const affinity = T.union(T.lit("openai"), T.lit("openai-nosession"), T.lit("openrouter"));
|
|
157
|
+
// pi 1.5.2: the prompt_cache_key switch, the same three values in the completions, responses and generic schemas.
|
|
158
|
+
const cacheKeyMode = T.union(T.lit("auto"), T.lit("enabled"), T.lit("disabled"));
|
|
157
159
|
const costRates = { input: req(T.num()), output: req(T.num()), cacheRead: req(T.num()), cacheWrite: req(T.num()) };
|
|
158
160
|
const ModelCostTier = T.obj({ inputTokensAbove: req(T.num()), ...costRates });
|
|
159
161
|
const ModelCost = T.obj({ ...costRates, tiers: opt(T.arr(ModelCostTier)) });
|
|
@@ -164,7 +166,8 @@ const ImageResize = T.obj({ maxWidth: opt(posInt), maxHeight: opt(posInt), maxBy
|
|
|
164
166
|
const ModelInputLimits = T.obj({ maxRequestBytes: opt(posInt), images: opt(T.obj({ resize: opt(ImageResize), maxPerMessage: opt(posInt), maxPerRequest: opt(posInt) })) });
|
|
165
167
|
// compat-schema.js's ProviderCompatSchema: the merged properties of the OpenAI completions, OpenAI responses, Anthropic
|
|
166
168
|
// messages, Bedrock, Mistral and System One schemas. A key two of them share takes pi's generic override, which for every shared
|
|
167
|
-
// key here is the same type as each api's own (a boolean,
|
|
169
|
+
// key here is the same type as each api's own (a boolean, the three-way affinity format, or 1.5.2's three-way
|
|
170
|
+
// promptCacheKeyMode), so the superset is exact.
|
|
168
171
|
const bool = opt(T.bool());
|
|
169
172
|
const ProviderCompat = T.obj({
|
|
170
173
|
// OpenAI completions
|
|
@@ -176,6 +179,8 @@ const ProviderCompat = T.obj({
|
|
|
176
179
|
maxTokensField: opt(T.union(T.lit("max_completion_tokens"), T.lit("max_tokens"))),
|
|
177
180
|
requiresToolResultName: bool,
|
|
178
181
|
requiresAssistantAfterToolResult: bool,
|
|
182
|
+
requiresNonNullAssistantContent: bool,
|
|
183
|
+
requiresToolsWithToolHistory: bool,
|
|
179
184
|
requiresThinkingAsText: bool,
|
|
180
185
|
requiresReasoningContentOnAssistantMessages: bool,
|
|
181
186
|
thinkingFormat: opt(T.union(...["openai", "openrouter", "deepseek", "together", "baseten", "zai", "qwen", "chat-template", "qwen-chat-template", "string-thinking", "ant-ling"].map((x) => T.lit(x)))),
|
|
@@ -193,6 +198,7 @@ const ProviderCompat = T.obj({
|
|
|
193
198
|
cacheControlFormat: opt(T.lit("anthropic")),
|
|
194
199
|
sendSessionAffinityHeaders: bool,
|
|
195
200
|
sessionAffinityFormat: opt(affinity),
|
|
201
|
+
promptCacheKeyMode: opt(cacheKeyMode),
|
|
196
202
|
supportsLongCacheRetention: bool,
|
|
197
203
|
vllmPriority: opt(T.num()),
|
|
198
204
|
// OpenAI responses
|
|
@@ -209,6 +215,7 @@ const ProviderCompat = T.obj({
|
|
|
209
215
|
supportsStrictTools: bool,
|
|
210
216
|
supportsMidConvoEffort: bool,
|
|
211
217
|
supportsMidConvoToolChanges: bool,
|
|
218
|
+
supportsMessageThreads: bool,
|
|
212
219
|
// System One classifier image transport, checked before pi reads the overlay.
|
|
213
220
|
imageInput: opt(T.union(T.lit("assets"), T.lit("images"), T.lit("state-image-urls"))),
|
|
214
221
|
sendImageOptInHeader: bool,
|