From 8ac605ac85458a3e5d716143af5cf1f0f3b04806 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 00:38:12 +0000 Subject: [PATCH 1/2] Require Node.js 22.12 or newer (AUG-294) Node.js 20 reached end of life in April 2026. Raise engines, the build target, CI, discovery, and docs to 22.12 without bumping 0.3.9. Co-authored-by: hamzahskafi --- .github/workflows/ci.yml | 6 ++++-- CHANGELOG.md | 10 ++++++++++ CONTRIBUTING.md | 2 +- README.md | 12 ++++++------ contracts/discovery-manifest.json | 2 +- docs/agent-setup.md | 2 +- examples/faq-chatbot/README.md | 6 +++--- package-lock.json | 2 +- package.json | 2 +- scripts/verify-published-discovery.mjs | 2 +- src/discovery.ts | 19 ++++++++++++++----- src/release.ts | 2 ++ test/discovery/manifest.test.ts | 16 ++++++++++++++++ tsup.config.ts | 2 +- 14 files changed, 62 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35b96f9..aa4a4f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,9 +25,10 @@ jobs: - macos-latest - windows-latest node: - - 20 + - 22.12.0 - 22 - 24 + - 26 runs-on: ${{ matrix.os }} steps: - name: Check out repository @@ -40,7 +41,8 @@ jobs: cache: npm - name: Install dependencies - run: npm ci + # On the minimum Node lane, fail if any dependency needs a newer Node. + run: npm ci ${{ matrix.node == '22.12.0' && '--engine-strict' || '' }} - name: Typecheck, test, and build run: npm run check diff --git a/CHANGELOG.md b/CHANGELOG.md index 98766f3..3087227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### Breaking + +- The CLI now requires Node.js 22.12 or newer (`engines.node` is + `>=22.12`). Node.js 20 reached end of life in April 2026 and no longer + gets security fixes, and the CLI's own dependencies (`commander@15`, + `vitest@5`) already need 22.12. Upgrade Node.js before installing this + release. `@augmentworks/cli@0.3.9` stays the last version for Node.js 20. + CI now tests Node.js 22.12.0, 22, 24 and 26, with an engine-strict install + on the 22.12.0 lane. The build targets `node22`. + ### Changed - Recorded the independent inspection of npm `@augmentworks/cli@0.3.9`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 22d3871..ceacd8b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ claims matter more than connector cleverness. ## Development setup -Use Node.js 20 or 22 and npm: +Use Node.js 22.12 or newer (22 or 24) and npm: ```bash npm ci diff --git a/README.md b/README.md index 013a857..f3898d7 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ target SDK, and no coding assistant is used in either runtime path. | Path | What it is | Needs | Status | | --- | --- | --- | --- | -| Packaged `demo` | Loopback-only synthetic refund target, isolated fixtures, and the real local runner/scorer. Shows a policy bug, then the same packet passing after the policy is enforced. | Node.js 20+ | In this `@augmentworks/cli@0.3.9` package | -| Local `test --local` | Customer-executed scoring of a data-only packet against *your* configured target. Requires no AugmentWorks account and contacts no AugmentWorks service. | Node.js 20+, a connector YAML, and a target you are authorized to call. The bundled starter is an isolated synthetic fixture. | In this `@augmentworks/cli@0.3.9` package | +| Packaged `demo` | Loopback-only synthetic refund target, isolated fixtures, and the real local runner/scorer. Shows a policy bug, then the same packet passing after the policy is enforced. | Node.js 22.12+ | In this `@augmentworks/cli@0.3.9` package | +| Local `test --local` | Customer-executed scoring of a data-only packet against *your* configured target. Requires no AugmentWorks account and contacts no AugmentWorks service. | Node.js 22.12+, a connector YAML, and a target you are authorized to call. The bundled starter is an isolated synthetic fixture. | In this `@augmentworks/cli@0.3.9` package | | Hosted `test` | Outbound HTTPS relay assessment with a live dashboard. Browser approval does not start a run. Pending hosted judging is never a pass. | Invited workspace, login, and an authorized isolated synthetic or staging target while hosted real-data is release-disabled | In this `@augmentworks/cli@0.3.9` package, including quoted `--estimate` / `--max-credits` | Product site: [https://augmentworks.ai](https://augmentworks.ai). @@ -49,7 +49,7 @@ published package. Do not run `@latest`. ## Support and FAQ example -Prerequisites: Node.js 20 or newer. No AugmentWorks account. Run the published +Prerequisites: Node.js 22.12 or newer. No AugmentWorks account. Run the published CLI from a directory that is not this checkout; inside the checkout, `npx` can exit `127` with `augmentworks: not found`. @@ -126,7 +126,7 @@ Executable `npx` examples pin **0.3.9**, the identity of this package. This publ ## Packaged demo (this 0.3.9 package) -Prerequisites: Node.js 20 or newer. No API key, login, second terminal, Docker, +Prerequisites: Node.js 22.12 or newer. No API key, login, second terminal, Docker, database, or model. From a clone of this repository after `npm ci && npm run build`: @@ -173,7 +173,7 @@ still names 0.3.6; the inspection receipt is the later evidence file. ## Hosted quickstart -Prerequisites: Node.js 20 or newer, an invited AugmentWorks workspace, and an +Prerequisites: Node.js 22.12 or newer, an invited AugmentWorks workspace, and an authorized, isolated synthetic or staging target while hosted real-data is release-disabled. Hosted access is not a public self-serve signup; do not assume a trial entitlement. @@ -996,7 +996,7 @@ for a safe coding-assistant workflow. ## Compatibility and releases -The package requires Node.js 20+. Configuration and relay envelopes carry an +The package requires Node.js 22.12 or newer. Configuration and relay envelopes carry an explicit protocol version. Patch releases remain compatible with their v1 schema; incompatible configuration or protocol changes require a new version. Published releases are expected to use npm trusted publishing with provenance. diff --git a/contracts/discovery-manifest.json b/contracts/discovery-manifest.json index 1251955..afe06eb 100644 --- a/contracts/discovery-manifest.json +++ b/contracts/discovery-manifest.json @@ -6,7 +6,7 @@ "releaseStatus": "development" }, "runtime": { - "node": ">=20" + "node": ">=22.12" }, "capabilities": { "localDemo": true, diff --git a/docs/agent-setup.md b/docs/agent-setup.md index 9f8d3c7..6a57c7e 100644 --- a/docs/agent-setup.md +++ b/docs/agent-setup.md @@ -46,7 +46,7 @@ Do not run `@latest`. Do not pin immutable npm `0.3.5` / `0.3.4` / `0.3.3` or ov Independent inspection of 0.3.5 is recorded in `docs/feature-readiness/published-registry-evidence.json`. -Prerequisites: Node.js 20+ and either the packaged fictional demo or a target +Prerequisites: Node.js 22.12 or newer and either the packaged fictional demo or a target you are authorized to call. Test your chatbot with your questions and business rules. That hosted scope stays off until a compatible published release is enabled. Until then, hosted runs diff --git a/examples/faq-chatbot/README.md b/examples/faq-chatbot/README.md index dac746a..a2aaba5 100644 --- a/examples/faq-chatbot/README.md +++ b/examples/faq-chatbot/README.md @@ -54,8 +54,8 @@ third mode. ## Prerequisites -- Node.js 20 or newer (`package.json` `engines.node` is `>=20` on - `@augmentworks/cli@0.3.9`). +- Node.js 22.12 or newer (`package.json` `engines.node` is `>=22.12`). + Node.js 20 reached end of life in April 2026. - A shell whose current directory is **not** a checkout of this CLI repository. Inside the checkout, `npx` can bind the local package and exit `127` with `augmentworks: not found`. @@ -199,7 +199,7 @@ an AugmentWorks workspace. | Situation | What you should see | Exit | | --- | --- | --- | | `node` is not on `PATH` | The shell reports that `node` or `npx` was not found. The CLI does not start. | Shell status, often `127` | -| Node.js older than 20 | Outside this package's `engines.node` range. The clean-room run used Node.js 20 or newer. | Not a tested CLI exit | +| Node.js older than 22.12 | Outside this package's `engines.node` range. Upgrade Node.js to 22.12 or newer. | Not a tested CLI exit | | No `.env` and neither variable is set | `ERROR ENV_REQUIRED` for `CHATBOT_BASE_URL` (`target.base_url`) and `CHATBOT_API_KEY` (`target.auth.bearer_env`). `Doctor found configuration errors.` | `2` | | `.env` created with `cp` and left world-readable | `WARN ENV_FILE_PERMISSIONS` and doctor can still exit `0`. `chmod 600 .env` removes that warning. | `0` | | `augmentworks.malformed.yaml` | `ERROR YAML_PARSE_ERROR` and the parser message. For this file: `Flow sequence in block collection must be sufficiently indented and end with a ]`, then the config path. `Doctor found configuration errors.` | `2` | diff --git a/package-lock.json b/package-lock.json index 643dfd1..5b670af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "zod": "4.6.5" }, "engines": { - "node": ">=20" + "node": ">=22.12" } }, "node_modules/@esbuild/aix-ppc64": { diff --git a/package.json b/package.json index 1c99205..6ffed21 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "THIRD_PARTY_NOTICES.md" ], "engines": { - "node": ">=20" + "node": ">=22.12" }, "scripts": { "build": "tsup", diff --git a/scripts/verify-published-discovery.mjs b/scripts/verify-published-discovery.mjs index 8547546..a8abba1 100644 --- a/scripts/verify-published-discovery.mjs +++ b/scripts/verify-published-discovery.mjs @@ -80,7 +80,7 @@ process.stdout.write( version, releaseStatus: "published" }, - runtime: { node: metadata.engines?.node ?? ">=20" }, + runtime: { node: metadata.engines?.node ?? ">=22.12" }, capabilities: { localDemo: false, localTest: true, diff --git a/src/discovery.ts b/src/discovery.ts index f844bc4..881bba3 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { LAST_VERIFIED_PUBLISHED_AT, LAST_VERIFIED_PUBLISHED_GIT_HEAD, + LAST_VERIFIED_PUBLISHED_NODE_RANGE, LAST_VERIFIED_PUBLISHED_PACKAGE_VERSION, LOCAL_PACKET_REFERENCE, NPM_PACKAGE, @@ -178,10 +179,11 @@ const discoveryManifestSchema = z path: ["provenance", "verifiedAt"] }); } - if (!value.runtime.node.includes("20")) { + const nodeFloor = /^>=\s*(\d+)(?:\.\d+){0,2}$/.exec(value.runtime.node.trim()); + if (nodeFloor == null || Number(nodeFloor[1]) < 20) { ctx.addIssue({ code: "custom", - message: "runtime.node must describe the supported Node 20+ range", + message: "runtime.node must be a >= range starting at Node 20 or later", path: ["runtime", "node"] }); } @@ -204,12 +206,16 @@ export function parseDiscoveryManifest(value: unknown): DiscoveryManifestParseRe return { ok: true, manifest: parsed.data as DiscoveryManifestV1 }; } +/** Must match `engines.node` in package.json. */ +const SOURCE_NODE_RANGE = ">=22.12"; + export interface BuildDiscoveryManifestOptions { readonly releaseStatus?: DiscoveryReleaseStatus; readonly version?: string; readonly localDemo?: boolean; readonly sourceCommit?: string | null; readonly verifiedAt?: string | null; + readonly nodeRange?: string; } export function buildDiscoveryManifest( @@ -230,7 +236,7 @@ export function buildDiscoveryManifest( version, releaseStatus }, - runtime: { node: ">=20" }, + runtime: { node: options.nodeRange ?? SOURCE_NODE_RANGE }, capabilities: { localDemo, localTest: true, @@ -263,13 +269,15 @@ export function publishedDiscoveryManifest(options: { readonly localDemo: boolean; readonly sourceCommit: string | null; readonly verifiedAt: string; + readonly nodeRange?: string; }): DiscoveryManifestV1 { return buildDiscoveryManifest({ releaseStatus: "published", version: options.version, localDemo: options.localDemo, sourceCommit: options.sourceCommit, - verifiedAt: options.verifiedAt + verifiedAt: options.verifiedAt, + ...(options.nodeRange === undefined ? {} : { nodeRange: options.nodeRange }) }); } @@ -311,5 +319,6 @@ export const LAST_VERIFIED_PUBLISHED_DISCOVERY = publishedDiscoveryManifest({ version: LAST_VERIFIED_PUBLISHED_PACKAGE_VERSION, localDemo: true, sourceCommit: LAST_VERIFIED_PUBLISHED_GIT_HEAD, - verifiedAt: LAST_VERIFIED_PUBLISHED_AT + verifiedAt: LAST_VERIFIED_PUBLISHED_AT, + nodeRange: LAST_VERIFIED_PUBLISHED_NODE_RANGE }); diff --git a/src/release.ts b/src/release.ts index a4386ed..8d51fff 100644 --- a/src/release.ts +++ b/src/release.ts @@ -34,6 +34,8 @@ export const PUBLISHED_PACKAGE_VERIFIED = true; export const LAST_VERIFIED_PUBLISHED_PACKAGE_VERSION = "0.3.9"; export const LAST_VERIFIED_PUBLISHED_GIT_HEAD = "9e7919cbfe583cc15701904580be9d36da3bcaf4"; export const LAST_VERIFIED_PUBLISHED_AT = "2026-10-13T20:31:53.099Z"; +/** The `engines.node` range that the inspected tarball declares. */ +export const LAST_VERIFIED_PUBLISHED_NODE_RANGE = ">=20"; export const LAST_VERIFIED_PUBLISHED_INTEGRITY = "sha512-ZDXMg5etb3UNGGrakJ5Iz+oYAkAOHqOt2DIVn41MvFnLjmlz/AIUL05pYGJHnDISo1At5Cik6Kjkses4hB4CYA=="; /** Immutable npm 0.3.3 (not this release). Do not overwrite or relabel. */ diff --git a/test/discovery/manifest.test.ts b/test/discovery/manifest.test.ts index 4bb3b10..3220518 100644 --- a/test/discovery/manifest.test.ts +++ b/test/discovery/manifest.test.ts @@ -95,6 +95,22 @@ describe("discovery manifest", () => { } }); + it("declares Node 22.12 for source builds and accepts older published Node floors", async () => { + const source = sourceDiscoveryManifest(); + const packageJson = JSON.parse( + await readFile(new URL("../../package.json", import.meta.url), "utf8") + ) as { engines: { node: string } }; + expect(source.runtime.node).toBe(">=22.12"); + expect(source.runtime.node).toBe(packageJson.engines.node); + expect(LAST_VERIFIED_PUBLISHED_DISCOVERY.runtime.node).toBe(">=20"); + for (const node of [">=20", ">=22", ">=22.12", ">=22.12.0", ">=24"]) { + expect(parseDiscoveryManifest({ ...source, runtime: { node } }).ok).toBe(true); + } + for (const node of [">=18", "22.x", "^22.12.0", ""]) { + expect(parseDiscoveryManifest({ ...source, runtime: { node } }).ok).toBe(false); + } + }); + it("keeps the source checkout development-status without claiming this tarball is already inspected", () => { const source = sourceDiscoveryManifest(); expect(source.package.version).toBe("0.3.9"); diff --git a/tsup.config.ts b/tsup.config.ts index 7602d55..6830baf 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ entry: ["src/index.ts"], format: ["esm"], - target: "node20", + target: "node22", platform: "node", clean: true, sourcemap: false, From 8c118ec7ea96f91a36865a85c5590f3310f043a5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 15 Oct 2026 00:51:25 +0000 Subject: [PATCH 2/2] Accept Windows path-stat dev 0 on Node 22.12 Node 22.12.0 through 22.16.x ship libuv 1.49, which reports dev 0 from path stat and the volume serial from fstat. Compare inodes, and compare device ids only when both are nonzero, so the 22.12.0 Windows lane can open files. Co-authored-by: hamzahskafi --- CHANGELOG.md | 9 ++++++ src/action-gate/ledger.ts | 3 +- src/assessment/load.ts | 3 +- src/commands/preview-mapping.ts | 3 +- src/config/environment.ts | 3 +- src/config/load.ts | 7 ++--- src/investigation/load.ts | 3 +- src/local/artifacts.ts | 7 ++--- src/local/open-report.ts | 13 ++------- src/local/packet.ts | 5 ++-- src/relay/journal.ts | 5 +--- src/relay/run-intent.ts | 3 +- src/relay/secure-lock.ts | 5 ++-- src/suite/load.ts | 3 +- src/system/file-identity.ts | 21 ++++++++++++++ test/system/file-identity.test.ts | 47 +++++++++++++++++++++++++++++++ 16 files changed, 106 insertions(+), 34 deletions(-) create mode 100644 src/system/file-identity.ts create mode 100644 test/system/file-identity.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3087227..a7039de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,15 @@ All notable changes to this project are documented here. The format follows CI now tests Node.js 22.12.0, 22, 24 and 26, with an engine-strict install on the 22.12.0 lane. The build targets `node22`. +### Fixed + +- Windows file-identity checks work on Node.js 22.12.0 through 22.16.x + (and 24.0.0 through 24.1.x). Those releases ship a libuv that reports + `dev: 0` from path `stat`/`lstat` and the volume serial from `fstat`, so + opening a config, packet, lock, or journal looked like the file had been + replaced. The file index still has to match. Two nonzero device ids still + have to match. + ### Changed - Recorded the independent inspection of npm `@augmentworks/cli@0.3.9`. diff --git a/src/action-gate/ledger.ts b/src/action-gate/ledger.ts index 317edb5..c440aa1 100644 --- a/src/action-gate/ledger.ts +++ b/src/action-gate/ledger.ts @@ -15,6 +15,7 @@ import { dirname, join, resolve } from "node:path"; import { AwError } from "../errors.js"; import { realDataError } from "../real-data/errors.js"; import { acquireSecureLock, ensureSecureDirectory } from "../relay/secure-lock.js"; +import { sameFileIdentity } from "../system/file-identity.js"; import { findUnsafeSymbolicLinkComponent } from "../system/path-safety.js"; import { ActionIntentSchema, @@ -460,7 +461,7 @@ function isCurrentOwner(uid: number): boolean { } function sameIdentity(left: Stats, right: Stats): boolean { - return left.dev === right.dev && left.ino === right.ino; + return sameFileIdentity(left, right); } function unsafeLedger(message: string, cause?: unknown): AwError { diff --git a/src/assessment/load.ts b/src/assessment/load.ts index ab22b71..f2508bf 100644 --- a/src/assessment/load.ts +++ b/src/assessment/load.ts @@ -4,6 +4,7 @@ import { lstat, open } from "node:fs/promises"; import { dirname, relative, resolve, sep } from "node:path"; import { AwError } from "../errors.js"; +import { sameFileIdentity } from "../system/file-identity.js"; import { findUnsafeSymbolicLinkComponent } from "../system/path-safety.js"; import { canonicalize, sha256 } from "../util/canonical.js"; import { parseYamlStrict, StrictYamlError } from "../config/yaml.js"; @@ -301,7 +302,7 @@ async function readBoundedUtf8File( : 0; handle = await open(path, constants.O_RDONLY | noFollow); const metadata = await handle.stat(); - if (!metadata.isFile() || metadata.dev !== beforeOpen.dev || metadata.ino !== beforeOpen.ino) { + if (!metadata.isFile() || !sameFileIdentity(metadata, beforeOpen)) { throw assessmentError(invalidCode, "The file changed while it was being opened."); } const bytes = Buffer.alloc(limit + 1); diff --git a/src/commands/preview-mapping.ts b/src/commands/preview-mapping.ts index f570d90..079c2de 100644 --- a/src/commands/preview-mapping.ts +++ b/src/commands/preview-mapping.ts @@ -16,6 +16,7 @@ import { } from "../connector/mapping-preview.js"; import { selectResponse } from "../connector/mapping.js"; import { EXIT, AwError, sanitizeTerminal } from "../errors.js"; +import { sameFileIdentity } from "../system/file-identity.js"; import { findUnsafeSymbolicLinkComponent } from "../system/path-safety.js"; import { LIMITS } from "../util/limits.js"; @@ -459,7 +460,7 @@ async function readBoundedRegularFile(path: string, maxBytes: number, label: str try { handle = await open(path, fsConstants.O_RDONLY | noFollow | nonBlocking); const metadata = await handle.stat(); - if (!metadata.isFile() || metadata.dev !== beforeOpen.dev || metadata.ino !== beforeOpen.ino) { + if (!metadata.isFile() || !sameFileIdentity(metadata, beforeOpen)) { throw new BoundedFileError( label === "fixture" ? "FIXTURE_UNREADABLE" : "CONFIG_FILE_UNREADABLE", `The ${label} file changed while it was being opened.`, diff --git a/src/config/environment.ts b/src/config/environment.ts index 82bcb03..c0f0bd2 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -4,6 +4,7 @@ import { lstat, open } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; import type { Stats } from "node:fs"; +import { sameFileIdentity } from "../system/file-identity.js"; import type { Diagnostic } from "./types.js"; export const ENV_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/; @@ -71,7 +72,7 @@ async function openAndReadEnvironment(envPath: string): Promise<{ readonly metad if (!metadata.isFile()) throw new Error("The .env path is not a regular file."); if ( preOpenMetadata !== undefined && - (preOpenMetadata.dev !== metadata.dev || preOpenMetadata.ino !== metadata.ino) + !sameFileIdentity(preOpenMetadata, metadata) ) { throw new Error("The .env file changed while it was being opened."); } diff --git a/src/config/load.ts b/src/config/load.ts index fbf18ba..7d569e1 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -4,6 +4,7 @@ import { lstat, open } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { AwError } from "../errors.js"; +import { sameFileIdentity } from "../system/file-identity.js"; import { findUnsafeSymbolicLinkComponent } from "../system/path-safety.js"; import { loadEnvironment } from "./environment.js"; import { resolveConfig } from "./resolve.js"; @@ -116,11 +117,7 @@ async function readConfigFile(path: string): Promise { try { handle = await open(path, fsConstants.O_RDONLY | noFollow | nonBlocking); const metadata = await handle.stat(); - if ( - !metadata.isFile() || - metadata.dev !== beforeOpen.dev || - metadata.ino !== beforeOpen.ino - ) { + if (!metadata.isFile() || !sameFileIdentity(metadata, beforeOpen)) { throw new ConfigFileError( "CONFIG_FILE_UNREADABLE", "The configuration file changed while it was being opened." diff --git a/src/investigation/load.ts b/src/investigation/load.ts index 6843349..ce7fbd2 100644 --- a/src/investigation/load.ts +++ b/src/investigation/load.ts @@ -4,6 +4,7 @@ import { lstat, open } from "node:fs/promises"; import { resolve } from "node:path"; import { AwError } from "../errors.js"; +import { sameFileIdentity } from "../system/file-identity.js"; import { findUnsafeSymbolicLinkComponent } from "../system/path-safety.js"; import { SecretRedactor, isSensitiveKey } from "../system/redact.js"; import { MAX_INVESTIGATION_FILE_BYTES, type InvestigationExport } from "./schema.js"; @@ -71,7 +72,7 @@ async function readBoundedUtf8File(path: string, limit: number): Promise process.platform !== "win32" && typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0; handle = await open(path, constants.O_RDONLY | noFollow); const metadata = await handle.stat(); - if (!metadata.isFile() || metadata.dev !== beforeOpen.dev || metadata.ino !== beforeOpen.ino) { + if (!metadata.isFile() || !sameFileIdentity(metadata, beforeOpen)) { throw investigationError( "INVESTIGATION_NOT_A_FILE", "The investigation file changed while it was being opened.", diff --git a/src/local/artifacts.ts b/src/local/artifacts.ts index 8922332..b14b5f0 100644 --- a/src/local/artifacts.ts +++ b/src/local/artifacts.ts @@ -8,6 +8,7 @@ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:p import { redactSecrets } from "../connector/mapping.js"; import { inspectOutbound, type DataPolicyContext } from "../data-policy/index.js"; import { AwError } from "../errors.js"; +import { sameFileIdentity } from "../system/file-identity.js"; import { assertJsonLimits } from "../util/limits.js"; import { sha256Json } from "./canonical.js"; import { LOCAL_TRUST_NOTICE, renderLocalReportHtml } from "./report-html.js"; @@ -402,8 +403,7 @@ async function removeOwnedOutput( metadata === undefined || metadata.isSymbolicLink() || !metadata.isDirectory() || - metadata.dev !== identity.dev || - metadata.ino !== identity.ino + !sameFileIdentity(metadata, identity) ) { return; } @@ -419,8 +419,7 @@ async function unlinkOwnedFile(path: string, identity: FileIdentity): Promise Promise; @@ -21,20 +22,12 @@ export async function openLocalReport(path: string): Promise { : 0; handle = await openFile(path, fsConstants.O_RDONLY | noFollow); const opened = await handle.stat(); - if ( - !opened.isFile() || - opened.dev !== metadata.dev || - opened.ino !== metadata.ino - ) { + if (!opened.isFile() || !sameFileIdentity(opened, metadata)) { throw new Error("the report path changed while it was being opened"); } const resolved = await realpath(path); const resolvedMetadata = await lstat(resolved); - if ( - !resolvedMetadata.isFile() || - resolvedMetadata.dev !== opened.dev || - resolvedMetadata.ino !== opened.ino - ) { + if (!resolvedMetadata.isFile() || !sameFileIdentity(resolvedMetadata, opened)) { throw new Error("the resolved report path changed while it was being opened"); } await openUrl(pathToFileURL(resolved).toString(), { wait: false }); diff --git a/src/local/packet.ts b/src/local/packet.ts index fbfb99d..62396c3 100644 --- a/src/local/packet.ts +++ b/src/local/packet.ts @@ -10,6 +10,7 @@ import starterPacketJson from "../../packets/support-refunds-starter/0.1.0/packe type: "json" }; import { AwError } from "../errors.js"; +import { sameFileIdentity } from "../system/file-identity.js"; import { findUnsafeSymbolicLinkComponent } from "../system/path-safety.js"; import { LIMITS } from "../util/limits.js"; import { hostedSuiteUnsupportedLocalError, livePacketUnsupportedLocalError, authorizedPacketUnsupportedLocalError } from "../suite/errors.js"; @@ -636,7 +637,7 @@ async function readPacketJson(path: string): Promise { if (!metadata.isFile()) { throw packetError("LOCAL_PACKET_FILE_INVALID", "The local packet path is not a regular file."); } - if (metadata.dev !== beforeOpen.dev || metadata.ino !== beforeOpen.ino) { + if (!sameFileIdentity(metadata, beforeOpen)) { throw packetError( "LOCAL_PACKET_FILE_INVALID", "The local packet changed while it was being opened." @@ -644,7 +645,7 @@ async function readPacketJson(path: string): Promise { } await assertNoSymbolicLinkComponents(path); const afterOpen = await lstat(path); - if (afterOpen.dev !== metadata.dev || afterOpen.ino !== metadata.ino) { + if (!sameFileIdentity(afterOpen, metadata)) { throw packetError( "LOCAL_PACKET_FILE_INVALID", "The local packet path changed after it was opened." diff --git a/src/relay/journal.ts b/src/relay/journal.ts index 6789e85..dd4fd09 100644 --- a/src/relay/journal.ts +++ b/src/relay/journal.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { z } from "zod"; import { AwError } from "../errors.js"; +import { sameFileIdentity } from "../system/file-identity.js"; import { canonicalize, sha256 } from "../util/canonical.js"; import { PacketBindingSchema, @@ -892,10 +893,6 @@ async function verifiedJournalIdentity(path: string, handle: FileHandle): Promis return handleIdentity; } -function sameFileIdentity(left: Stats, right: Stats): boolean { - return left.dev === right.dev && left.ino === right.ino; -} - function journalChanged(message: string, cause?: unknown): AwError { return new AwError({ code: "JOURNAL_CHANGED", diff --git a/src/relay/run-intent.ts b/src/relay/run-intent.ts index 5d06a90..004ccee 100644 --- a/src/relay/run-intent.ts +++ b/src/relay/run-intent.ts @@ -13,6 +13,7 @@ import { type RunStatusResponse } from "../cloud/protocol.js"; import { AwError } from "../errors.js"; +import { sameFileIdentity } from "../system/file-identity.js"; import { canonicalize, sha256 } from "../util/canonical.js"; import { getStateDirectory } from "./state-dir.js"; import { acquireSecureLock, ensureSecureDirectory, type SecureLockHandle } from "./secure-lock.js"; @@ -540,7 +541,7 @@ async function readIntent(path: string): Promise=22.12`, so those releases are supported. A zero `dev` + * is unknown on Windows; the file index still has to match, and two nonzero + * device ids still have to match. + */ +export function sameFileIdentity( + left: { readonly dev: number; readonly ino: number }, + right: { readonly dev: number; readonly ino: number }, + platform: NodeJS.Platform = process.platform +): boolean { + if (left.ino !== right.ino) return false; + if (left.dev === right.dev) return true; + return platform === "win32" && (left.dev === 0 || right.dev === 0); +} diff --git a/test/system/file-identity.test.ts b/test/system/file-identity.test.ts new file mode 100644 index 0000000..88873f8 --- /dev/null +++ b/test/system/file-identity.test.ts @@ -0,0 +1,47 @@ +import { mkdtemp, open, rm, writeFile } from "node:fs/promises"; +import { lstat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { sameFileIdentity } from "../../src/system/file-identity.js"; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })) + ); +}); + +describe("sameFileIdentity", () => { + it("requires the same inode and device id", () => { + expect(sameFileIdentity({ dev: 1, ino: 2 }, { dev: 1, ino: 2 }, "linux")).toBe(true); + expect(sameFileIdentity({ dev: 1, ino: 2 }, { dev: 1, ino: 3 }, "linux")).toBe(false); + expect(sameFileIdentity({ dev: 1, ino: 2 }, { dev: 9, ino: 2 }, "linux")).toBe(false); + expect(sameFileIdentity({ dev: 1, ino: 2 }, { dev: 9, ino: 2 }, "win32")).toBe(false); + expect(sameFileIdentity({ dev: 0, ino: 2 }, { dev: 9, ino: 2 }, "linux")).toBe(false); + }); + + it("treats a zero Windows dev as unknown when the inode matches", () => { + expect(sameFileIdentity({ dev: 0, ino: 2 }, { dev: 9, ino: 2 }, "win32")).toBe(true); + expect(sameFileIdentity({ dev: 9, ino: 2 }, { dev: 0, ino: 2 }, "win32")).toBe(true); + expect(sameFileIdentity({ dev: 0, ino: 2 }, { dev: 0, ino: 2 }, "win32")).toBe(true); + expect(sameFileIdentity({ dev: 0, ino: 2 }, { dev: 9, ino: 3 }, "win32")).toBe(false); + }); + + it("treats a path lstat and a handle stat as the same file", async () => { + const directory = await mkdtemp(join(tmpdir(), "augmentworks-file-identity-")); + directories.push(directory); + const path = join(directory, "file.txt"); + await writeFile(path, "ok"); + const beforeOpen = await lstat(path); + const handle = await open(path, "r"); + try { + expect(sameFileIdentity(beforeOpen, await handle.stat())).toBe(true); + } finally { + await handle.close(); + } + }); +});