diff --git a/extensions/shared/child-session.ts b/extensions/shared/child-session.ts index e37fc3f5..9e0f8033 100644 --- a/extensions/shared/child-session.ts +++ b/extensions/shared/child-session.ts @@ -173,9 +173,6 @@ function packageSourceValue(source: PackageSource) { const CHILD_DISABLED_OPENPI_EXTENSION = "-extensions/git-info/index.ts" as const; -const OPENPI_GIT_INFO_EXTENSION_PATH = realpathSync.native( - fileURLToPath(new URL("../git-info/index.ts", import.meta.url)), -); function canonicalExistingPath(value: string) { try { @@ -185,15 +182,38 @@ function canonicalExistingPath(value: string) { } } +/** + * ENOENT means git-info is truly absent (trimmed fork, partial install): + * nothing to exclude. Any other failure (permissions, symlink loops) means we + * cannot verify the Git-polling extension is present, so fail closed instead + * of running a child with an unverifiable extension. + */ +export function resolveGitInfoPathOrThrow( + resolve: (value: string) => string = realpathSync.native, +): string | undefined { + try { + return resolve( + fileURLToPath(new URL("../git-info/index.ts", import.meta.url)), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + function excludeOpenPiGitInfoExtension( resources: LoadExtensionsResult, + resolve: (value: string) => string = realpathSync.native, ): LoadExtensionsResult { + const gitInfoPath = resolveGitInfoPathOrThrow(resolve); + // undefined = absent (ENOENT): nothing to exclude. Non-ENOENT failures + // throw from resolveGitInfoPathOrThrow and fail child creation upstream. + if (gitInfoPath === undefined) return resources; return { ...resources, extensions: resources.extensions.filter( (extension) => - canonicalExistingPath(extension.resolvedPath) !== - OPENPI_GIT_INFO_EXTENSION_PATH, + canonicalExistingPath(extension.resolvedPath) !== gitInfoPath, ), }; } diff --git a/tests/extensions/shared/child-session.test.ts b/tests/extensions/shared/child-session.test.ts index 4ef5daeb..4864fc9a 100644 --- a/tests/extensions/shared/child-session.test.ts +++ b/tests/extensions/shared/child-session.test.ts @@ -13,7 +13,7 @@ import { syncBuiltinESMExports } from "node:module"; import { tmpdir } from "node:os"; import * as path from "node:path"; import test from "node:test"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { createAgentSession, DefaultPackageManager, @@ -34,6 +34,7 @@ import { createChildResources, type DisposableChildSession, effectiveChildToolAllowlist, + resolveGitInfoPathOrThrow, resolveStandaloneChildProjectTrust, shutdownAndDisposeChildSession, } from "../../../extensions/shared/child-session.ts"; @@ -1470,3 +1471,31 @@ test("every registered package tool is classified child-safe or excluded (fail-c ); } }); + +test("git-info exclusion: ENOENT degrades, other errors fail closed", async () => { + const enoent: NodeJS.ErrnoException = new Error("no such file"); + enoent.code = "ENOENT"; + // Absent (ENOENT) -> undefined, so nothing is excluded. + assert.equal( + resolveGitInfoPathOrThrow(() => { + throw enoent; + }), + undefined, + ); + // Present -> the real path is returned for exclusion matching. + assert.equal( + resolveGitInfoPathOrThrow(() => "/repo/extensions/git-info/index.ts"), + "/repo/extensions/git-info/index.ts", + ); + // Unverifiable (non-ENOENT) -> must throw, not silently degrade. + const eacces: NodeJS.ErrnoException = new Error("permission denied"); + eacces.code = "EACCES"; + assert.throws( + () => + resolveGitInfoPathOrThrow(() => { + throw eacces; + }), + (error: unknown) => (error as NodeJS.ErrnoException).code === "EACCES", + "non-ENOENT realpath failures must fail closed", + ); +});