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

129 lines (128 loc) • 7.21 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. */ /** * 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 declare function shouldCaptureSnapshot(args: { hasSnapshot: boolean; written: unknown; current: unknown; }): boolean; /** * 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 declare function isUsableSnapshot<K extends string>(value: unknown, requiredKey: K): value is Record<K, unknown>; /** Deep copy through JSON, so a snapshot cannot alias the object it describes. */ export declare function cloneForSnapshot<T>(value: T): T; /** * 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 declare function isProxyOwnedValue(args: { written: unknown; current: unknown; }): boolean; /** * The path a chain of symlinks finally names, which need not exist. A path * that is not a link comes back unchanged. */ export declare function resolveSymlinkTarget(filePath: string): Promise<string>; /** * 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 declare function writeFileAtomic(filePath: string, contents: string, mode?: number): Promise<void>;