diff --git a/lib/harness-start-hook.ts b/lib/harness-start-hook.ts index a106052b7..a81989b5f 100644 --- a/lib/harness-start-hook.ts +++ b/lib/harness-start-hook.ts @@ -158,8 +158,12 @@ export function inspectHook(t: HookTarget, profile: string, env: Env, home: stri } /** True when a plugin folder holds nothing but the .edited- copies gstack kept when it turned the hook off. */ -const onlyKeptEdits = (root: string) => fs.readdirSync(root, { recursive: true, withFileTypes: true }) - .every(e => e.isDirectory() || (e.isFile() && /\.edited-[0-9T-]+Z$/.test(e.name))); +function onlyKeptEdits(root: string): boolean { + let entries: fs.Dirent[]; + try { entries = fs.readdirSync(root, { recursive: true, withFileTypes: true }); } catch { return false; } + const files = entries.filter(e => !e.isDirectory()); + return files.length > 0 && files.every(e => e.isFile() && /\.edited-[0-9T-]+Z$/.test(e.name)); +} /** Checks the plugin folder before anything is written: under the profile, no symlink from the profile down, and not someone else's. */ export function assertHookRoot(t: HookTarget, profile: string, root: string, recordedRoot: string | undefined, fail: (code: 'HARNESS_UNSAFE_DESTINATION' | 'HARNESS_HOOK_OWNED_ELSEWHERE', msg: string) => never): void { diff --git a/test/harness-start-hook.test.ts b/test/harness-start-hook.test.ts index 180587290..7c47aea5f 100644 --- a/test/harness-start-hook.test.ts +++ b/test/harness-start-hook.test.ts @@ -169,6 +169,17 @@ describe('start hook: who decides', () => { expect(r.calls().concat(s.calls())).toEqual([]); }); + test('a file, an empty folder or a folder with any non-kept file at the plugin path is still owned elsewhere', () => { + for (const make of [(root: string) => fs.writeFileSync(root, 'x'), (root: string) => fs.mkdirSync(path.join(root, 'sub'), { recursive: true }), + (root: string) => { fs.mkdirSync(root, { recursive: true }); fs.writeFileSync(path.join(root, 'note.txt.edited-2026-11-01T06-31-18-911Z'), 'kept'); fs.writeFileSync(path.join(root, 'plugin.yaml'), 'mine'); }]) { + const r = rig('hermes'); + fs.mkdirSync(path.dirname(r.root), { recursive: true }); + make(r.root); + expect(() => register(r.o({ startHook: true }))).toThrow(expect.objectContaining({ code: 'HARNESS_HOOK_OWNED_ELSEWHERE' })); + expect(register({ ...r.o(), env: { ...r.env, GSTACK_HARNESS_START_HOOK: '1' } }).hook?.state).toBe('off'); + } + }, 300_000); + test('a profile path with a trailing slash under a symlinked parent still takes the hook and is saved without the slash', () => { const r = rig('hermes'); const link = path.join(tmp(), 'ln');