UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

252 lines • 12 kB
/** * Shared snapshot bookkeeping for the JSON-file configurators. * * Every writer that edits a user's config in place has to answer one question * on each apply(): is the value sitting in the file right now the one *we* put * there, or one the user put there? * * Snapshotting only on first touch is not enough. The sentinel is persisted in * the user's file so a restore survives a crash, which means it also survives * an unclean kill where no restore ever ran. If the user then edits the block * by hand — reasonably, since the proxy is gone — a presence-only guard keeps * the stale snapshot, apply() overwrites their edit, and the next restore * writes the stale value back over it. For Qwen that value is a live API key. * * So each writer also records what it wrote. A current value that still matches * the recorded write is ours and the snapshot stands; anything else is the * user's and must be re-snapshotted before we overwrite it. */ /** * Structural equality, insensitive to object key order. * * Comparing serialised JSON would be simpler but wrong: `JSON.stringify` is not * canonical, so anything that rewrites the user's config — a formatter, an * editor's "sort keys", another tool round-tripping the file — reorders keys and * makes our own block look like the user's. The writer would then snapshot the * proxy block as the "original" and restore it over the real config later. * * `undefined` is only ever equal to itself: absent is not the same as present. */ function valuesMatch(current, written) { if (current === undefined || written === undefined) { return current === written; } if (current === null || written === null) { return current === written; } if (typeof current !== "object" || typeof written !== "object") { return current === written; } if (Array.isArray(current) || Array.isArray(written)) { if (!Array.isArray(current) || !Array.isArray(written)) { return false; } return (current.length === written.length && current.every((item, index) => valuesMatch(item, written[index]))); } const a = current; const b = written; const aKeys = Object.keys(a); if (aKeys.length !== Object.keys(b).length) { return false; } return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && valuesMatch(a[key], b[key])); } /** * Whether apply() should record `current` as the user's original value. * * - No snapshot yet: record one. This is the first touch. * - Snapshot present but no record of what we wrote: leave it alone. The file * was written by a version that predates the write-record, so we cannot tell * ours from the user's and the safe move is the old behaviour. * - Snapshot present and the current value is exactly what we wrote: leave it * alone. This is the repeat-apply case the first-touch guard exists for. * - Snapshot present and the current value is *not* what we wrote: re-record. * The user replaced it while the proxy was not running. */ export function shouldCaptureSnapshot(args) { if (!args.hasSnapshot) { return true; } if (args.written === undefined) { return false; } return !valuesMatch(args.current, args.written); } /** * Whether a decoded snapshot file is structurally usable. * * A snapshot on disk is not necessarily one we wrote: it can be truncated by a * full disk, hand-edited, or left over from another version. `JSON.parse` is * happy with `{}`, `[]`, `null` and `"text"`, and every one of those then reads * as "a snapshot whose recorded original is absent" — which restore paths treat * as "the user had nothing here", and act on by deleting the user's real * config. Requiring the discriminating key present makes a malformed file fall * through to the caller's no-snapshot branch, which refuses to destroy * anything, instead of impersonating an empty one. */ export function isUsableSnapshot(value, requiredKey) { return (typeof value === "object" && value !== null && !Array.isArray(value) && Object.prototype.hasOwnProperty.call(value, requiredKey)); } /** Deep copy through JSON, so a snapshot cannot alias the object it describes. */ export function cloneForSnapshot(value) { return JSON.parse(JSON.stringify(value)); } /** * Whether the value currently in the file is the one this writer put there. * * Restore uses this as its licence to act. A base-URL check alone catches a * user who repointed the client elsewhere, but not one who kept the proxy URL * and changed something beside it — a rotated key, an added field. Reverting * those to the snapshot destroys a deliberate edit; for Qwen it destroys a * credential. No record of what we wrote (a config from before this existed) * means we cannot prove ownership either way, so the caller keeps its previous * behaviour rather than refusing every restore. */ export function isProxyOwnedValue(args) { if (args.written === undefined) { return true; } return valuesMatch(args.current, args.written); } /** * The path a chain of symlinks finally names, which need not exist. A path * that is not a link comes back unchanged. */ export async function resolveSymlinkTarget(filePath) { const fs = await import("fs"); const { dirname, resolve } = await import("path"); let target = filePath; for (let hops = 0;; hops += 1) { let isLink; try { isLink = fs.lstatSync(target).isSymbolicLink(); } catch { isLink = false; } if (!isLink) { return target; } if (hops === 40) { throw new Error(`${filePath}: too many levels of symbolic links`); } target = resolve(dirname(target), fs.readlinkSync(target)); } } /** Distinguishes concurrent writers within one process. */ let atomicWriteCounter = 0; /** * Replace a file's contents without ever exposing a partial one. * * `writeFileSync` opens with `O_TRUNC`, so from the truncate until the last * byte lands the user's config is short — and a real config spans several * syscalls, not an instant. Anything reading concurrently, the CLI the config * belongs to included, can load a truncated file; a crash in that window leaves * it truncated permanently. Both Qwen and OpenCode keep live API keys there. * * Writing to a sibling temp file and renaming closes it: `rename(2)` within a * directory is atomic, so a reader sees either the whole old file or the whole * new one. The temp file must be a sibling — a rename across filesystems is a * copy, which reintroduces exactly the window this removes. * * PERMISSIONS ARE THE SUBTLE PART, and getting them wrong here leaks API keys. * * Writing through a temp file changes who decides the destination's mode. A * plain `writeFileSync` over an existing file leaves that file's mode alone, so * a config the user had locked to 0600 stayed 0600. A rename replaces the inode, * so the destination inherits the TEMP file's mode instead — and a temp file * created without an explicit mode lands at 0666 minus umask, i.e. 0644 on a * default system. Left unhandled, making the write atomic would have quietly * widened every credential file it touched from 0600 to 0644. * * So when the caller does not specify a mode, the destination's current mode is * carried over, which reproduces `writeFileSync`'s behaviour exactly; a file * that does not exist yet starts at 0600 rather than whatever umask allows. * * The mode is applied at CREATE time, not after. `writeFileSync` followed by * `chmodSync` would put the credential bytes on disk at 0644 first and tighten * them a moment later — a window a local reader can win. The trailing `chmod` * remains only to pin the exact mode, since umask masks the create mode; by * then the file has never been readable more widely than its final mode. * * On Windows the guarantee holds but the failure mode differs, which is worth * stating because the obvious worry there is the wrong one. Node's `renameSync` * is `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING`; replacing a file on the * same volume is an atomic directory-entry update, so a concurrent reader still * sees the whole old file or the whole new one and never a torn one. What * Windows adds is that the rename can *fail* — `EPERM`/`EBUSY` when a reader * holds the destination open — where POSIX would succeed. That path is safe: * the catch below removes the temp file and rethrows, leaving the previous * config intact for the caller to report on. * * The sibling rule above is what makes that true. `MOVEFILE_COPY_ALLOWED` is * also set, so a cross-volume rename silently degrades to copy-then-delete and * is NOT atomic. Moving the temp file to `os.tmpdir()` would look like a * tidy-up and would quietly restore the exact window this function exists to * close, on Windows only, where nobody here would see it. * * A config kept in a dotfiles repository is often a symlink into it, a setup * Grok documents and its own writer honours. A rename over the link would * replace the link with a regular file and leave the repository's copy stale, * so the write lands on the end of the link chain instead, which need not * exist yet. The temp file is that target's sibling, so the rename stays * atomic. */ export async function writeFileAtomic(filePath, contents, mode) { const fs = await import("fs"); const { dirname, join, basename } = await import("path"); const target = await resolveSymlinkTarget(filePath); atomicWriteCounter += 1; const tempPath = join(dirname(target), `.${basename(target)}.neurolink-${process.pid}-${atomicWriteCounter}.tmp`); // Which step failed changes what the user should do about it: a failed write // is usually a missing directory or a full disk and the config is untouched, // while a failed rename is a locked destination and the config is intact but // stale. The bare errno is the same shape for both, so the stage is recorded // as it advances and named in the rethrow. let stage = "write"; // Resolved before the first byte is written — see the permissions note above. let effectiveMode = mode; if (effectiveMode === undefined) { try { effectiveMode = fs.statSync(target).mode & 0o777; } catch { effectiveMode = 0o600; } } try { // The temp file is a sibling of the destination, so a missing parent fails // the write rather than the rename — the config is untouched, but the // caller sees an ENOENT naming a path it never asked to write. Creating // the directory first makes a first-run write behave like the plain // writeFileSync it replaced. fs.mkdirSync(dirname(target), { recursive: true }); fs.writeFileSync(tempPath, contents, { mode: effectiveMode }); stage = "chmod"; fs.chmodSync(tempPath, effectiveMode); stage = "rename"; fs.renameSync(tempPath, target); } catch (error) { // Never leave scratch in the user's config directory. try { fs.rmSync(tempPath, { force: true }); } catch { // best effort } const reason = error instanceof Error ? error.message : String(error); // The original is attached as `cause`, not discarded: wrapping moves the // errno off the thrown object, and `cause` is where anything that needs // ENOENT/EPERM finds it. No caller reads it today — every call site either // lets this propagate or swallows it — so nothing breaks, but a future one // should not have to re-derive the syscall from a string. throw new Error(`atomic write to ${filePath} failed at the ${stage} step: ${reason}`, { cause: error }); } } //# sourceMappingURL=snapshot.js.map