From f276da838fd6a019f4ae325781ed8832722fed0e Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 06:52:47 +0000 Subject: [PATCH] fix(harness): any saved plugin root is checked for symlinks before an opt-out deletes through it Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- lib/harness-register.ts | 3 ++- test/harness-start-hook.test.ts | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/harness-register.ts b/lib/harness-register.ts index 9485a3f70..8768a3b35 100644 --- a/lib/harness-register.ts +++ b/lib/harness-register.ts @@ -540,7 +540,8 @@ function hookPlan(opts: RegisterOptions, dest: string, prev: Ledger | null): Hoo const profile = prevOn ? prev!.meta.hook_profile : hookProfile(t.name, opts.env, opts.home); const root = hookRoot(t.name, profile); const plan = { on: choice.on, prevOn, explicit: choice.source === 'flag', retry: prevOn && prev!.meta.hook_active === 'no', profile, root, files: new Map() }; - if (prevOn && !choice.on) assertHookRoot(t.name, profile, root, root, (code, msg) => { throw new HarnessError(code, msg); }); + const saved = prev?.meta.hook_root && prev.meta.hook_profile ? { profile: prev.meta.hook_profile, root: prev.meta.hook_root } : null; + if (saved && !choice.on) assertHookRoot(t.name, saved.profile, saved.root, saved.root, (code, msg) => { throw new HarnessError(code, msg); }); if (!choice.on) return plan; const why = dest !== profileSkills(t.name, profile) ? 'this registration uses another skills folder (' + dest + ')' : t.name === 'openclaw' && opts.env.OPENCLAW_CONFIG_PATH ? 'OPENCLAW_CONFIG_PATH points OpenClaw at a config outside that profile' : ''; diff --git a/test/harness-start-hook.test.ts b/test/harness-start-hook.test.ts index b8a1e43f3..33cb17ff0 100644 --- a/test/harness-start-hook.test.ts +++ b/test/harness-start-hook.test.ts @@ -420,6 +420,13 @@ describe('start hook: teardown keeps what gstack did not write (fake CLI)', () = expect(() => register(r.o({ startHook: false }))).toThrow(/EACCES/); expect(readLedger(on.ledger)!.meta.hook_root).toBe(r.root); } finally { fs.chmodSync(r.root, 0o755); } + const ext = path.join(r.profile, 'extensions'), moved = path.join(r.d, 'moved'); + fs.renameSync(ext, moved); + fs.symlinkSync(moved, ext); + expect(() => register(r.o({ startHook: false }))).toThrow(expect.objectContaining({ code: 'HARNESS_UNSAFE_DESTINATION' })); + expect(fs.existsSync(path.join(moved, HOOK_ID, 'index.js'))).toBe(true); + fs.unlinkSync(ext); + fs.renameSync(moved, ext); const off = register(r.o({ startHook: false })); expect(off.hook?.state).toBe('off'); expect(fs.existsSync(path.join(r.profile, 'extensions'))).toBe(false);