diff --git a/src/application/ClientConfigurationFiles.ts b/src/application/ClientConfigurationFiles.ts index 5982f53a9..a3ff426e7 100644 --- a/src/application/ClientConfigurationFiles.ts +++ b/src/application/ClientConfigurationFiles.ts @@ -1,5 +1,5 @@ import { err, ok, type Result } from "../domain/result.js"; -import { readFile } from "node:fs/promises"; +import { readRegularFileText } from "./RegularFileRead.js"; import { effectiveClientServer, parseClientConfiguration, @@ -19,7 +19,7 @@ export const readConfigurationText = async ( path: string, ): Promise> => { try { - return ok(await readFile(path, "utf8")); + return ok(await readRegularFileText(path)); } catch (cause: unknown) { if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") return ok(undefined); diff --git a/src/application/ReferenceSourceImportPolicy.ts b/src/application/ReferenceSourceImportPolicy.ts index 0cb467d66..e358c3636 100644 --- a/src/application/ReferenceSourceImportPolicy.ts +++ b/src/application/ReferenceSourceImportPolicy.ts @@ -1,7 +1,8 @@ -import { readFile, realpath, stat } from "node:fs/promises"; +import { lstat, realpath, stat } from "node:fs/promises"; import { join, resolve } from "node:path"; import ignore from "ignore"; +import { readRegularFileText } from "./RegularFileRead.js"; import { err, ok, type Result } from "../domain/result.js"; import { @@ -47,14 +48,27 @@ const resolveRoot = async ( const buildIgnored = async ( root: string, excludePaths: readonly string[], + signal?: AbortSignal, ): Promise> => { const ignored = ignore(); + const policyPath = join(root, ".gitignore"); + let present = true; try { - ignored.add(await readFile(join(root, ".gitignore"), "utf8")); + await lstat(policyPath); } catch (cause: unknown) { - // Missing or unreadable ignore file does not authorize broader access. - void cause; + if ( + !( + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "ENOENT" + ) + ) + throw cause; + present = false; } + signal?.throwIfAborted(); + if (present) ignored.add(await readRegularFileText(policyPath, { signal })); ignored.add([...DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS]); for (const path of excludePaths) { ignored.add(path); @@ -71,9 +85,22 @@ export const prepareReferenceSourceImport = async ( > => { const root = await resolveRoot(options.root); if (!root.ok) return root; - return ok({ - root: root.value, - ignored: await buildIgnored(root.value, options.excludePaths ?? []), - secrets: ignore().add([...options.policy.secretPatterns]), - }); + try { + return ok({ + root: root.value, + ignored: await buildIgnored( + root.value, + options.excludePaths ?? [], + options.signal, + ), + secrets: ignore().add([...options.policy.secretPatterns]), + }); + } catch (cause: unknown) { + return err( + failure( + options.signal?.aborted === true ? "cancelled" : "io", + `Reference source ignore policy could not be read at ${join(root.value, ".gitignore")}: ${cause instanceof Error ? cause.message : String(cause)}`, + ), + ); + } }; diff --git a/src/application/ReferenceSourceVcsAdapter.ts b/src/application/ReferenceSourceVcsAdapter.ts index 3ddad25d7..ef136a2ad 100644 --- a/src/application/ReferenceSourceVcsAdapter.ts +++ b/src/application/ReferenceSourceVcsAdapter.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; -import { lstat, readFile } from "node:fs/promises"; +import { lstat } from "node:fs/promises"; import { join, resolve } from "node:path"; +import { readRegularFile, readRegularFileText } from "./RegularFileRead.js"; export type ReferenceSourceVcsInfo = | { @@ -29,7 +30,7 @@ export const readReferenceSourceVcs = async ( : { kind: "unknown", head: null, dirty: null }; } try { - const head = await resolveSourceHead(root); + const head = await resolveSourceHead(root, signal); if (isAborted(signal)) return { kind: "unknown", head: null, dirty: null }; return { kind: "git", head, dirty: null }; } catch (cause: unknown) { @@ -39,13 +40,44 @@ export const readReferenceSourceVcs = async ( } }; -const resolveSourceHead = async (root: string): Promise => { +const resolveSourceHead = async ( + root: string, + signal?: AbortSignal, +): Promise => { // isomorphic-git is loaded on first use so CLI and MCP startup skip it. const { resolveRef } = await import("isomorphic-git"); + let failedRead: { readonly cause: unknown } | undefined; + const referenceFs = { + ...fs, + promises: { + ...fs.promises, + // isomorphic-git treats every read failure as absence. Preserve real + // failures so a readable packed ref cannot hide an unreadable loose ref. + readFile: async (path: string) => { + try { + return await readRegularFile(path, { signal }); + } catch (cause: unknown) { + if (errorCode(cause) !== "ENOENT") failedRead ??= { cause }; + throw cause; + } + }, + }, + }; + const readRef = async (options: { + readonly gitdir?: string; + readonly ref: string; + }) => { + const head = await resolveRef({ fs: referenceFs, dir: root, ...options }); + signal?.throwIfAborted(); + if (failedRead !== undefined) throw failedRead.cause; + return head; + }; const dotgit = join(root, ".git"); - if (!(await lstat(dotgit)).isFile()) - return resolveRef({ fs, dir: root, ref: "HEAD" }); - const pointer = (await readFile(dotgit, "utf8")).replace(/\r?\n$/u, ""); + if (!(await lstat(dotgit)).isFile()) return readRef({ ref: "HEAD" }); + const pointer = (await readRegularFileText(dotgit, { signal })).replace( + /\r?\n$/u, + "", + ); if (!pointer.startsWith("gitdir: ") || pointer.length === 8) throw new Error("Invalid Git directory pointer"); const gitdir = resolve(root, pointer.slice(8)); @@ -53,25 +85,23 @@ const resolveSourceHead = async (root: string): Promise => { try { commonDirectory = resolve( gitdir, - (await readFile(join(gitdir, "commondir"), "utf8")).replace( - /\r?\n$/u, - "", - ), + ( + await readRegularFileText(join(gitdir, "commondir"), { signal }) + ).replace(/\r?\n$/u, ""), ); } catch (cause: unknown) { if (errorCode(cause) !== "ENOENT") throw cause; - return resolveRef({ fs, dir: root, gitdir, ref: "HEAD" }); + return readRef({ gitdir, ref: "HEAD" }); } - const head = (await readFile(join(gitdir, "HEAD"), "utf8")).trim(); - if (!head.startsWith("ref: ")) - return resolveRef({ fs, dir: root, gitdir, ref: "HEAD" }); + const head = ( + await readRegularFileText(join(gitdir, "HEAD"), { signal }) + ).trim(); + if (!head.startsWith("ref: ")) return readRef({ gitdir, ref: "HEAD" }); const ref = head.slice(5); const privateRef = ["refs/bisect/", "refs/rewritten/", "refs/worktree/"].some( (prefix) => ref.startsWith(prefix), ); - return resolveRef({ - fs, - dir: root, + return readRef({ gitdir: privateRef ? gitdir : commonDirectory, ref, }); diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index 5cbf95206..4ae8a5ff7 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -14,7 +14,7 @@ import { type ClientRegistrationDialect, } from "./ClientConfigurationDocument.js"; import { constants as fsConstants } from "node:fs"; -import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, rm, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import writeFileAtomic from "write-file-atomic"; @@ -22,6 +22,7 @@ import { PRODUCT_IDENTITY } from "../identity.js"; import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js"; import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js"; import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js"; +import { readRegularFileText } from "./RegularFileRead.js"; import type { ClientConfigurationInspection, ClientConfigurationResult, @@ -64,7 +65,7 @@ const configureClientDocument = async ( return { status: "failed", reason: "path" }; let original: string | undefined; try { - original = await readFile(transactionPath, "utf8"); + original = await readRegularFileText(transactionPath); } catch (cause: unknown) { if (!isMissing(cause)) return { status: "failed", reason: "readback" }; } @@ -149,7 +150,7 @@ const configureClientDocument = async ( } try { const readback = parseClientConfiguration( - await readFile(transactionPath, "utf8"), + await readRegularFileText(transactionPath), format, ); if (!registrationCurrent(readback, desired)) { @@ -178,7 +179,7 @@ export const clientConfigurationAligned = async ( try { const files = await readClientConfigurationFiles(client); if (!files.ok) return false; - const original = await readFile(client.configPath, "utf8"); + const original = await readRegularFileText(client.configPath); const parsed = parseClientConfiguration(original, client.format); const policy = await readClientPolicyBlock( client, @@ -246,7 +247,7 @@ export const inspectClientConfiguration = async ( }; let original: string; try { - original = await readFile(transactionPath, "utf8"); + original = await readRegularFileText(transactionPath); } catch (cause: unknown) { if (isMissing(cause)) return { status: "create" }; return { diff --git a/src/application/SetupSkill.ts b/src/application/SetupSkill.ts index b0770c31a..25228d14b 100644 --- a/src/application/SetupSkill.ts +++ b/src/application/SetupSkill.ts @@ -4,10 +4,8 @@ import { dirname, join } from "node:path"; import writeFileAtomic from "write-file-atomic"; import { PRODUCT_IDENTITY } from "../identity.js"; -import { - NonRegularFileReadError, - openRegularFile, -} from "../filesystem/RegularFile.js"; +import { NonRegularFileReadError } from "../filesystem/RegularFile.js"; +import { readRegularFileText } from "./RegularFileRead.js"; import { clientSkillDirectories } from "./SupportedClients.js"; export const MANAGED_SKILL_FILES = [ @@ -23,14 +21,8 @@ export const isManagedSkillManifest = (content: string): boolean => content.startsWith(`---\nname: ${PRODUCT_IDENTITY.skillName}\n`) || content.startsWith(`---\r\nname: ${PRODUCT_IDENTITY.skillName}\r\n`); -export const readSkillFile = async (path: string): Promise => { - const handle = await openRegularFile(path, { symlinks: "reject" }); - try { - return await handle.readFile("utf8"); - } finally { - await handle.close(); - } -}; +export const readSkillFile = (path: string): Promise => + readRegularFileText(path, { symlinks: "reject" }); interface CanonicalSkillFile { readonly content: string; @@ -199,7 +191,7 @@ export const installCanonicalSkill = async ( for (const { destination, content } of changed) await writeText(destination, content); for (const { destination, content } of changed) - if ((await readFile(destination, "utf8")) !== content) + if ((await readSkillFile(destination)) !== content) throw new Error(`skill readback mismatch: ${destination}`); return "installed"; } catch (cause: unknown) { @@ -231,7 +223,7 @@ export const readInstalledSkillIdentity = async ( let content: string | undefined; for (const destination of destinations) { try { - content = await readFile(join(destination.path, "SKILL.md"), "utf8"); + content = await readSkillFile(join(destination.path, "SKILL.md")); break; } catch { // Another selected copy may provide metadata; canonical validation below diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index 73986c3c3..6310ce99b 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -6,7 +6,7 @@ import { withClientServers, type ClientConfigurationDocument, } from "./ClientConfigurationDocument.js"; -import { copyFile, lstat, readFile, realpath, rm } from "node:fs/promises"; +import { copyFile, lstat, realpath, rm } from "node:fs/promises"; import { constants as fsConstants } from "node:fs"; import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; import { join } from "node:path"; @@ -23,6 +23,7 @@ import { } from "./SetupSkill.js"; import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js"; import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js"; +import { readRegularFileText } from "./RegularFileRead.js"; import { manualRegistrationRemediation, supportedClients, @@ -49,7 +50,7 @@ export interface UninstallFileSystem { } const systemFileSystem: UninstallFileSystem = { - readText: (path) => readFile(path, "utf8"), + readText: (path) => readRegularFileText(path), readRegularText: readSkillFile, copy: (source, destination) => copyFile(source, destination, fsConstants.COPYFILE_EXCL), diff --git a/src/application/UpdateMaintenance.ts b/src/application/UpdateMaintenance.ts index 40d66bcbc..2a5a286d5 100644 --- a/src/application/UpdateMaintenance.ts +++ b/src/application/UpdateMaintenance.ts @@ -1,4 +1,3 @@ -import { readFile } from "node:fs/promises"; import { z } from "zod"; import { PRODUCT_IDENTITY } from "../identity.js"; @@ -14,6 +13,7 @@ import { supportedClients } from "./SupportedClients.js"; import { existingSkillDestinations } from "./SetupSkill.js"; import type { SetupAction } from "./SetupTypes.js"; import type { Result } from "../domain/result.js"; +import { readRegularFileText } from "./RegularFileRead.js"; /** Existing REA integrations selected for maintenance, without discovering new targets. */ export interface MaintenanceScope { @@ -73,7 +73,7 @@ export const existingMaintenanceScope = async ( const client = supported.find(({ name }) => name === registration.client); if (client === undefined) continue; const parsed = parseClientConfiguration( - await readFile(client.configPath, "utf8"), + await readRegularFileText(client.configPath), client.format, ); const enabled = z @@ -130,7 +130,9 @@ export const planIntegrationMaintenance = async ( }; if (plan.data.plannedActions.length === 0) return { status: "current", plannedActions: [] }; - const allowedIds = new Set(scope.clients.map((client) => `configure_client:${client}`)); + const allowedIds = new Set( + scope.clients.map((client) => `configure_client:${client}`), + ); const skillTargets = new Set(skillDestinations.map(({ path }) => path)); if ( plan.data.plannedActions.some((action) =>