|
| 1 | +/** |
| 2 | + * The deployment POINTER (INT-DEPLOYMENT-POINTER-CONTRACT, issue #92): the one file that lets the admin's |
| 3 | + * `/dispatch` find a deployment the setup wizard built somewhere else. `resolvePaths` (read-model.mjs) is |
| 4 | + * env-only BY CONTRACT and stays untouched -- a wizard-built deployment in `~/pi-dispatch` is invisible from |
| 5 | + * any other cwd -- so this module layers the pointer's entries into `process.env` ONCE at extension load. |
| 6 | + * Env always wins, key by key; with the layering in place, every `resolvePaths(process.env)` call site |
| 7 | + * (every command and every LLM tool) is covered with zero signature changes. |
| 8 | + * |
| 9 | + * The contract in one line: **the pointer resolves paths only, never credentials, never capability |
| 10 | + * grants.** The read side enforces that with an allowlist (`POINTER_ENV_ALLOWLIST`) rather than a |
| 11 | + * blocklist: anything else in `env` is DROPPED silently (the operator-file unknown-fields-dropped |
| 12 | + * policy) -- most pointedly `PI_DISPATCH_RUN_ROOTS`, because a pointer that could widen the AI-run |
| 13 | + * folder allowlist would be a second, unreviewed door to a capability the panel deliberately gates |
| 14 | + * behind the operator's own env; and any credential-shaped key, because secrets travel through the |
| 15 | + * operator's env and the token broker, never through a file the wizard writes world-readable. |
| 16 | + * |
| 17 | + * Failure doctrine: a broken pointer must leave `/dispatch` exactly as functional as before the pointer |
| 18 | + * existed (env then cwd defaults). So `readPointer` NEVER throws -- unparseable JSON, a non-object, a |
| 19 | + * missing/invalid version, a newer version all come back as `{ ignored: reason }` -- and the apply path |
| 20 | + * retains a one-line notice for the next `/dispatch` to surface (the REBUILT_NOTICE idiom: a surfaced |
| 21 | + * warning, never a throw). This is deliberately weaker than the subscriptions file's loud refusal; the |
| 22 | + * reconciliation is recorded in the spec: the pointer is an availability aid, not a data file. |
| 23 | + * |
| 24 | + * Staleness is the wizard's problem, not the reader's: `readPointer` never stats `deploymentDir` or any |
| 25 | + * env value. The reader is pure over the file text plus one `fs.readFileSync`, so it cannot slow a |
| 26 | + * session down or invent a second existence check that disagrees with the wizard's. |
| 27 | + */ |
| 28 | + |
| 29 | +import * as nodeFs from "node:fs"; |
| 30 | +import { homedir } from "node:os"; |
| 31 | +import { join, isAbsolute } from "node:path"; |
| 32 | + |
| 33 | +/** The pointer schema version this build understands. A NEWER file is ignored with a notice, never guessed at. */ |
| 34 | +export const POINTER_VERSION = 1; |
| 35 | + |
| 36 | +/** |
| 37 | + * The only env keys a pointer may set -- the path/URL variables `resolvePaths` reads to find a deployment's |
| 38 | + * files. An allowlist, not a blocklist: a new resolvePaths variable gets pointer coverage only by being |
| 39 | + * added HERE, on review. `PI_DISPATCH_RUN_ROOTS` is absent on purpose (a capability grant, not a path), |
| 40 | + * as is every credential-shaped key (the pointer never carries secrets). |
| 41 | + */ |
| 42 | +export const POINTER_ENV_ALLOWLIST = Object.freeze([ |
| 43 | + "VALKEY_URL", |
| 44 | + "PI_LOGS_DIR", |
| 45 | + "PI_SETTINGS_FILE", |
| 46 | + "PI_TRIGGERS_FILE", |
| 47 | + "PI_PAUSE_WINDOWS_FILE", |
| 48 | + "PI_SUBSCRIPTIONS_FILE", |
| 49 | +]); |
| 50 | + |
| 51 | +const POINTER_BASENAME = "pi-dispatch-deployment.json"; |
| 52 | + |
| 53 | +// ---- module state: the once-per-process memo, the retained notice, and the owned-key ledger ---- |
| 54 | + |
| 55 | +// Whether the process-wide layering has happened. applyDeploymentPointer is called from the extension |
| 56 | +// factory, which pi may in principle evaluate more than once; the memo makes every call after the first |
| 57 | +// a no-op so the layering result cannot depend on load order or count. |
| 58 | +let appliedOnce = false; |
| 59 | + |
| 60 | +// The retained one-line notice from an ignored pointer, surfaced once by the next /dispatch. A single |
| 61 | +// slot, latest-wins: there is one pointer file, so there is at most one thing to say about it. |
| 62 | +let notice; |
| 63 | + |
| 64 | +// The keys THIS MODULE wrote into the env, and therefore the only keys a re-apply may update. A var the |
| 65 | +// operator exported is never in here, so it can never be overwritten -- not even by the wizard's own |
| 66 | +// re-apply after it rewrites the pointer. |
| 67 | +const ownedKeys = new Set(); |
| 68 | + |
| 69 | +/** |
| 70 | + * Where the pointer lives: `PI_DISPATCH_DEPLOYMENT_FILE`, defaulting to pi's own agent dir |
| 71 | + * (`PI_CODING_AGENT_DIR` or `~/.pi/agent` -- the repo's one established home-dir pattern, resolved the |
| 72 | + * way worker/src/import-pi.mjs does). `||` on both reads so an EMPTY string falls back like an unset one, |
| 73 | + * matching resolvePaths' own defaulting idiom. |
| 74 | + */ |
| 75 | +export function pointerPath(env = process.env) { |
| 76 | + return ( |
| 77 | + env.PI_DISPATCH_DEPLOYMENT_FILE || |
| 78 | + join(env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"), POINTER_BASENAME) |
| 79 | + ); |
| 80 | +} |
| 81 | + |
| 82 | +/** |
| 83 | + * Validate + filter one raw parsed pointer into the canonical `{ version, deploymentDir, env }` shape. |
| 84 | + * The single normalizer for BOTH sides: `readPointer` runs every file through it, and `writePointer` |
| 85 | + * runs every candidate through it before writing -- so a pointer that would be ignored on read is never |
| 86 | + * written, and a disallowed env key can neither be applied NOR persisted through this API. |
| 87 | + * |
| 88 | + * Returns `{ pointer }` or `{ ignored: reason }`. Never throws. |
| 89 | + */ |
| 90 | +function normalizePointer(raw) { |
| 91 | + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { |
| 92 | + return { ignored: "not a JSON object" }; |
| 93 | + } |
| 94 | + // version is the one field that cannot be retrofitted: required, integer >= 1. A NEWER version means a |
| 95 | + // future wizard wrote a shape this build does not understand -- ignore the whole file (degrading to the |
| 96 | + // pre-pointer behavior) rather than half-read it, and name both versions so the operator knows which |
| 97 | + // side to upgrade. |
| 98 | + if (!Number.isInteger(raw.version) || raw.version < 1) { |
| 99 | + return { ignored: "missing or invalid version (integer >= 1 required)" }; |
| 100 | + } |
| 101 | + if (raw.version > POINTER_VERSION) { |
| 102 | + return { ignored: `version ${raw.version} is newer than the supported version ${POINTER_VERSION}` }; |
| 103 | + } |
| 104 | + if (typeof raw.deploymentDir !== "string" || !isAbsolute(raw.deploymentDir)) { |
| 105 | + return { ignored: "deploymentDir must be an absolute path" }; |
| 106 | + } |
| 107 | + // An absent env map reads as {} (a pointer may name only the deployment dir); a PRESENT non-object env |
| 108 | + // is a malformed file and ignored whole -- silently "fixing" it would mask a hand-edit gone wrong. |
| 109 | + const rawEnv = raw.env === undefined ? {} : raw.env; |
| 110 | + if (rawEnv === null || typeof rawEnv !== "object" || Array.isArray(rawEnv)) { |
| 111 | + return { ignored: "env must be an object" }; |
| 112 | + } |
| 113 | + const env = {}; |
| 114 | + // Iterating the ALLOWLIST (not the file's keys) is what makes every unknown key -- run roots, |
| 115 | + // credentials, typos -- vanish without a branch to forget. Values must be non-empty strings; and every |
| 116 | + // path value must be ABSOLUTE, because a relative path would resolve against whichever session's cwd |
| 117 | + // happens to read it -- silently wrong in exactly the cross-directory case the pointer exists to fix. |
| 118 | + // VALKEY_URL is a URL, not a filesystem path, so it is exempt from the absoluteness gate only. |
| 119 | + for (const key of POINTER_ENV_ALLOWLIST) { |
| 120 | + const value = rawEnv[key]; |
| 121 | + if (typeof value !== "string" || value === "") continue; |
| 122 | + if (key !== "VALKEY_URL" && !isAbsolute(value)) continue; |
| 123 | + env[key] = value; |
| 124 | + } |
| 125 | + // Unknown TOP-LEVEL fields drop here too: only the three canonical fields are ever kept or re-written. |
| 126 | + return { pointer: { version: raw.version, deploymentDir: raw.deploymentDir, env } }; |
| 127 | +} |
| 128 | + |
| 129 | +/** |
| 130 | + * Read and normalize the pointer file. Returns exactly one of: |
| 131 | + * `{ pointer }` -- a valid pointer, env already allowlist-filtered |
| 132 | + * `{ ignored: reason }` -- a file that exists but cannot be honored (never a throw) |
| 133 | + * `{ absent: true }` -- no file: the normal pre-wizard state, not an error |
| 134 | + * |
| 135 | + * Pure over the file text + `fs.readFileSync`: no stat of `deploymentDir`, no module-state mutation |
| 136 | + * (notices are retained by the APPLY path, not here) -- stale-deployment detection is the wizard's job. |
| 137 | + */ |
| 138 | +export function readPointer({ path = pointerPath(), fs = nodeFs } = {}) { |
| 139 | + let text; |
| 140 | + try { |
| 141 | + text = fs.readFileSync(path, "utf8"); |
| 142 | + } catch (err) { |
| 143 | + if (err && err.code === "ENOENT") return { absent: true }; |
| 144 | + // Any other read failure (permissions, EISDIR, ...) degrades like a malformed file: ignored, named. |
| 145 | + return { ignored: `unreadable: ${err?.message ?? err}` }; |
| 146 | + } |
| 147 | + let raw; |
| 148 | + try { |
| 149 | + raw = JSON.parse(text); |
| 150 | + } catch (err) { |
| 151 | + return { ignored: `unparseable JSON: ${err.message}` }; |
| 152 | + } |
| 153 | + return normalizePointer(raw); |
| 154 | +} |
| 155 | + |
| 156 | +/** |
| 157 | + * The shared layering step for apply/re-apply. A key is written only when the pointer may govern it: |
| 158 | + * either nobody set it (`undefined` -- and note that an operator's EMPTY export counts as set, because |
| 159 | + * exporting `PI_LOGS_DIR=""` is still operator intent this module must not second-guess), or this module |
| 160 | + * itself set it on an earlier pass (`ownedKeys`). An ignored pointer retains the one-line notice and |
| 161 | + * applies nothing. |
| 162 | + */ |
| 163 | +function layerPointer(env, fs) { |
| 164 | + const path = pointerPath(env); |
| 165 | + const res = readPointer({ path, fs }); |
| 166 | + if (res.ignored) { |
| 167 | + notice = `deployment pointer ignored: ${res.ignored} (${path})`; |
| 168 | + return { applied: [] }; |
| 169 | + } |
| 170 | + if (res.absent) return { applied: [] }; |
| 171 | + const applied = []; |
| 172 | + for (const [key, value] of Object.entries(res.pointer.env)) { |
| 173 | + if (env[key] !== undefined && !ownedKeys.has(key)) continue; // the operator's export always wins |
| 174 | + env[key] = value; |
| 175 | + ownedKeys.add(key); |
| 176 | + applied.push(key); |
| 177 | + } |
| 178 | + return { applied }; |
| 179 | +} |
| 180 | + |
| 181 | +/** |
| 182 | + * Layer the pointer's allowed, absolute env entries into `env` (default `process.env`), once per process. |
| 183 | + * Returns `{ applied: [keys] }` (informational; empty on memo hit / absent / ignored). |
| 184 | + * |
| 185 | + * Called at the TOP of the extension factory on purpose: `resolvePaths(process.env)` runs per-command AND |
| 186 | + * per LLM tool call, so factory-time layering is in place before any resolve -- including for an operator |
| 187 | + * who never types `/dispatch` and only lets the model call `dispatch_status`. Memoized so a second factory |
| 188 | + * evaluation cannot re-read the file mid-session; the ONLY refresh path is `reapplyDeploymentPointer`, |
| 189 | + * which the wizard calls deliberately after rewriting the file. |
| 190 | + */ |
| 191 | +export function applyDeploymentPointer(env = process.env, { fs = nodeFs } = {}) { |
| 192 | + if (appliedOnce) return { applied: [] }; |
| 193 | + appliedOnce = true; |
| 194 | + return layerPointer(env, fs); |
| 195 | +} |
| 196 | + |
| 197 | +/** |
| 198 | + * Re-read the pointer and refresh the layering -- the wizard's post-write hook, so a just-built deployment |
| 199 | + * takes effect in the SAME pi session without a restart. Same signature and return as apply, but it skips |
| 200 | + * the memo. Safety is unchanged: it may set a still-unset key or update a key THIS MODULE set earlier, |
| 201 | + * and never one the operator exported (even one exported after the first apply). A key the module owns |
| 202 | + * that a rewritten pointer no longer carries keeps its last applied value until process restart -- the |
| 203 | + * re-apply updates, it never unsets, so a half-typed pointer edit cannot yank paths out from under a |
| 204 | + * live panel. |
| 205 | + */ |
| 206 | +export function reapplyDeploymentPointer(env = process.env, { fs = nodeFs } = {}) { |
| 207 | + appliedOnce = true; // a later plain apply stays a no-op; this call IS the refresh |
| 208 | + return layerPointer(env, fs); |
| 209 | +} |
| 210 | + |
| 211 | +/** |
| 212 | + * Return the retained one-line notice once, then clear it -- undefined when there is nothing to say. |
| 213 | + * The `/dispatch` handler drains this into `notify(..., "warning")`, which is the pointer's entire error |
| 214 | + * surface: one line, once, never a throw. |
| 215 | + */ |
| 216 | +export function takePointerNotice() { |
| 217 | + const n = notice; |
| 218 | + notice = undefined; |
| 219 | + return n; |
| 220 | +} |
| 221 | + |
| 222 | +/** |
| 223 | + * Write a pointer for the wizard: validated through the SAME normalizer as the read side (a pointer that |
| 224 | + * would be ignored on read is refused here with `{ invalid: reason }` and the file is untouched), then |
| 225 | + * written atomically (tmp + rename, the repo's write idiom) as 2-space JSON with a trailing newline -- |
| 226 | + * hand-editable thereafter. Note it persists the NORMALIZED shape: disallowed env keys and relative path |
| 227 | + * values are dropped before the bytes exist, so this API cannot be used to smuggle a credential or a |
| 228 | + * capability grant into the file. Returns `{ ok: true }` on success. |
| 229 | + */ |
| 230 | +export function writePointer({ path = pointerPath(), pointer, fs = nodeFs } = {}) { |
| 231 | + const res = normalizePointer(pointer); |
| 232 | + if (res.ignored) return { invalid: res.ignored }; |
| 233 | + const tmp = `${path}.tmp`; |
| 234 | + fs.writeFileSync(tmp, `${JSON.stringify(res.pointer, null, 2)}\n`); |
| 235 | + fs.renameSync(tmp, path); |
| 236 | + return { ok: true }; |
| 237 | +} |
| 238 | + |
| 239 | +/** |
| 240 | + * TEST-ONLY: clear the process-wide memo, the retained notice, and the owned-key ledger, so each test |
| 241 | + * case starts from the fresh-process state. Named to make any production call site an obvious review |
| 242 | + * failure; production code has no reason to reset -- the memo being process-wide is the point. |
| 243 | + */ |
| 244 | +export function resetForTests() { |
| 245 | + appliedOnce = false; |
| 246 | + notice = undefined; |
| 247 | + ownedKeys.clear(); |
| 248 | +} |
0 commit comments