@edgehero/pi-dispatch 5.0.0 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/package.json +4 -3
- package/src/cli.mjs +8 -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.mjs +21 -3
- package/src/envelope.mjs +5 -3
- package/src/mcp-config.mjs +3 -1
- package/src/models-json.mjs +8 -1
- package/src/setup-host-entry.mjs +4 -1
- package/src/up.mjs +6 -4
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pi-dispatch console`: get me into the console (issue #849). One command to remember, whatever state things are in.
|
|
3
|
+
*
|
|
4
|
+
* The work is split by who owns what. This side owns the service: is the console answering, and if not, can it be started. The admin's
|
|
5
|
+
* `pi-dispatch-console open` owns the credential: which operator, the audit row, the one-time code and the browser. So a code is minted
|
|
6
|
+
* only by the package that has always minted one.
|
|
7
|
+
*
|
|
8
|
+
* It starts a service that is already installed and stopped, because that is what someone typing this wants, and starting one is not a
|
|
9
|
+
* new grant: the unit exists because they installed it. It never installs one, which is `up --console`'s decision and asks first.
|
|
10
|
+
*/
|
|
11
|
+
import * as nodeFs from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join, resolve } from "node:path";
|
|
14
|
+
import { parseArgs } from "node:util";
|
|
15
|
+
import { attachedCommand, commandsHere, waitForConsole } from "./console-bootstrap.mjs";
|
|
16
|
+
import { makeConsoleAudit } from "./console-audit.mjs";
|
|
17
|
+
import { consoleProbeTarget, probeConsole } from "./console-probe.mjs";
|
|
18
|
+
import { logsDirPath } from "./config.mjs";
|
|
19
|
+
import { findOnPath, installedConsoleUnitPath, resolveConsoleEntry, runService } from "./service.mjs";
|
|
20
|
+
import { deploymentServiceEnv } from "./service-env.mjs";
|
|
21
|
+
import { consoleTokensPathFor } from "./service.mjs";
|
|
22
|
+
import { readTokensFile, tokenNames } from "./console-tokens.mjs";
|
|
23
|
+
|
|
24
|
+
const SHELL = Object.freeze({ name: "shell", role: "shell", session: null });
|
|
25
|
+
|
|
26
|
+
const USAGE = "usage: pi-dispatch console [--name <name>] [--next /setup] [--port <n>] [--deployment <dir>]\n";
|
|
27
|
+
|
|
28
|
+
export async function runConsoleOpen(argv, deps = {}) {
|
|
29
|
+
const fs = deps.fs ?? nodeFs;
|
|
30
|
+
const env = deps.env ?? process.env;
|
|
31
|
+
const platform = deps.platform ?? process.platform;
|
|
32
|
+
const uid = deps.euid ?? process.geteuid?.();
|
|
33
|
+
const home = deps.home ?? homedir();
|
|
34
|
+
const out = deps.out ?? ((text) => process.stdout.write(text));
|
|
35
|
+
const refuse = (text) => { out(`pi-dispatch console: ${text}\n`); return 2; };
|
|
36
|
+
// Strict, as the console's own command is: a misread flag here would start a service or sign in the wrong person.
|
|
37
|
+
let values;
|
|
38
|
+
try { ({ values } = parseArgs({ args: argv, options: { deployment: { type: "string" }, name: { type: "string" }, next: { type: "string" }, port: { type: "string" }, help: { type: "boolean", short: "h" } } })); }
|
|
39
|
+
catch (e) { return refuse(`${e?.message ?? "the arguments could not be read"}; run pi-dispatch console --help`); }
|
|
40
|
+
if (values.help) { out(USAGE); return 0; }
|
|
41
|
+
// Free refusals first: a typo must not start a service and write a row before anyone finds out.
|
|
42
|
+
if (values.deployment === "") return refuse("--deployment needs a folder.");
|
|
43
|
+
if (values.next !== undefined && values.next !== "/setup") return refuse("--next must be /setup.");
|
|
44
|
+
if (values.port !== undefined && !(/^\d{1,5}$/.test(values.port) && Number(values.port) >= 1 && Number(values.port) <= 65535)) return refuse("--port must be a port number (1 to 65535).");
|
|
45
|
+
const realpath = fs.realpathSync;
|
|
46
|
+
const from = realpath(deps.cwd ?? process.cwd());
|
|
47
|
+
let dir;
|
|
48
|
+
try { dir = realpath(resolve(from, values.deployment ?? ".")); }
|
|
49
|
+
catch { return refuse("that deployment folder does not exist."); }
|
|
50
|
+
const here = commandsHere(dir, fs, { from });
|
|
51
|
+
if (!fs.existsSync(join(dir, ".env"))) return refuse(values.deployment === undefined ? "this folder is not a deployment. Run this from your deployment folder, or pass --deployment <dir>." : "that folder is not a deployment: it has no .env.");
|
|
52
|
+
// Read whatever `--name` says: a file the admin would refuse, or a choice it could not make, is known now, before a service starts.
|
|
53
|
+
let tokens = null;
|
|
54
|
+
try { tokens = (deps.readTokens ?? readTokensFile)({ path: (deps.consoleTokensPath ?? consoleTokensPathFor)(dir, platform), fs, uid }); } catch { /* the admin's own read says what is wrong */ }
|
|
55
|
+
if (tokens?.refusal) return refuse(`the console tokens file cannot be used: ${tokens.refusal}. Run ${here.worker} doctor.`);
|
|
56
|
+
if (tokens?.file) {
|
|
57
|
+
// `tokenNames`: an entry kept for a role this build does not know is no missing name, and the admin explains it (issue #761).
|
|
58
|
+
if (values.name !== undefined && !tokenNames(tokens.file).includes(values.name)) return refuse(`there is no token named "${values.name}" in this deployment.`);
|
|
59
|
+
const operators = tokens.file.tokens.filter((entry) => entry.role === "operator").length;
|
|
60
|
+
if (values.name === undefined && operators !== 1) return refuse(operators === 0 ? `there is no operator token yet. Run ${here.worker} up --console.` : `there are ${operators} operator tokens, so name one with --name <name>.`);
|
|
61
|
+
}
|
|
62
|
+
// env-internal PATH: find the installed admin binary, as `up --console` does.
|
|
63
|
+
const consoleEntry = (deps.resolveConsole ?? (() => resolveConsoleEntry({ findBin: (name) => findOnPath(name, env.PATH), realpath })))();
|
|
64
|
+
if (!consoleEntry) return refuse(`the console package is not installed here. Run ${here.worker} up --console in your deployment folder to set it up.`);
|
|
65
|
+
|
|
66
|
+
// PI_LOGS_DIR too: the service start's row and the sign-in's row must land in one audit chain.
|
|
67
|
+
const shellConsoleKeys = Object.keys(env).filter((key) => key.startsWith("PI_CONSOLE_") || key === "PI_LOGS_DIR");
|
|
68
|
+
const read = deploymentServiceEnv({ env: {}, dir, keys: [...new Set(["PI_CONSOLE_LISTEN", "PI_LOGS_DIR", ...shellConsoleKeys])], fs, uid, platform });
|
|
69
|
+
if (read.untrusted || (read.unreadable && read.unreadable !== "ENOENT")) return refuse(`this deployment's .env is unsafe or cannot be read. Run ${here.worker} doctor in this folder.`);
|
|
70
|
+
// The admin's `open` reads this shell's values first; the probe and the service read the file. Two answers would put the link, or the
|
|
71
|
+
// code, somewhere the running console never looks.
|
|
72
|
+
if (shellConsoleKeys.some((key) => env[key] !== read.env[key])) return refuse("console settings in this shell differ from the deployment's .env. Unset them here.");
|
|
73
|
+
const target = consoleProbeTarget(read.env.PI_CONSOLE_LISTEN);
|
|
74
|
+
if (target.unknown) return refuse("the console has no concrete local address (PI_CONSOLE_LISTEN), so no link could reach it.");
|
|
75
|
+
|
|
76
|
+
const probe = deps.probeConsole ?? probeConsole;
|
|
77
|
+
if ((await probe(read.env)).state !== "running") {
|
|
78
|
+
// This deployment's own unit, never just any console unit on the account: another's could carry a `--host-setup` grant.
|
|
79
|
+
if ((deps.consoleUnit ?? installedConsoleUnitPath)({ platform, home, cwd: dir }) === null) return refuse(`the console is not running, and no service for this deployment is installed. Run ${here.worker} up --console to set it up, or ${here.console} serve to run it in this terminal.`);
|
|
80
|
+
out("The console is not running. Starting its service.\n");
|
|
81
|
+
const audit = deps.audit ?? makeConsoleAudit({ logsDir: resolve(dir, logsDirPath(read.env)), fs, now: deps.now ?? Date.now });
|
|
82
|
+
const row = { actor: SHELL, action: "write", kind: "setup-console-start", target: "console", outcome: "ok" };
|
|
83
|
+
try { audit.append(row); } catch { return refuse(`the audit log could not be written, so the console service was not started. Run ${here.worker} doctor.`); }
|
|
84
|
+
const started = await (deps.runServiceFn ?? runService)(["start", "--console"], { cwd: dir, env, platform, euid: uid, home, fs, resolveConsole: () => consoleEntry });
|
|
85
|
+
if (started !== 0) {
|
|
86
|
+
try { audit.append({ ...row, outcome: "failed", reason: "console-start-refused" }); } catch { /* the refusal below is what the person must see */ }
|
|
87
|
+
return refuse(`the console service did not start. ${here.worker} service status --console says why.`);
|
|
88
|
+
}
|
|
89
|
+
const ready = await waitForConsole(read.env, { probe, now: deps.now ?? Date.now, ms: deps.consoleWaitMs ?? 15_000, ...(deps.sleep ? { sleep: deps.sleep } : {}) });
|
|
90
|
+
if (!ready) return refuse(`the console service started but did not answer in time. ${here.worker} service status --console says why.`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const passed = [];
|
|
94
|
+
for (const flag of ["name", "next", "port"]) if (values[flag] !== undefined) passed.push(`--${flag}`, values[flag]);
|
|
95
|
+
// A unix socket has no port of its own; 4790 is the local end of the tunnel the printed `ssh -L` line sets up.
|
|
96
|
+
if (target.kind === "unix" && values.port === undefined) passed.push("--port", "4790");
|
|
97
|
+
const code = await (deps.consoleOpen ?? attachedCommand)(process.execPath, [consoleEntry, "open", "--deployment", dir, ...passed], { cwd: dir, env });
|
|
98
|
+
return typeof code === "number" ? code : refuse("the console command could not be started.");
|
|
99
|
+
}
|
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";
|
|
@@ -5552,6 +5553,16 @@ export function consoleChecks(env, { cwd = process.cwd(), fs = nodeFs, uid = pro
|
|
|
5552
5553
|
} else if (operators > 0) {
|
|
5553
5554
|
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
5555
|
}
|
|
5556
|
+
// Issue #849: the remembered devices, which sit beside the tokens file and are as sensitive as it is about who may sign in.
|
|
5557
|
+
const devicesDir = consoleDevicesDir(path);
|
|
5558
|
+
const devices = devicesState({ dir: devicesDir, fs, uid, platform });
|
|
5559
|
+
if (devices.problem) {
|
|
5560
|
+
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) });
|
|
5561
|
+
} else if (!devices.absent) {
|
|
5562
|
+
const devicesInside = consoleTokensInsideJobPaths(devicesDir, env, { cwd, fs, folders, skillsDirs, realpath, directory: true });
|
|
5563
|
+
if (devicesInside.problem) checks.push({ ok: false, label: `console: the remembered-device folder ${quotedShown(devicesDir)} ${devicesInside.problem}`, fix: devicesInside.fix });
|
|
5564
|
+
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` });
|
|
5565
|
+
}
|
|
5555
5566
|
const kept = keptTokens(read.file).length;
|
|
5556
5567
|
if (kept > 0) {
|
|
5557
5568
|
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 +5578,11 @@ export function consoleChecks(env, { cwd = process.cwd(), fs = nodeFs, uid = pro
|
|
|
5567
5578
|
} else {
|
|
5568
5579
|
checks.push({ ok: true, label: `console: listens on ${escapeControls(listen.shown)}` });
|
|
5569
5580
|
}
|
|
5581
|
+
// 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.
|
|
5582
|
+
if (bindPublic && !listen.refusal && !listen.loopback) {
|
|
5583
|
+
const devices = devicesState({ dir: consoleDevicesDir(path), fs, uid, platform });
|
|
5584
|
+
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" });
|
|
5585
|
+
}
|
|
5570
5586
|
if (bindPublic) {
|
|
5571
5587
|
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
5588
|
}
|
|
@@ -5653,17 +5669,19 @@ export async function consoleServiceChecks(env, seams = {}) {
|
|
|
5653
5669
|
* relative run root is skipped here (the envelope refuses one; the console has no such rule to borrow), and a path
|
|
5654
5670
|
* that cannot be judged is said, not passed.
|
|
5655
5671
|
*/
|
|
5656
|
-
function consoleTokensInsideJobPaths(path, env, { cwd, fs, folders, skillsDirs, realpath }) {
|
|
5672
|
+
function consoleTokensInsideJobPaths(path, env, { cwd, fs, folders, skillsDirs, realpath, directory = false }) {
|
|
5657
5673
|
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
5674
|
try {
|
|
5659
5675
|
const st = fs.statSync(path);
|
|
5660
|
-
|
|
5676
|
+
// A folder always has more than one link (its own name, `.`, and each child's `..`), so the count says nothing about one.
|
|
5677
|
+
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
5678
|
const real = realpath(path);
|
|
5662
5679
|
const runRoots = delimitedList(env.PI_DISPATCH_RUN_ROOTS).filter((r) => isAbsolute(r));
|
|
5663
5680
|
const globalPiDir = typeof env.PI_GLOBAL_PI_DIR === "string" && env.PI_GLOBAL_PI_DIR !== "" ? env.PI_GLOBAL_PI_DIR : null;
|
|
5664
5681
|
// 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 });
|
|
5682
|
+
const inside = envelopeInsideJobPaths(real, { cronFolders: folders, runRoots, skillsDirs, globalPiDir }, { cwd, directory });
|
|
5666
5683
|
if (inside === null) return {};
|
|
5684
|
+
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
5685
|
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
5686
|
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
5687
|
} catch (err) {
|
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,
|
package/src/setup-host-entry.mjs
CHANGED
|
@@ -4,7 +4,10 @@ import { dirname, join } from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
6
|
export function setupCachePath(path) {
|
|
7
|
-
|
|
7
|
+
// pnpm's, yarn's and bun's temporary stores are as disposable as npm's own. Matched by their whole shape, because a folder someone named
|
|
8
|
+
// `dlx` is an ordinary folder.
|
|
9
|
+
const text = String(path);
|
|
10
|
+
return /(?:^|[/\\])(?:_npx|_cacache|npm-cache|\.cache)(?:[/\\]|$)/.test(text) || /(?:^|[/\\])pnpm[/\\]dlx(?:[/\\]|$)/.test(text) || /(?:^|[/\\])xfs-[^/\\]+[/\\]dlx-\d+(?:[/\\]|$)/.test(text) || /(?:^|[/\\])bunx-\d+-[^/\\]+(?:[/\\]|$)/.test(text) || /(?:^|[/\\])\.yarn[/\\]berry[/\\]cache(?:[/\\]|$)/.test(text);
|
|
8
11
|
}
|
|
9
12
|
export function trustedSetupFile(entry, { uid = process.geteuid?.(), realpath = realpathSync, stat = statSync } = {}) {
|
|
10
13
|
try {
|
package/src/up.mjs
CHANGED
|
@@ -1155,7 +1155,9 @@ export async function runUp(argv = [], deps = {}) {
|
|
|
1155
1155
|
for (const [name, note] of summary) {
|
|
1156
1156
|
out(` ${name.padEnd(21)} ${note}\n`);
|
|
1157
1157
|
}
|
|
1158
|
-
|
|
1158
|
+
// `up --console` goes on to /setup, whose checklist is the next step, and these bare commands are not on a local install's PATH.
|
|
1159
|
+
const nextOut = deps.skipNextSteps ? () => {} : out;
|
|
1160
|
+
nextOut(`
|
|
1159
1161
|
Next:
|
|
1160
1162
|
edit ${envPath}
|
|
1161
1163
|
set ANTHROPIC_API_KEY (or your provider's key) — already logged into pi? leave it blank
|
|
@@ -1167,7 +1169,7 @@ Next:
|
|
|
1167
1169
|
// A worker run BY HAND reads only its shell, so on the podman venue it needs the list exported; the service reads
|
|
1168
1170
|
// `.env` itself. Printed only there, so every other deployment's closing text is unchanged.
|
|
1169
1171
|
if (venues.podmanUsed) {
|
|
1170
|
-
|
|
1172
|
+
nextOut(" (podman venue: a worker started by hand reads only its shell, so export the same PI_BACKENDS there first, or run it as a service with `pi-dispatch service install`, which reads .env)\n");
|
|
1171
1173
|
}
|
|
1172
1174
|
// Issue #708: the console as a service, offered only where it could start (an operator token exists): without one,
|
|
1173
1175
|
// `service install --console` refuses, and a "Next:" line that leads to a refusal is not a next step.
|
|
@@ -1175,10 +1177,10 @@ Next:
|
|
|
1175
1177
|
try {
|
|
1176
1178
|
consoleReady = consoleOperators(cwd) > 0 && !consoleUnitInstalled(cwd);
|
|
1177
1179
|
} catch {}
|
|
1178
|
-
if (consoleReady)
|
|
1180
|
+
if (consoleReady) nextOut(" pi-dispatch service install --console\n keep the operator console running as a service (open it with /dispatch console)\n");
|
|
1179
1181
|
// Issue #707: the openshell venues' own next step, said only where one is blessed.
|
|
1180
1182
|
if (openshell.used) {
|
|
1181
|
-
|
|
1183
|
+
nextOut(` pi-dispatch service install\n the worker as a ${platform === "darwin" ? "LaunchAgent (the gateway runs in this login session: `brew services start openshell`, the service of OpenShell's release formula, keeps it there)" : `user service that pulls in the gateway's own unit (${OPENSHELL_GATEWAY_UNIT})`}; then \`pi-dispatch doctor\` reads the gateway, its engine and the image back. A worker started by hand reads only its shell, so export the same PI_BACKENDS (and PI_OPENSHELL_ENGINE) there first\n`);
|
|
1182
1184
|
}
|
|
1183
1185
|
// Issue #464: a Valkey this pass refused (another account's, or one no one could name) is a failed `up` whatever doctor
|
|
1184
1186
|
// says: a worker started now would take that queue's jobs, and an exit 0 read by a script or the wizard says ready.
|