/** * Guarded-write compare-and-swap core (upstream epic #1375, phase A4a). * * Wraps the S3 safeWriteText publish primitive behind version-token guards so * that every write is deterministic: * * - an unobserved target may only be created when it is absent * (createIfAbsent); * - an observed target is published only when the on-disk version token still * matches the current observation token (replaceIfVersion); * - an edit-style write requires a prior observation (unobservedEditGuard). * * A per-absolute-path FIFO chain of tail promises orders concurrent * in-process writes to the same path. Each publish refreshes the observation to * the token it wrote when the new token can be computed, so same-task writes * apply last-write-wins; when that refresh fails the observation keeps the * previous observation token. A write that goes through replaceIfVersion fails * stale when the on-disk token differs from the token the observation currently * holds; an observed "create" whose target has disappeared instead uses * createIfAbsent and can recreate it. Observations come from the task's S2 * ObservationRegistry and authorize the write as well as the version check. */ import * as fs from "fs/promises" import * as path from "path" import { safeWriteText } from "../../services/file-safety/safeWriteText" import { computeVersionToken } from "../../utils/versionToken" import { withFileLock } from "../../utils/fileLock" import { resolveLockKey } from "../../services/file-safety/safeWriteText" import type { Task } from "../task/Task" // -- Types ------------------------------------------------------------------ /** Write kind that drives guard selection. */ export type GuardedWriteKind = "create" | "update" | "edit" /** * Call-site context the guard needs but cannot derive itself. */ export interface GuardedWriteOptions { /** * The target sits outside every workspace root and the tool layer obtained an * approval DECISION for it (the user approved, or the auto-approval policy * permitted it). Only a tool's post-approval path may set this: it is not the * same as 'the path is outside the workspace', and an unapproved call keeps the * containment checks in force. */ approvedOutsideWorkspace?: boolean /** * The canonical identity of the target, captured BEFORE the approval was asked, * for an approved outside-workspace write. The guard binds the publish to it, so a * name repointed between the approval and the publish resolves elsewhere and is * refused. The guard cannot derive it itself: any lookup it performs happens after * the approval, and would capture the swapped identity as the baseline. Required * whenever approvedOutsideWorkspace is set. */ approvedCanonicalTarget?: string /** * Roots other than task.cwd that a write may be contained in - the other VS Code * workspace folders. The tool layer classifies paths against ALL workspace folders * (isPathOutsideWorkspace) and only asks for approval for paths outside every one * of them, so a guard that knew only task.cwd would reject a write the tool layer * had already treated as an ordinary in-workspace edit. Core stays host-agnostic: * the extension-host caller supplies them. */ additionalRoots?: string[] } /** Error thrown when a guard rejects a write. Exported so a caller can tell a guard verdict from an unrelated failure. */ export class GuardRejectedError extends Error { constructor( message: string, readonly path: string, ) { super(message) this.name = "GuardRejectedError" } } // -- Per-path tail-promise chain -------------------------------------------- /** * Per-absolute-path FIFO chain of pending guarded writes (tail promise per * path). Every write enqueues onto the current tail for its path, so * concurrent writes to the same path run one at a time in submission order. * * The chain never leaks a rejection through itself: each link settles, a * rejected link is skipped by the next writer (a failed write must not block * later writes to the same path), and every caller receives its own link * promise to handle. * * Settled entries are evicted (below), so a long-lived extension does not * accumulate a map entry per distinct written path. */ const pendingChains = new Map>() /** * Enqueue a write operation on the per-path FIFO chain. * * Returns the promise for this link; it always settles. A prior link that * rejected is skipped, not propagated. The map entry for this link is * deleted once it settles — but only while it is still the current tail for * the path, so a replacement enqueued in the meantime keeps ownership. */ function enqueue(pathKey: string, fn: () => Promise): Promise { const prev = pendingChains.get(pathKey) ?? Promise.resolve() const next = prev.then(fn, fn) pendingChains.set(pathKey, next) void next.then( () => { if (pendingChains.get(pathKey) === next) { pendingChains.delete(pathKey) } }, () => { if (pendingChains.get(pathKey) === next) { pendingChains.delete(pathKey) } }, ) return next } // -- Guard primitives -------------------------------------------------------- /** * Extract a Node errno code (e.g. "ENOENT") from a thrown value, or * undefined when the value carries none. */ export function errorCode(error: unknown): string | undefined { return typeof error === "object" && error !== null && "code" in error ? (error as { code?: string }).code : undefined } /** * Cancellation re-check under the publish lock. A write queued on the FIFO * chain can outlive its task: the caller already reported the write to the model, * so a task aborted while its link waited must not publish afterwards. */ function cancelledBeforePublish(absolutePath: string, displayPath: string, isCancelled?: () => boolean): void { if (isCancelled?.()) { throw new GuardRejectedError( "Task was cancelled before this write published -- nothing was written.", displayPath, ) } } /** True when the path is absent on disk (fs.access reports ENOENT). */ async function fileIsAbsent(absolutePath: string): Promise { try { await fs.access(absolutePath) return false } catch (error: unknown) { return errorCode(error) === "ENOENT" } } /** * Publish content only if the target file does not exist. * * Rejects with a loud remediation error when the file already exists: the * write was issued for a file that was never read, so the caller must read * the file first, then retry. */ /** * Read the on-disk token after a publish, best-effort: a publish that * succeeded is not undone by a failed stat, so the caller keeps the publish * and only skips the observation refresh. */ async function tokenAfterPublish(absolutePath: string): Promise { return computeVersionToken(absolutePath).catch(() => undefined) } export async function createIfAbsent( absolutePath: string, content: string | Uint8Array, displayPath: string, // Re-checked under the lock: a link that waited on the FIFO chain can outlive // the task that queued it. isCancelled?: () => boolean, // Re-checked under the lock, immediately before the publish: the path was authorized // when it was queued, but a symlink can be swapped in while the link waited on the // FIFO chain or on this lock. verifyTarget?: () => Promise, ): Promise { // Lock the key every other writer to this file uses: the resolved publish // target, so a symlink alias and its referent share one lock. const lockKey = await resolveLockKey(absolutePath) // Create the directory that would hold the lock before acquiring it. proper-lockfile // builds its lock as ".lock" with a non-recursive mkdir, so a create into a // directory that does not exist yet fails with ENOENT at lock acquisition - before the // writer below ever reaches the mkdir -p it performs itself. Measured on this repo's // pinned runtime: acquireFileLock on /missing-a/missing-b/target.json fails with // ENOENT, resolveLockKey of the same path returns a key inside the missing directory and // fails the same way, and the identical call after mkdir -p of that directory acquires // and releases. This is the create path, so making the containing directory is part of // what the caller already asked for. await fs.mkdir(path.dirname(lockKey), { recursive: true }) return withFileLock(lockKey, async () => { cancelledBeforePublish(absolutePath, displayPath, isCancelled) // Bind the publish to the target the authorization check covers: the // re-check runs first and returns the canonical path it authorized, and // every step below acts on that fixed target. const boundTarget = (await verifyTarget?.()) ?? absolutePath // The lock is held on a key computed BEFORE it was acquired, and the target is // bound inside it. If the path was repointed while this call waited, the bound // target no longer belongs to the held lock: a direct writer to that target takes // a different lock, both can pass the absence check below, and the later rename // replaces the earlier file. Re-derive the key with the same canonicalization // that produced it and refuse the publish when it no longer matches. if ((await resolveLockKey(boundTarget)) !== lockKey) { throw new GuardRejectedError( "Path changed while this write waited for the lock -- re-read the file, then retry.", displayPath, ) } try { await fs.access(boundTarget) } catch (error: unknown) { if (errorCode(error) !== "ENOENT") { // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. throw error } // Immediately before publication starts. cancelledBeforePublish(absolutePath, displayPath, isCancelled) await safeWriteText(boundTarget, content) // Read the new token under the same lock, otherwise a peer lock-using // writer can publish in the gap and the caller records that writer's // token as its own observation. return tokenAfterPublish(boundTarget) } throw new GuardRejectedError( "File already exists at " + displayPath + " and was not read before this write -- read the file first, then retry.", displayPath, ) }) } /** * Publish content only if the current on-disk version token equals * expectedVersion (the token observed at read time). * * On a match the content is published via the S3 safeWriteText primitive; on * a mismatch the write is rejected stale with a re-read-then-retry * remediation suffix. * * Atomicity boundary: the check and the publish run inside one acquisition of the * shared advisory lock, and the post-publish token is read under that same lock, so * no writer that participates in the protocol can interleave here. Every production * caller of the publish primitive holds the same canonical key (safeWriteJson * acquires it before publishing; TaskHistoryStore deletion takes it on the same * resolved key). The primitive itself cannot re-acquire the lock -- withFileLock must * not be re-entered inside an operation, and the callers already hold it, so wrapping * it would deadlock them. A writer that never takes the lock is outside the supported * threat model: a plain rename has no conditional form, so no advisory mechanism can * bind a checked version to it. */ export async function replaceIfVersion( absolutePath: string, expectedVersion: string, content: string | Uint8Array, // Re-checked under the lock: a link that waited on the FIFO chain can outlive // the task that queued it. displayPath: string, isCancelled?: () => boolean, // Re-checked under the lock, immediately before the publish: the path was authorized // when it was queued, but a symlink can be swapped in while the link waited on the // FIFO chain or on this lock. verifyTarget?: () => Promise, ): Promise { // Lock the key every other writer to this file uses: the resolved publish // target, so a symlink alias and its referent share one lock. const lockKey = await resolveLockKey(absolutePath) return withFileLock(lockKey, async () => { cancelledBeforePublish(absolutePath, displayPath, isCancelled) // Bind first: the version comparison and the publish must act on the same // target the authorization check covered. const boundTarget = (await verifyTarget?.()) ?? absolutePath // Re-derive the key from the target this call actually bound to. The key was computed // before the lock was acquired, so a path repointed while this call waited is covered by // a different lock: a writer holding that lock can publish between the version // comparison below and this rename, and this write would overwrite its file while both // sides believed they were serialized. Refuse instead, the way createIfAbsent does for // the same window. if ((await resolveLockKey(boundTarget)) !== lockKey) { throw new GuardRejectedError( "Path changed while this write waited for the lock -- re-read the file, then retry.", displayPath, ) } let currentVersion: string try { currentVersion = await computeVersionToken(boundTarget) } catch (error: unknown) { if (errorCode(error) === "ENOENT") { // The observed file was deleted after the read: the version recorded // at read time no longer exists on disk. Normalize the raw ENOENT // into the guard's re-read-then-retry contract so the caller gets a // remediation it can act on, not a raw errno. throw new GuardRejectedError( "File was deleted after it was read -- the version recorded at read time (" + expectedVersion + ") no longer exists; re-read the file, then retry.", displayPath, ) } // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. throw error } // Re-checked after the awaited preflight: the task can be aborted while this // operation waits, and a publish that starts after that is a write the caller // has already reported as not performed. cancelledBeforePublish(absolutePath, displayPath, isCancelled) if (currentVersion === expectedVersion) { // Immediately before publication starts. cancelledBeforePublish(absolutePath, displayPath, isCancelled) await safeWriteText(boundTarget, content) // Read the new token under the same lock, otherwise a peer lock-using // writer can publish in the gap and the caller records that writer's // token as its own observation. return tokenAfterPublish(boundTarget) } throw new GuardRejectedError( "Stale version -- the file changed since you read it (expected " + expectedVersion + ", current " + currentVersion + "); re-read the file, then retry.", displayPath, ) }) } /** * Unobserved-edit guard: an edit-style write without a prior observation is * rejected before any I/O. The literal-match / patch logic stays with the * tools in S4b; this guard only verifies that a read happened first. * * Returns Promise because the rejection is total: this function * never resolves. */ export async function unobservedEditGuard(absolutePath: string, displayPath: string): Promise { throw new GuardRejectedError("File not read yet -- read the file, then retry.", displayPath) } // -- Public API -------------------------------------------------------------- /** * Resolve a relative or absolute path against task.cwd. * * path.resolve also normalizes an already-absolute input (collapsing "." / ".." * segments and trailing separators), so the key always matches the * ObservationRegistry key recorded at read time (ReadFileTool observes under * path.resolve(task.cwd, relPath)) and two spellings of one file share one * FIFO chain. */ function resolveAbsolutePath(task: Task, relPathOrAbsolute: string): string { return path.resolve(task.cwd, relPathOrAbsolute) } /** * Reject a target that is not inside the task's workspace, before anything is * queued or touched. path.resolve keeps an absolute input unchanged and collapses * ".." segments, so a model-supplied string forwarded verbatim can name any path * on the machine. The rooignore rules, protected-path rules and the user-approval * flow live in the tool layer; this is the containment line inside the publish * helper itself, so a caller that forgets them cannot publish outside the * workspace by accident. */ function isInside(root: string, target: string): boolean { const relative = path.relative(root, target) return !(relative === "" || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) } function assertInsideWorkspace(roots: string[], absolutePath: string, displayPath: string): void { if (!roots.some((root) => isInside(root, absolutePath))) { throw new GuardRejectedError( `Path resolves outside the workspace -- write inside the workspace, then retry.`, displayPath, ) } } /** * The lexical check above cannot see a symlink whose referent leaves the * workspace, and the publish resolves through that link. Resolve both sides the * same way and compare again. Only a missing TARGET is walked up to the nearest * existing ancestor (a create has no target yet); a workspace that cannot be * resolved - for any reason, ENOENT included - is rejected rather than falling back * to the lexical decision, because the lexical decision is exactly what a symlink * defeats. A root that is not on disk is not a container this write can be checked * against, so the write is refused instead of published on a weaker guarantee. */ async function assertCanonicalInsideWorkspace( roots: string[], absolutePath: string, displayPath: string, // The caller may already hold the canonical target it wants to publish to; // checking THAT value (rather than resolving the path a second time) keeps the // containment decision and the bound publish target the same resolution. preResolvedTarget?: string, ): Promise { const resolvedRoots: string[] = [] const unresolvedRoots: string[] = [] for (const root of roots) { try { resolvedRoots.push(await fs.realpath(root)) } catch { // A root that cannot be canonicalized is dropped from the ALLOW-LIST rather than // refusing every write in a multi-root workspace. Containment only gets narrower: // a target is still accepted only if a root that DID resolve contains it, so the // broken root never vouches for anything. A write that lives only under the broken // root falls through to the refusal below. unresolvedRoots.push(root) } } if (resolvedRoots.length === 0) { throw new GuardRejectedError( "Workspace could not be resolved, so this write cannot be checked against it -- retry with a path inside the workspace.", displayPath, ) } const target = preResolvedTarget ?? (await realpathNearest(absolutePath, displayPath)) if (!resolvedRoots.some((root) => isInside(root, target))) { throw new GuardRejectedError( unresolvedRoots.length > 0 ? `Path is not inside any workspace folder that could be resolved -- retry with a path inside the workspace.` : `Path resolves through a link to outside the workspace -- write a real file inside the workspace, then retry.`, displayPath, ) } } async function realpathNearest(target: string, displayPath: string): Promise { const lexical = path.resolve(target) try { return await fs.realpath(lexical) } catch (error: unknown) { if (errorCode(error) !== "ENOENT") { throw new GuardRejectedError( "Path could not be resolved, so it cannot be checked against the workspace -- retry with a path inside the workspace.", displayPath, ) } // The leaf itself can be a dangling link: realpath reports ENOENT for it // exactly as for a not-yet-created file, but the publish resolves the name // again and would then follow a referent this check never authorized. const leaf = await fs.lstat(lexical, { bigint: true }).catch((lstatError: unknown) => { if (errorCode(lstatError) === "ENOENT") { return undefined } throw new GuardRejectedError( "Path could not be resolved, so it cannot be checked against the workspace -- retry with a path inside the workspace.", displayPath, ) }) if (leaf?.isSymbolicLink()) { throw new GuardRejectedError( `Path is a link that does not resolve -- retry with a path inside the workspace.`, displayPath, ) } const missing: string[] = [] let ancestor = lexical while (true) { const parent = path.dirname(ancestor) if (parent === ancestor) { return lexical } missing.push(path.basename(ancestor)) ancestor = parent try { const real = await fs.realpath(ancestor) return path.join(real, ...missing.reverse()) } catch (innerError: unknown) { if (errorCode(innerError) !== "ENOENT") { throw new GuardRejectedError( "Path could not be resolved, so it cannot be checked against the workspace -- retry with a path inside the workspace.", displayPath, ) } // "Missing" has two meanings and only one of them is benign. A directory // that has not been created yet is fine - the publish makes it. A component // that EXISTS as a symlink whose referent is gone is a dangling link: the // lexical name would be re-joined onto a container that points elsewhere // (or nowhere), and the publish would then create the file outside the // directory this check authorized. const asLink = await fs.lstat(ancestor, { bigint: true }).catch(() => undefined) if (asLink?.isSymbolicLink()) { throw new GuardRejectedError( "Path runs through a link that does not resolve -- retry with a path inside the workspace.", displayPath, ) } } } } } /** * The canonical identity of a write target, for a caller that needs to bind an * approval to it. Call this BEFORE asking the user: the guard compares its * pre-publish resolution against this value, so a name repointed in between is * refused. A target that does not exist yet is tolerated the way the guard tolerates * it (a create makes the missing components); any other resolution failure throws a * GuardRejectedError instead of falling back to the lexical path. */ export async function canonicalizeForApproval(target: string, displayPath?: string): Promise { return realpathNearest(target, displayPath ?? target) } /** * Guarded write entry point. * * 1. Resolves the absolute path against task.cwd. * 2. Consults the task's S2 observation registry to pick the guard: * - unobserved + create/update: createIfAbsent (rejects if it exists); * - observed + create on a file that vanished after the read: recreate; * - observed otherwise: replaceIfVersion (CAS on the S1 version token); * - unobserved + edit: unobservedEditGuard. * 3. A full-file replacement ("update", or a "create" whose target still * exists) additionally requires a complete observation: a partial read * (slice, range, truncated, indentation block) authorizes targeted edits * only, never a full-file overwrite of the existing content. * 4. Runs the chosen guard on the per-path FIFO chain so concurrent writes to * the same path are deterministically ordered. * 5. After a successful publish, refreshes the observation with the new * on-disk token (complete) so consecutive writes by the same task do not * fail stale against the version they just published. */ export async function guardedWrite( task: Task, relPathOrAbsolute: string, content: string | Uint8Array, kind: GuardedWriteKind = "update", // Optional completeness for the refresh. A tool that built its content from a // view of another file must carry that view's completeness through the publish // instead of claiming completeness for lines it never read. completeOverride?: boolean, options?: GuardedWriteOptions, ): Promise { const absolutePath = resolveAbsolutePath(task, relPathOrAbsolute) // Model-facing path: the caller's own spelling, not the resolved absolute // path. The guard key stays absolute, but a rejection must not put a // user-specific absolute path into the model's context. const displayPath = relPathOrAbsolute const approvedOutside = options?.approvedOutsideWorkspace === true // task.cwd is the task's own root; the other workspace folders come from the // extension-host caller because the tool layer classifies against all of them. const roots = [path.resolve(task.cwd), ...(options?.additionalRoots ?? []).map((root) => path.resolve(root))] // Containment is decided before the link is queued: a write that would land // outside the workspace must not take a slot on the FIFO chain, take the file // lock, or touch the filesystem at all. An approved outside-workspace write skips // containment - the tool layer already put it in front of the user - but not the // identity re-check below. if (!approvedOutside) { assertInsideWorkspace(roots, absolutePath, displayPath) } // Bound to the task and the caller's spelling so the guard can re-run the same // decision under the lock, immediately before the publish. let authorizedTarget: string | undefined if (approvedOutside) { // The identity has to come from the caller, captured before the approval was // asked. A lookup performed here runs after the approval, so a name repointed in // between would be captured as the baseline and the swapped write accepted. if (options?.approvedCanonicalTarget === undefined) { throw new GuardRejectedError( "The approved target was not captured before approval, so this write cannot be bound to the path that was approved -- re-approve the write, then retry.", displayPath, ) } authorizedTarget = options.approvedCanonicalTarget } const verifyTarget = async (): Promise => { // Resolve ONCE and return the canonical target: the containment decision // below and the publish must act on the same resolution, so the guarded // writers publish to THIS value instead of handing the caller's alias to // safeWriteText (which would resolve it a third time - a link swapped in // after the check would otherwise move the bytes to another file). const resolved = await realpathNearest(absolutePath, displayPath) if (!approvedOutside) { await assertCanonicalInsideWorkspace(roots, absolutePath, displayPath, resolved) return resolved } // For an approved outside-workspace write the re-check is an IDENTITY check: what // resolves now must still be what was approved. A symlink swapped in - before this // call or while the link waited on the chain - moves the write somewhere the user // never saw, so the comparison is against the identity captured before the // approval, never against a lookup done here. if (authorizedTarget !== resolved) { throw new GuardRejectedError( "The approved target no longer resolves to the path that was approved -- re-approve the write, then retry.", displayPath, ) } return resolved } return enqueue(absolutePath, async () => { // Verify the target INSIDE the reserved link. Awaiting the verification before enqueueing // would decide the order of two writes to one path by which verification finished first, // not by which call was made first, and the chain's contract is submission order; the // verification is an await, so a slow check on the first write would let the second // publish and refresh the observation ahead of it. The publish helpers re-run this same // verification under the lock, so moving it changes when a refusal is reported, never // whether it is reported. await verifyTarget() // Cancellation is checked when the link is dequeued, not when it was enqueued: // a write queued before an abort can still reach its turn on the chain after the // task is gone, and the caller has already reported the write to the model. if (task.abort) { throw new GuardRejectedError( "Task was cancelled before this write ran -- the queued publish is not performed.", displayPath, ) } const obs = task.observationRegistry.get(absolutePath) // A targeted edit authorizes only the view the model saw, so a partial // observation stays partial; a full-file publish is complete. let staysPartial = false // Token the guard read under the lock, so the refresh records the token // this write published rather than a peer writer's. let publishedToken: string | undefined if (kind === "edit") { // Edit-style writes require a prior read: no observation, no write. // A targeted edit only authorizes the view the model saw, so a // partial observation is valid for the edit itself; the version // check still rejects a file that moved since the read. if (obs === undefined) { await unobservedEditGuard(absolutePath, displayPath) } else { publishedToken = await replaceIfVersion( absolutePath, obs.version, content, displayPath, () => task.abort, verifyTarget, ) staysPartial = obs.complete === false } } else { // "create" or "update" publish a full file built on the model's // content. A replacement of an existing target -- an "update", or a // "create" whose target is still on disk -- therefore requires a // complete observation: a slice, range, truncated, or // indentation-block read only authorizes the view the model saw, and // publishing over the existing file would silently drop everything // the model never read, so the guard fails closed with a // re-read-the-whole-file remediation. A fresh create (absent target) // needs no prior read and stays allowed. const absent = kind === "create" && (await fileIsAbsent(absolutePath)) if (!absent && obs !== undefined && obs.complete === false) { throw new GuardRejectedError( "File was only partially read (line slice, range, truncated view, or indentation block) -- " + "a full-file replacement needs the complete content; re-read the whole file, then retry.", displayPath, ) } if (obs === undefined) { // Never read: only an absent target may be created. publishedToken = await createIfAbsent( absolutePath, content, displayPath, () => task.abort, verifyTarget, ) } else if (absent) { // A "create" on a file that vanished after the read recreates it. publishedToken = await createIfAbsent( absolutePath, content, displayPath, () => task.abort, verifyTarget, ) } else { // The version recorded at read time must still match the on-disk // token. publishedToken = await replaceIfVersion( absolutePath, obs.version, content, displayPath, () => task.abort, verifyTarget, ) } } // A publish changes the on-disk token (the rename changes ino, size, and // mtime). The model just wrote the full content, so refresh the // observation with the new token: a consecutive write by the same task // must not fail stale against the version it just published. // The guard already read the token under the lock; a failed stat after a // successful publish only skips the refresh, it does not undo the publish. if (publishedToken !== undefined) { // Refresh with the new token, keeping the completeness the guard // established: a partial observation that authorized a targeted edit // must stay partial, otherwise a later full-file replacement would // publish content built from the slice alone. task.observationRegistry.observe(absolutePath, publishedToken, completeOverride ?? !staysPartial) } }) } /** * Reset the per-path tail-promise chains (test hook). * * @internal Test-only: production code never resets the chains, so the production * dead-code check must not report this export as unused. */ export function resetChain(): void { pendingChains.clear() }