From ac8d091644649d0df559b4274ec5cc061bb7b6a2 Mon Sep 17 00:00:00 2001 From: Dhravya Date: Fri, 9 Oct 2026 05:07:07 +0000 Subject: [PATCH 1/3] fix: safely construct and migrate installed hook commands Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .github/workflows/hook-paths.yml | 27 +++++ package.json | 2 +- src/cli.ts | 84 +++++++------- test/hook-paths.mjs | 185 +++++++++++++++++++++++++++++++ 4 files changed, 251 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/hook-paths.yml create mode 100644 test/hook-paths.mjs diff --git a/.github/workflows/hook-paths.yml b/.github/workflows/hook-paths.yml new file mode 100644 index 0000000..69ab405 --- /dev/null +++ b/.github/workflows/hook-paths.yml @@ -0,0 +1,27 @@ +name: Hook Path Regression Tests + +on: + pull_request: + paths: + - src/** + - test/hook-paths.mjs + - build.mjs + - package.json + - package-lock.json + - .github/workflows/hook-paths.yml + +jobs: + hook-paths: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: "24" + - run: npm ci + - run: npm run typecheck + - run: npm run build + - run: node --test test/hook-paths.mjs diff --git a/package.json b/package.json index 861ee9c..5df5930 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "scripts": { "build": "node build.mjs", "typecheck": "tsc --noEmit", - "test": "npm run build && node --test test/unit.mjs" + "test": "npm run build && node --test test/unit.mjs test/hook-paths.mjs" }, "keywords": [ "codex", diff --git a/src/cli.ts b/src/cli.ts index 302e31f..0ab62f7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -289,24 +289,34 @@ function normalizeHookEvents(raw: unknown): HookEvents { return events; } -/** - * Ensure a hook is registered in the given event's MatcherGroup array. - * If the command already exists, update its timeout and statusMessage. - * Otherwise, append it to an existing global (no-matcher) group or create one. - */ +function hookCommand(script: string): string { + if (process.platform === "win32") { + const encodedPath = Buffer.from(script, "utf-8").toString("base64"); + return `node -e "require(Buffer.from('${encodedPath}','base64').toString('utf-8'))"`; + } + if (/^[a-zA-Z0-9_./-]+$/.test(script)) return `node ${script}`; + return `node '${script.replaceAll("'", "'\\''")}'`; +} + +function matchesHookCommand(command: string, script: string): boolean { + return command === hookCommand(script) || command === `node ${script}`; +} + function ensureHookRegistered( groups: MatcherGroup[], - command: string, + script: string, timeout: number, statusMessage: string, background = false, matcher?: string, ): void { - const exists = groups.some((g) => g.hooks.some((h) => h.command === command)); + const command = hookCommand(script); + const exists = groups.some((g) => g.hooks.some((h) => matchesHookCommand(h.command, script))); if (exists) { for (const group of groups) { for (const hook of group.hooks) { - if (hook.command === command) { + if (matchesHookCommand(hook.command, script)) { + hook.command = command; hook.timeout = timeout; hook.statusMessage = statusMessage; if (background) hook.async = true; @@ -333,16 +343,14 @@ function ensureHookRegistered( } } -/** - * Remove all hooks matching any of the given commands from an event's groups. - * Returns the filtered groups (empty groups are dropped). - */ function removeHookCommands( groups: MatcherGroup[], - commands: string[], + scripts: string[], ): MatcherGroup[] { return groups - .map((g) => ({ ...g, hooks: g.hooks.filter((h) => !commands.includes(h.command)) })) + .map((g) => ({ ...g, hooks: g.hooks.filter((h) => + !scripts.some((script) => matchesHookCommand(h.command, script)) + ) })) .filter((g) => g.hooks.length > 0); } @@ -353,37 +361,32 @@ function mergeHooksJson(add: boolean) { } const hooks = readHooksJson(); + const oldCaptureScript = join(SUPERMEMORY_HOOKS_DIR, "capture.js"); + const oldTurnCaptureScript = join(SUPERMEMORY_HOOKS_DIR, "capture-turn.js"); if (add) { - const recallCmd = `node ${RECALL_SCRIPT}`; - const recallApproveCmd = `node ${RECALL_APPROVE_SCRIPT}`; - const flushCmd = `node ${FLUSH_SCRIPT}`; - const sessionStartCmd = `node ${SESSION_START_SCRIPT}`; - const oldCaptureCmd = `node ${join(SUPERMEMORY_HOOKS_DIR, "capture.js")}`; - const oldTurnCaptureCmd = `node ${join(SUPERMEMORY_HOOKS_DIR, "capture-turn.js")}`; - if (!hooks.SessionStart) hooks.SessionStart = []; ensureHookRegistered( hooks.SessionStart, - sessionStartCmd, + SESSION_START_SCRIPT, SESSION_START_TIMEOUT_SECONDS, "Loading memory profile...", ); // Recall must stay synchronous because its output is injected. if (!hooks.UserPromptSubmit) hooks.UserPromptSubmit = []; - ensureHookRegistered(hooks.UserPromptSubmit, recallCmd, RECALL_TIMEOUT_SECONDS, "Searching memories..."); + ensureHookRegistered(hooks.UserPromptSubmit, RECALL_SCRIPT, RECALL_TIMEOUT_SECONDS, "Searching memories..."); // Remove the old per-prompt capture hook. Stop now owns automatic capture. hooks.UserPromptSubmit = removeHookCommands( hooks.UserPromptSubmit, - [oldTurnCaptureCmd], + [oldTurnCaptureScript], ); if (!hooks.PreToolUse) hooks.PreToolUse = []; ensureHookRegistered( hooks.PreToolUse, - recallApproveCmd, + RECALL_APPROVE_SCRIPT, RECALL_APPROVE_TIMEOUT_SECONDS, "Checking Supermemory recall...", false, @@ -392,7 +395,7 @@ function mergeHooksJson(add: boolean) { // Remove old capture.js Stop hook from previous installs if (hooks.Stop) { - hooks.Stop = removeHookCommands(hooks.Stop, [oldCaptureCmd]); + hooks.Stop = removeHookCommands(hooks.Stop, [oldCaptureScript]); if (hooks.Stop.length === 0) delete hooks.Stop; } @@ -400,37 +403,30 @@ function mergeHooksJson(add: boolean) { if (!hooks.Stop) hooks.Stop = []; ensureHookRegistered( hooks.Stop, - flushCmd, + FLUSH_SCRIPT, FLUSH_TIMEOUT_SECONDS, "Saving to memory...", true, ); } else { // Remove our hooks from every MatcherGroup, then drop empty groups. - const recallCmd = `node ${RECALL_SCRIPT}`; - const recallApproveCmd = `node ${RECALL_APPROVE_SCRIPT}`; - const flushCmd = `node ${FLUSH_SCRIPT}`; - const sessionStartCmd = `node ${SESSION_START_SCRIPT}`; - const oldCaptureCmd = `node ${join(SUPERMEMORY_HOOKS_DIR, "capture.js")}`; - const oldTurnCaptureCmd = `node ${join(SUPERMEMORY_HOOKS_DIR, "capture-turn.js")}`; - if (hooks.SessionStart) { - hooks.SessionStart = removeHookCommands(hooks.SessionStart, [sessionStartCmd]); + hooks.SessionStart = removeHookCommands(hooks.SessionStart, [SESSION_START_SCRIPT]); if (hooks.SessionStart.length === 0) delete hooks.SessionStart; } if (hooks.UserPromptSubmit) { hooks.UserPromptSubmit = removeHookCommands( hooks.UserPromptSubmit, - [recallCmd, oldTurnCaptureCmd], + [RECALL_SCRIPT, oldTurnCaptureScript], ); if (hooks.UserPromptSubmit.length === 0) delete hooks.UserPromptSubmit; } if (hooks.PreToolUse) { - hooks.PreToolUse = removeHookCommands(hooks.PreToolUse, [recallApproveCmd]); + hooks.PreToolUse = removeHookCommands(hooks.PreToolUse, [RECALL_APPROVE_SCRIPT]); if (hooks.PreToolUse.length === 0) delete hooks.PreToolUse; } if (hooks.Stop) { - hooks.Stop = removeHookCommands(hooks.Stop, [flushCmd, oldCaptureCmd]); + hooks.Stop = removeHookCommands(hooks.Stop, [FLUSH_SCRIPT, oldCaptureScript]); if (hooks.Stop.length === 0) delete hooks.Stop; } } @@ -603,22 +599,18 @@ function status() { if (hooksJsonExists) { try { const hooks = normalizeHookEvents(JSON.parse(readFileSync(CODEX_HOOKS_JSON, "utf-8"))); - const recallCmd = `node ${RECALL_SCRIPT}`; - const recallApproveCmd = `node ${RECALL_APPROVE_SCRIPT}`; - const flushCmd = `node ${FLUSH_SCRIPT}`; - const sessionStartCmd = `node ${SESSION_START_SCRIPT}`; const recallRegistered = hooks.UserPromptSubmit?.some((g: MatcherGroup) => - g.hooks.some((h: HookEntry) => h.command === recallCmd) + g.hooks.some((h: HookEntry) => matchesHookCommand(h.command, RECALL_SCRIPT)) ); const recallApproveRegistered = hooks.PreToolUse?.some((g: MatcherGroup) => g.matcher === SUPERMEMORY_MCP_MATCHER && - g.hooks.some((h: HookEntry) => h.command === recallApproveCmd) + g.hooks.some((h: HookEntry) => matchesHookCommand(h.command, RECALL_APPROVE_SCRIPT)) ); const flushRegistered = hooks.Stop?.some((g: MatcherGroup) => - g.hooks.some((h: HookEntry) => h.command === flushCmd && h.async === true) + g.hooks.some((h: HookEntry) => matchesHookCommand(h.command, FLUSH_SCRIPT) && h.async === true) ); const sessionStartRegistered = hooks.SessionStart?.some((g: MatcherGroup) => - g.hooks.some((h: HookEntry) => h.command === sessionStartCmd) + g.hooks.some((h: HookEntry) => matchesHookCommand(h.command, SESSION_START_SCRIPT)) ); hooksEnabled = !!( recallRegistered && diff --git a/test/hook-paths.mjs b/test/hook-paths.mjs new file mode 100644 index 0000000..fd3f80b --- /dev/null +++ b/test/hook-paths.mjs @@ -0,0 +1,185 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const cliBin = fileURLToPath(new URL("../dist/cli.js", import.meta.url)); +const scripts = { + SessionStart: "session-start.js", + UserPromptSubmit: "recall.js", + PreToolUse: "recall-approve.js", + Stop: "flush.js", +}; + +function fixture(t, name, platform = process.platform) { + const root = mkdtempSync(join(tmpdir(), "csm-hook-path-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const home = join(root, name); + const codexDir = join(home, ".codex"); + mkdirSync(codexDir, { recursive: true }); + const preload = join(root, "boundary.cjs"); + writeFileSync(preload, ` + require('node:os').homedir = () => process.env.TEST_CODEX_HOME; + Object.defineProperty(process, 'platform', { value: ${JSON.stringify(platform)} }); + global.fetch = async () => { throw Error('Network disabled in hook path tests'); }; + for (const name of ['node:http', 'node:https', 'node:net']) { + const module = require(name); + for (const key of ['request', 'get', 'connect', 'createConnection']) { + if (typeof module[key] === 'function') module[key] = () => { throw Error('Network disabled'); }; + } + } + const Module = require('node:module'); + const load = Module._extensions['.js']; + Module._extensions['.js'] = (module, filename) => { + if (require('node:path').dirname(filename) === require('node:path').join(process.env.TEST_CODEX_HOME, '.codex', 'supermemory')) { + process.stderr.write('HOOK_STARTED:' + require('node:path').basename(filename) + '\\n'); + } + return load(module, filename); + }; + `); + const env = { + ...process.env, + HOME: home, + USERPROFILE: home, + TEST_CODEX_HOME: home, + SUPERMEMORY_CODEX_API_KEY: "sm_DUMMY_PATH_TEST", + NODE_OPTIONS: `--require=${JSON.stringify(preload)}`, + }; + const hooksPath = join(codexDir, "hooks.json"); + return { + home, codexDir, hooksPath, env, + cli(command) { + const result = spawnSync(process.execPath, [cliBin, command], { env, encoding: "utf8", timeout: 10000 }); + assert.equal(result.status, 0, result.stderr); + return result.stdout; + }, + hooks() { + return JSON.parse(readFileSync(hooksPath, "utf8")).hooks; + }, + }; +} + +function invoke(command, env, shell, input = {}) { + const windows = process.platform === "win32"; + return spawnSync(shell, windows ? ["/d", "/s", "/c", `"${command}"`] : ["-lc", command], { + env, + windowsVerbatimArguments: windows, + encoding: "utf8", + input: JSON.stringify(input), + timeout: 10000, + }); +} + +const shells = process.platform === "win32" + ? [process.env.COMSPEC || "cmd.exe"] + : ["/bin/sh", "/bin/bash", "/bin/zsh"].filter(existsSync); +const names = process.platform === "win32" + ? ["plain", "home with spaces", "home's quotes", "home %PATH% !quoted! & (nested)", "mémoire 日本語"] + : ["plain", "home with spaces", "nested home/with spaces", "home's quotes", 'home "double" quotes', "home $PATH `echo nope` ; & (nested)", "home\nnewline", "mémoire 日本語"]; + +for (const name of names) { + test(`installed hook commands execute from ${JSON.stringify(name)}`, (t) => { + const f = fixture(t, name); + f.cli("install"); + const hooks = f.hooks(); + for (const [event, script] of Object.entries(scripts)) { + const command = hooks[event][0].hooks[0].command; + for (const shell of shells) { + const result = invoke(command, f.env, shell); + assert.equal(result.status, 0, `${shell}: ${result.stderr}`); + assert.ok(result.stderr.includes(`HOOK_STARTED:${script}`), result.stderr); + } + } + const approval = invoke(hooks.PreToolUse[0].hooks[0].command, f.env, shells[0], { + tool_name: "mcp__supermemory__search_memory", + tool_input: { query: "path test" }, + }); + assert.equal(approval.status, 0, approval.stderr); + assert.equal(JSON.parse(approval.stdout).hookSpecificOutput.permissionDecision, "allow"); + f.cli("install"); + assert.deepEqual(f.hooks(), hooks); + assert.match(f.cli("status"), /hooks\.json:\s+✓ registered/); + }); +} + +test("Windows encoded-path commands load the installed bundles", { skip: process.platform === "win32" }, (t) => { + const f = fixture(t, "home %PATH% !quoted! & ' \" 日本語", "win32"); + f.cli("install"); + for (const [event, script] of Object.entries(scripts)) { + const command = f.hooks()[event][0].hooks[0].command; + assert.match(command, /^node -e "require\(Buffer\.from\('[A-Za-z0-9+/=]+','base64'\)\.toString\('utf-8'\)\)"$/); + const result = invoke(command, f.env, "/bin/sh"); + assert.equal(result.status, 0, result.stderr); + assert.ok(result.stderr.includes(`HOOK_STARTED:${script}`), result.stderr); + } +}); + +for (const uninstallFormat of ["legacy", "current", "mixed"]) { + test(`status and uninstall recognize ${uninstallFormat} commands and preserve unrelated hooks`, (t) => { + const f = fixture(t, "home's memory path"); + f.cli("install"); + const hooks = f.hooks(); + const unrelated = { type: "command", command: "echo unrelated", timeout: 7 }; + for (const [index, [event, script]] of Object.entries(scripts).entries()) { + if (uninstallFormat === "legacy" || (uninstallFormat === "mixed" && index % 2 === 0)) { + hooks[event][0].hooks[0].command = `node ${join(f.codexDir, "supermemory", script)}`; + } + hooks[event][0].hooks.push(unrelated); + hooks[event].push({ matcher: "unrelated", hooks: [unrelated] }); + } + hooks.CustomEvent = [{ matcher: "keep", hooks: [unrelated] }]; + const addRetired = (event, script) => { + hooks[event][0].hooks.push({ type: "command", command: `node ${join(f.codexDir, "supermemory", script)}` }); + }; + addRetired("Stop", "capture.js"); + addRetired("UserPromptSubmit", "capture-turn.js"); + writeFileSync(f.hooksPath, JSON.stringify({ hooks })); + assert.match(f.cli("status"), /hooks\.json:\s+✓ registered/); + f.cli("uninstall"); + const remaining = f.hooks(); + for (const event of Object.keys(scripts)) { + assert.deepEqual(remaining[event], [ + { ...hooks[event][0], hooks: [unrelated] }, + { matcher: "unrelated", hooks: [unrelated] }, + ]); + } + assert.deepEqual(remaining.CustomEvent, hooks.CustomEvent); + assert.match(f.cli("status"), /hooks\.json:\s+✗ not registered/); + }); +} + +test("reinstall migrates legacy commands in place without duplicates or unrelated changes", (t) => { + const f = fixture(t, "home's memory path"); + const unrelated = { type: "command", command: "echo unrelated", timeout: 7 }; + const hooks = Object.fromEntries(Object.entries(scripts).map(([event, script]) => [event, [{ + matcher: event === "PreToolUse" ? "^mcp__supermemory__" : "", + hooks: [{ + type: "command", command: `node ${join(f.codexDir, "supermemory", script)}`, + timeout: 1, async: true, customField: "preserved", + }, unrelated], + customGroupField: "preserved", + }]])); + hooks.Stop[0].hooks.push({ type: "command", command: `node ${join(f.codexDir, "supermemory", "capture.js")}` }); + hooks.UserPromptSubmit[0].hooks.push({ type: "command", command: `node ${join(f.codexDir, "supermemory", "capture-turn.js")}` }); + writeFileSync(f.hooksPath, JSON.stringify({ hooks })); + f.cli("install"); + const migrated = f.hooks(); + for (const [event, script] of Object.entries(scripts)) { + assert.equal(migrated[event].length, 1); + assert.equal(migrated[event][0].matcher, hooks[event][0].matcher); + assert.equal(migrated[event][0].customGroupField, "preserved"); + assert.equal(migrated[event][0].hooks.length, 2); + assert.equal(migrated[event][0].hooks[0].customField, "preserved"); + assert.equal(migrated[event][0].hooks[0].async, event === "Stop" ? true : undefined); + assert.deepEqual(migrated[event][0].hooks[1], unrelated); + const result = invoke(migrated[event][0].hooks[0].command, f.env, shells[0]); + assert.equal(result.status, 0, result.stderr); + assert.ok(result.stderr.includes(`HOOK_STARTED:${script}`), result.stderr); + } + assert.match(f.cli("status"), /hooks\.json:\s+✓ registered/); + f.cli("install"); + assert.deepEqual(f.hooks(), migrated); +}); From a6a9adbd7f994731cd78d2e148fd0d41fb3d89c6 Mon Sep 17 00:00:00 2001 From: Dhravya Date: Fri, 9 Oct 2026 05:11:33 +0000 Subject: [PATCH 2/3] test: resolve temporary directory aliases before tracking hook starts Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/hook-paths.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/hook-paths.mjs b/test/hook-paths.mjs index fd3f80b..cfc2b26 100644 --- a/test/hook-paths.mjs +++ b/test/hook-paths.mjs @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -15,7 +15,7 @@ const scripts = { }; function fixture(t, name, platform = process.platform) { - const root = mkdtempSync(join(tmpdir(), "csm-hook-path-")); + const root = realpathSync(mkdtempSync(join(tmpdir(), "csm-hook-path-"))); t.after(() => rmSync(root, { recursive: true, force: true })); const home = join(root, name); const codexDir = join(home, ".codex"); From 99f5abde30350e1bf5b7a6c108f3a09822ab1c08 Mon Sep 17 00:00:00 2001 From: Dhravya Date: Sat, 10 Oct 2026 00:49:17 +0000 Subject: [PATCH 3/3] chore: keep hook path verification probes outside the PR Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .github/workflows/hook-paths.yml | 27 ----- package.json | 2 +- test/hook-paths.mjs | 185 ------------------------------- 3 files changed, 1 insertion(+), 213 deletions(-) delete mode 100644 .github/workflows/hook-paths.yml delete mode 100644 test/hook-paths.mjs diff --git a/.github/workflows/hook-paths.yml b/.github/workflows/hook-paths.yml deleted file mode 100644 index 69ab405..0000000 --- a/.github/workflows/hook-paths.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Hook Path Regression Tests - -on: - pull_request: - paths: - - src/** - - test/hook-paths.mjs - - build.mjs - - package.json - - package-lock.json - - .github/workflows/hook-paths.yml - -jobs: - hook-paths: - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: "24" - - run: npm ci - - run: npm run typecheck - - run: npm run build - - run: node --test test/hook-paths.mjs diff --git a/package.json b/package.json index 5df5930..861ee9c 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "scripts": { "build": "node build.mjs", "typecheck": "tsc --noEmit", - "test": "npm run build && node --test test/unit.mjs test/hook-paths.mjs" + "test": "npm run build && node --test test/unit.mjs" }, "keywords": [ "codex", diff --git a/test/hook-paths.mjs b/test/hook-paths.mjs deleted file mode 100644 index cfc2b26..0000000 --- a/test/hook-paths.mjs +++ /dev/null @@ -1,185 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const cliBin = fileURLToPath(new URL("../dist/cli.js", import.meta.url)); -const scripts = { - SessionStart: "session-start.js", - UserPromptSubmit: "recall.js", - PreToolUse: "recall-approve.js", - Stop: "flush.js", -}; - -function fixture(t, name, platform = process.platform) { - const root = realpathSync(mkdtempSync(join(tmpdir(), "csm-hook-path-"))); - t.after(() => rmSync(root, { recursive: true, force: true })); - const home = join(root, name); - const codexDir = join(home, ".codex"); - mkdirSync(codexDir, { recursive: true }); - const preload = join(root, "boundary.cjs"); - writeFileSync(preload, ` - require('node:os').homedir = () => process.env.TEST_CODEX_HOME; - Object.defineProperty(process, 'platform', { value: ${JSON.stringify(platform)} }); - global.fetch = async () => { throw Error('Network disabled in hook path tests'); }; - for (const name of ['node:http', 'node:https', 'node:net']) { - const module = require(name); - for (const key of ['request', 'get', 'connect', 'createConnection']) { - if (typeof module[key] === 'function') module[key] = () => { throw Error('Network disabled'); }; - } - } - const Module = require('node:module'); - const load = Module._extensions['.js']; - Module._extensions['.js'] = (module, filename) => { - if (require('node:path').dirname(filename) === require('node:path').join(process.env.TEST_CODEX_HOME, '.codex', 'supermemory')) { - process.stderr.write('HOOK_STARTED:' + require('node:path').basename(filename) + '\\n'); - } - return load(module, filename); - }; - `); - const env = { - ...process.env, - HOME: home, - USERPROFILE: home, - TEST_CODEX_HOME: home, - SUPERMEMORY_CODEX_API_KEY: "sm_DUMMY_PATH_TEST", - NODE_OPTIONS: `--require=${JSON.stringify(preload)}`, - }; - const hooksPath = join(codexDir, "hooks.json"); - return { - home, codexDir, hooksPath, env, - cli(command) { - const result = spawnSync(process.execPath, [cliBin, command], { env, encoding: "utf8", timeout: 10000 }); - assert.equal(result.status, 0, result.stderr); - return result.stdout; - }, - hooks() { - return JSON.parse(readFileSync(hooksPath, "utf8")).hooks; - }, - }; -} - -function invoke(command, env, shell, input = {}) { - const windows = process.platform === "win32"; - return spawnSync(shell, windows ? ["/d", "/s", "/c", `"${command}"`] : ["-lc", command], { - env, - windowsVerbatimArguments: windows, - encoding: "utf8", - input: JSON.stringify(input), - timeout: 10000, - }); -} - -const shells = process.platform === "win32" - ? [process.env.COMSPEC || "cmd.exe"] - : ["/bin/sh", "/bin/bash", "/bin/zsh"].filter(existsSync); -const names = process.platform === "win32" - ? ["plain", "home with spaces", "home's quotes", "home %PATH% !quoted! & (nested)", "mémoire 日本語"] - : ["plain", "home with spaces", "nested home/with spaces", "home's quotes", 'home "double" quotes', "home $PATH `echo nope` ; & (nested)", "home\nnewline", "mémoire 日本語"]; - -for (const name of names) { - test(`installed hook commands execute from ${JSON.stringify(name)}`, (t) => { - const f = fixture(t, name); - f.cli("install"); - const hooks = f.hooks(); - for (const [event, script] of Object.entries(scripts)) { - const command = hooks[event][0].hooks[0].command; - for (const shell of shells) { - const result = invoke(command, f.env, shell); - assert.equal(result.status, 0, `${shell}: ${result.stderr}`); - assert.ok(result.stderr.includes(`HOOK_STARTED:${script}`), result.stderr); - } - } - const approval = invoke(hooks.PreToolUse[0].hooks[0].command, f.env, shells[0], { - tool_name: "mcp__supermemory__search_memory", - tool_input: { query: "path test" }, - }); - assert.equal(approval.status, 0, approval.stderr); - assert.equal(JSON.parse(approval.stdout).hookSpecificOutput.permissionDecision, "allow"); - f.cli("install"); - assert.deepEqual(f.hooks(), hooks); - assert.match(f.cli("status"), /hooks\.json:\s+✓ registered/); - }); -} - -test("Windows encoded-path commands load the installed bundles", { skip: process.platform === "win32" }, (t) => { - const f = fixture(t, "home %PATH% !quoted! & ' \" 日本語", "win32"); - f.cli("install"); - for (const [event, script] of Object.entries(scripts)) { - const command = f.hooks()[event][0].hooks[0].command; - assert.match(command, /^node -e "require\(Buffer\.from\('[A-Za-z0-9+/=]+','base64'\)\.toString\('utf-8'\)\)"$/); - const result = invoke(command, f.env, "/bin/sh"); - assert.equal(result.status, 0, result.stderr); - assert.ok(result.stderr.includes(`HOOK_STARTED:${script}`), result.stderr); - } -}); - -for (const uninstallFormat of ["legacy", "current", "mixed"]) { - test(`status and uninstall recognize ${uninstallFormat} commands and preserve unrelated hooks`, (t) => { - const f = fixture(t, "home's memory path"); - f.cli("install"); - const hooks = f.hooks(); - const unrelated = { type: "command", command: "echo unrelated", timeout: 7 }; - for (const [index, [event, script]] of Object.entries(scripts).entries()) { - if (uninstallFormat === "legacy" || (uninstallFormat === "mixed" && index % 2 === 0)) { - hooks[event][0].hooks[0].command = `node ${join(f.codexDir, "supermemory", script)}`; - } - hooks[event][0].hooks.push(unrelated); - hooks[event].push({ matcher: "unrelated", hooks: [unrelated] }); - } - hooks.CustomEvent = [{ matcher: "keep", hooks: [unrelated] }]; - const addRetired = (event, script) => { - hooks[event][0].hooks.push({ type: "command", command: `node ${join(f.codexDir, "supermemory", script)}` }); - }; - addRetired("Stop", "capture.js"); - addRetired("UserPromptSubmit", "capture-turn.js"); - writeFileSync(f.hooksPath, JSON.stringify({ hooks })); - assert.match(f.cli("status"), /hooks\.json:\s+✓ registered/); - f.cli("uninstall"); - const remaining = f.hooks(); - for (const event of Object.keys(scripts)) { - assert.deepEqual(remaining[event], [ - { ...hooks[event][0], hooks: [unrelated] }, - { matcher: "unrelated", hooks: [unrelated] }, - ]); - } - assert.deepEqual(remaining.CustomEvent, hooks.CustomEvent); - assert.match(f.cli("status"), /hooks\.json:\s+✗ not registered/); - }); -} - -test("reinstall migrates legacy commands in place without duplicates or unrelated changes", (t) => { - const f = fixture(t, "home's memory path"); - const unrelated = { type: "command", command: "echo unrelated", timeout: 7 }; - const hooks = Object.fromEntries(Object.entries(scripts).map(([event, script]) => [event, [{ - matcher: event === "PreToolUse" ? "^mcp__supermemory__" : "", - hooks: [{ - type: "command", command: `node ${join(f.codexDir, "supermemory", script)}`, - timeout: 1, async: true, customField: "preserved", - }, unrelated], - customGroupField: "preserved", - }]])); - hooks.Stop[0].hooks.push({ type: "command", command: `node ${join(f.codexDir, "supermemory", "capture.js")}` }); - hooks.UserPromptSubmit[0].hooks.push({ type: "command", command: `node ${join(f.codexDir, "supermemory", "capture-turn.js")}` }); - writeFileSync(f.hooksPath, JSON.stringify({ hooks })); - f.cli("install"); - const migrated = f.hooks(); - for (const [event, script] of Object.entries(scripts)) { - assert.equal(migrated[event].length, 1); - assert.equal(migrated[event][0].matcher, hooks[event][0].matcher); - assert.equal(migrated[event][0].customGroupField, "preserved"); - assert.equal(migrated[event][0].hooks.length, 2); - assert.equal(migrated[event][0].hooks[0].customField, "preserved"); - assert.equal(migrated[event][0].hooks[0].async, event === "Stop" ? true : undefined); - assert.deepEqual(migrated[event][0].hooks[1], unrelated); - const result = invoke(migrated[event][0].hooks[0].command, f.env, shells[0]); - assert.equal(result.status, 0, result.stderr); - assert.ok(result.stderr.includes(`HOOK_STARTED:${script}`), result.stderr); - } - assert.match(f.cli("status"), /hooks\.json:\s+✓ registered/); - f.cli("install"); - assert.deepEqual(f.hooks(), migrated); -});