From 44567f6d4ee09ac01222f3054768e025fb8ba079 Mon Sep 17 00:00:00 2001 From: Jiahe Geng <146067293+m0Nst3r873@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:09:19 +0800 Subject: [PATCH] feat(local-agent): install/uninstall session hooks via sync + ack (#238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the clawpro install_hook_rule / uninstall_hook_rule sync commands so a backend can remotely manage a session hook in the current reporting tool's settings, keyed by slug, reporting the result over the existing ack channel. Delivery, ack, and retry semantics are reused unchanged; these two types were previously silently skipped (handle_type === 'hook'). Behavior: - Current tool only — writes to context.tool's settings (never batch-injects). - Supported tools: claude / codex / workbuddy / codebuddy (+ internal variants). Cursor and OpenClaw-family tools are rejected (acked failed, unsupported tool). - Event whitelist: SessionStart / UserPromptSubmit / PreToolUse / PostToolUse / Stop. Anything else is acked failed (unsupported event). - Default timeout 10s when omitted; explicit backend timeout honored. - Idempotent: re-installing a slug replaces rather than duplicates, including same-tool event/command changes (prior entry is removed first). Missing-slug uninstall is idempotent success. Isolation: agent hooks use a dedicated [teamai:agent-hook:] description marker, distinct from built-in ([teamai] ) and team ([teamai:hook:) markers, so team full-reconcile treats them as untouched and never deletes them. The claude removeAll teardown branch is extended to also recognize the agent-hook marker, so `teamai uninstall` sweeps residue even without the manifest. Tracking: agent hooks are recorded in a separate ~/.teamai/local-agent/ agent-hooks.json (atomic writes), kept apart from the team managed-hooks.json so a team pull can never treat them as stale. Codex settings carry no description field, so codex hooks are matched by command and the manifest is the authoritative record for their teardown. Teardown covers all three paths with no residue: uninstall_hook_rule, `teamai source remove` (removeAllAgentHooks), and `teamai uninstall`. Trust model matches uninstall_teamai: an agent hook is a backend-supplied command the tool auto-runs on its events, so TEAMAI_DISABLE_REMOTE_CMD=1 also rejects install_hook_rule / uninstall_hook_rule (acked failed). Docs updated in both languages. Unit tests cover install/replace/remove for claude & codex, validation failures, cursor rejection, marker isolation, teardown sweep, codex command-change reinstall, and the kill switch. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/usage-guide.md | 39 ++++++- docs/usage-guide.zh-CN.md | 37 ++++++- src/__tests__/hooks.test.ts | 95 +++++++++++++++- src/__tests__/local-agent.test.ts | 170 +++++++++++++++++++++++------ src/hooks.ts | 174 +++++++++++++++++++++++++++++- src/local-agent.ts | 162 ++++++++++++++++++++++++++-- src/types.ts | 10 ++ src/uninstall.ts | 10 ++ 8 files changed, 655 insertions(+), 42 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index fab7563e..7b093ac3 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -647,6 +647,43 @@ Security boundary for the executed `cmd`: - **On by default** — like install/uninstall commands, it runs automatically. Set `TEAMAI_DISABLE_REMOTE_CMD=1` on the client to reject it (acked `failed` with `remote cmd disabled by client`). - **Timeout** — a hung command is killed after 120s and acked `failed`. +The backend may also push **`install_hook_rule`** / **`uninstall_hook_rule`** commands to remotely +manage a session hook in the **current reporting tool**'s settings, keyed by `slug`. The result is +reported over the same ack channel: + +```jsonc +// install (or replace) a hook keyed by slug +{ "id": 50, "type": "install_hook_rule", "handle_type": "hook", "slug": "my-hook", + "event": "SessionStart", "cmd": "echo hi", "timeout": 10 } + +// uninstall the hook previously installed under slug +{ "id": 51, "type": "uninstall_hook_rule", "handle_type": "hook", "slug": "my-hook" } +``` + +Rules for agent hooks: + +- **Current tool only** — the hook is written to the tool that is reporting (e.g. under Claude ⇒ + only `.claude/settings.json`). Other tools are never touched. +- **Supported tools** — `claude` / `codex` / `workbuddy` / `codebuddy` (plus their internal + variants). **Cursor and OpenClaw-family tools are rejected** → acked `failed` (`unsupported tool`). +- **Event whitelist** — `SessionStart` / `UserPromptSubmit` / `PreToolUse` / `PostToolUse` / `Stop`. + Any other event → acked `failed` (`unsupported event`). +- **Optional `matcher`** — a tool-name filter for `PreToolUse` / `PostToolUse`; defaults to `*` + (all tools) when omitted. +- **Default timeout 10s** when `timeout` is omitted; the backend value is honored when present. +- **Idempotent** — re-installing the same `slug` replaces the existing hook rather than duplicating + it; `uninstall_hook_rule` for a missing `slug` is acked `success`. +- **Isolation** — agent hooks use a dedicated `[teamai:agent-hook:]` marker, so a team pull + never deletes them and installing one never disturbs built-in or team hooks. +- **Teardown** — agent hooks are removed by `uninstall_hook_rule`, `teamai source remove`, and + `teamai uninstall` (no residue in any tool's settings). +- **Kill-switch** — an agent hook is a backend-supplied command the tool auto-runs on its events, + so it shares the `uninstall_teamai` trust model: setting `TEAMAI_DISABLE_REMOTE_CMD=1` on the + client rejects `install_hook_rule` / `uninstall_hook_rule` too (acked `failed`). +- **Codex matching** — codex settings carry no description field, so codex agent hooks are matched + by their exact command and the local-agent manifest is the authoritative record for their + teardown. Backends should use a **unique `cmd` per codex `slug`** so replace/remove stay precise. + Configurable environment variables: | Variable | Purpose | @@ -657,7 +694,7 @@ Configurable environment variables: | `TEAMAI_REPORT_AGENTS` | Comma-separated list of agents that report (default `workbuddy,codebuddy`) | | `TEAMAI_SKILL_DOWNLOAD_HOSTS` | Allowlist of hosts for skill `download_url` (empty = allow all) | | `TEAMAI_ALLOW_SANDBOX_REPORT` | Set to `1` to force report/sync inside a CloudStudio sandbox (see note below) | -| `TEAMAI_DISABLE_REMOTE_CMD` | Set to `1` to reject server-pushed `uninstall_teamai` commands (they are acked `failed`) | +| `TEAMAI_DISABLE_REMOTE_CMD` | Set to `1` to reject server-pushed `uninstall_teamai`, `install_hook_rule`, and `uninstall_hook_rule` commands (they are acked `failed`) | > **Privacy:** The install path and machine id are only hashed locally to derive `local_agent_id` — they are never reported. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 822451ef..36c16e77 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -645,6 +645,41 @@ cat ~/.claude/CLAUDE.md - **默认开启** —— 与 install/uninstall 命令一致,会自动执行。客户端设 `TEAMAI_DISABLE_REMOTE_CMD=1` 可拒绝(ack `failed`,错误为 `remote cmd disabled by client`)。 - **超时** —— 命令卡住 120s 后被杀掉并 ack `failed`。 +后端还可下发 **`install_hook_rule`** / **`uninstall_hook_rule`** 命令,按 `slug` 远程管理**当前上报工具** +settings 里的一个 session hook。结果经同一 ack 通道回报: + +```jsonc +// 按 slug 安装(或替换)一个 hook +{ "id": 50, "type": "install_hook_rule", "handle_type": "hook", "slug": "my-hook", + "event": "SessionStart", "cmd": "echo hi", "timeout": 10 } + +// 卸载此前以该 slug 安装的 hook +{ "id": 51, "type": "uninstall_hook_rule", "handle_type": "hook", "slug": "my-hook" } +``` + +agent hook 规则: + +- **仅当前工具** —— hook 只写入正在上报的工具(如在 Claude 下运行 ⇒ 只写 `.claude/settings.json`), + 绝不触碰其它工具。 +- **支持的工具** —— `claude` / `codex` / `workbuddy` / `codebuddy`(含其内部变体)。**Cursor 与 OpenClaw + 家族被拒绝** → ack `failed`(`unsupported tool`)。 +- **事件白名单** —— `SessionStart` / `UserPromptSubmit` / `PreToolUse` / `PostToolUse` / `Stop`。 + 其它事件 → ack `failed`(`unsupported event`)。 +- **可选 `matcher`** —— `PreToolUse` / `PostToolUse` 的工具名过滤;省略时默认为 `*`(全部工具)。 +- **默认超时 10s** —— 省略 `timeout` 时用 10s;后端给了值则以后端为准。 +- **幂等** —— 用相同 `slug` 重装会替换已有 hook 而非重复追加;对不存在的 `slug` 执行 + `uninstall_hook_rule` 也 ack `success`。 +- **隔离** —— agent hook 使用专属 marker `[teamai:agent-hook:]`,团队 pull 不会删除它, + 安装它也不会扰动 built-in 或团队 hook。 +- **清理** —— agent hook 会被 `uninstall_hook_rule`、`teamai source remove` 和 `teamai uninstall` + 彻底移除(不在任何工具 settings 里留残留)。 +- **关闭开关** —— agent hook 是后端下发、由工具在其事件上自动执行的命令,因此与 `uninstall_teamai` + 共用信任模型:客户端设 `TEAMAI_DISABLE_REMOTE_CMD=1` 也会拒绝 `install_hook_rule` / + `uninstall_hook_rule`(ack `failed`)。 +- **Codex 匹配** —— codex settings 无 description 字段,因此 codex agent hook 按其确切命令匹配, + 且以 local-agent manifest 作为卸载的权威记录。后端应为**每个 codex `slug` 使用唯一的 `cmd`**, + 以保证替换/删除的精确性。 + 可配置环境变量: | 变量 | 作用 | @@ -655,7 +690,7 @@ cat ~/.claude/CLAUDE.md | `TEAMAI_REPORT_AGENTS` | 参与上报的 agent,逗号分隔(默认 `workbuddy,codebuddy`) | | `TEAMAI_SKILL_DOWNLOAD_HOSTS` | skill `download_url` host 白名单(空 = 全部放行) | | `TEAMAI_ALLOW_SANDBOX_REPORT` | 设为 `1` 可强制在 CloudStudio 沙箱内 report/sync(见下方说明) | -| `TEAMAI_DISABLE_REMOTE_CMD` | 设为 `1` 可拒绝服务端下发的 `uninstall_teamai` 命令(会 ack `failed`) | +| `TEAMAI_DISABLE_REMOTE_CMD` | 设为 `1` 可拒绝服务端下发的 `uninstall_teamai`、`install_hook_rule`、`uninstall_hook_rule` 命令(会 ack `failed`) | > **隐私**:install path 和 machine id 仅在本地哈希以派生 `local_agent_id`,不会上报。 diff --git a/src/__tests__/hooks.test.ts b/src/__tests__/hooks.test.ts index 96d02497..34167028 100644 --- a/src/__tests__/hooks.test.ts +++ b/src/__tests__/hooks.test.ts @@ -25,7 +25,7 @@ vi.mock('../utils/logger.js', () => ({ }, })); -import { getHookStatus, injectHooks, removeHooks, injectHooksToAllTools, TEAMAI_HOOK_SUBCOMMANDS, TEAMAI_LEGACY_HOOK_SUBCOMMANDS, CLAUDE_TO_CURSOR_EVENTS } from '../hooks.js'; +import { getHookStatus, injectHooks, removeHooks, injectHooksToAllTools, TEAMAI_HOOK_SUBCOMMANDS, TEAMAI_LEGACY_HOOK_SUBCOMMANDS, CLAUDE_TO_CURSOR_EVENTS, reconcileHooks, applyAgentHook, removeAgentHook, isAgentHookSupportedTool, isAgentHookEvent, agentHookDescription } from '../hooks.js'; // ── Helpers ────────────────────────────────────────────── @@ -619,6 +619,99 @@ describe('hooks', () => { }); }); + describe('agent hooks (issue #238)', () => { + it('isAgentHookSupportedTool: claude/codex/workbuddy/codebuddy yes, cursor/openclaw no', () => { + for (const t of ['claude', 'codex', 'workbuddy', 'codebuddy', 'codex-internal']) { + expect(isAgentHookSupportedTool(t)).toBe(true); + } + for (const t of ['cursor', 'openclaw', 'qclaw', 'easyclaw', 'autoclaw']) { + expect(isAgentHookSupportedTool(t)).toBe(false); + } + }); + + it('isAgentHookEvent: only the 5 whitelisted events', () => { + for (const e of ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop']) { + expect(isAgentHookEvent(e)).toBe(true); + } + for (const e of ['Notification', 'PreCompact', 'foo', 'sessionStart']) { + expect(isAgentHookEvent(e)).toBe(false); + } + }); + + it('applyAgentHook (claude): writes a slug-tagged entry with timeout', async () => { + await applyAgentHook('/t/settings.json', 'claude', { + slug: 's1', event: 'SessionStart', command: 'echo hi', timeout: 10, + }); + const s = mockFiles['/t/settings.json'] as any; + const e = s.hooks.SessionStart.find((x: any) => x.description === agentHookDescription('s1')); + expect(e).toBeDefined(); + expect(e.matcher).toBe('*'); + expect(e.hooks[0].command).toBe('echo hi'); + expect(e.hooks[0].timeout).toBe(10); + }); + + it('applyAgentHook (claude): re-install same slug replaces, no duplicate', async () => { + await applyAgentHook('/t/s.json', 'claude', { slug: 's2', event: 'Stop', command: 'echo a' }); + await applyAgentHook('/t/s.json', 'claude', { slug: 's2', event: 'Stop', command: 'echo b' }); + const s = mockFiles['/t/s.json'] as any; + const mine = s.hooks.Stop.filter((x: any) => x.description === agentHookDescription('s2')); + expect(mine).toHaveLength(1); + expect(mine[0].hooks[0].command).toBe('echo b'); + }); + + it('applyAgentHook preserves a user hook in the same event', async () => { + mockFiles['/t/s.json'] = { + hooks: { SessionStart: [{ matcher: '*', hooks: [{ type: 'command', command: 'user-cmd' }] }] }, + }; + await applyAgentHook('/t/s.json', 'claude', { slug: 's3', event: 'SessionStart', command: 'echo hi' }); + const s = mockFiles['/t/s.json'] as any; + expect(s.hooks.SessionStart.some((x: any) => x.hooks[0].command === 'user-cmd')).toBe(true); + expect(s.hooks.SessionStart.some((x: any) => x.description === agentHookDescription('s3'))).toBe(true); + }); + + it('applyAgentHook (codex): writes entry without description, matched by command', async () => { + await applyAgentHook('/t/codex.json', 'codex', { slug: 's4', event: 'PreToolUse', command: 'echo cx' }); + const s = mockFiles['/t/codex.json'] as any; + const e = s.hooks.PreToolUse.find((x: any) => x.hooks[0].command === 'echo cx'); + expect(e).toBeDefined(); + expect(e.description).toBeUndefined(); + }); + + it('removeAgentHook (claude): removes by slug, drops empty event key', async () => { + await applyAgentHook('/t/s.json', 'claude', { slug: 's5', event: 'SessionStart', command: 'echo hi' }); + await removeAgentHook('/t/s.json', 'claude', { slug: 's5' }); + const s = mockFiles['/t/s.json'] as any; + expect(s.hooks.SessionStart).toBeUndefined(); + }); + + it('removeAgentHook (codex): removes by command', async () => { + await applyAgentHook('/t/codex.json', 'codex', { slug: 's6', event: 'Stop', command: 'echo cx6' }); + await removeAgentHook('/t/codex.json', 'codex', { slug: 's6', command: 'echo cx6' }); + const s = mockFiles['/t/codex.json'] as any; + expect(s.hooks.Stop).toBeUndefined(); + }); + + it('normal reconcile leaves an agent hook untouched; removeAll sweeps it', async () => { + // Seed a claude settings file with a built-in inject + an agent hook. + await injectHooks('/t/rec.json', 'claude'); + await applyAgentHook('/t/rec.json', 'claude', { slug: 's7', event: 'SessionStart', command: 'echo hi' }); + const marker = agentHookDescription('s7'); + const has = () => { + const s = mockFiles['/t/rec.json'] as any; + return Object.values(s.hooks).some((arr: any) => arr.some((e: any) => e.description === marker)); + }; + expect(has()).toBe(true); + + // Normal reconcile (no manifest, removeAll=false) must NOT delete the agent hook. + await reconcileHooks('/t/rec.json', 'claude', []); + expect(has()).toBe(true); + + // Teardown removeAll must sweep it. + await reconcileHooks('/t/rec.json', 'claude', [], { removeAll: true }); + expect(has()).toBe(false); + }); + }); + describe('getHookStatus', () => { it('reports installed for current Claude hooks', async () => { await injectHooks('/test/settings.json', 'claude'); diff --git a/src/__tests__/local-agent.test.ts b/src/__tests__/local-agent.test.ts index 09e7eece..6b0f6b94 100644 --- a/src/__tests__/local-agent.test.ts +++ b/src/__tests__/local-agent.test.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import fse from 'fs-extra'; +import { agentHookDescription } from '../hooks.js'; vi.mock('../utils/logger.js', () => ({ log: { @@ -1246,7 +1247,7 @@ describe('local-agent: cmds[] migration', () => { return zipSync({ [`${skillName}/SKILL.md`]: strToU8(skillMd) }); } - async function runResponse(body: Record, zip?: Uint8Array) { + async function runResponse(body: Record, zip?: Uint8Array, tool: string = 'codebuddy') { await fse.ensureDir(path.join(tmpDir, '.codebuddy', 'skills')); await setupConfig(); const acks: Array> = []; @@ -1260,7 +1261,7 @@ describe('local-agent: cmds[] migration', () => { }); vi.stubGlobal('fetch', fetchMock); const { reportAndSyncLocalAgent } = await import('../local-agent.js'); - await reportAndSyncLocalAgent({ cwd: tmpDir, tool: 'codebuddy', status: 'running' }); + await reportAndSyncLocalAgent({ cwd: tmpDir, tool, status: 'running' }); return acks; } @@ -1320,43 +1321,150 @@ describe('local-agent: cmds[] migration', () => { expect(acks.find((a) => a.id === 4)?.version).toBe('1.0.0'); }); - it('skips uninstall_hook_rule without touching a same-slug rule', async () => { - // Step 1: install a rule with slug 'shared'. - await runResponse({ + it('install_hook_rule writes a slug-tagged claude hook with default timeout', async () => { + const acks = await runResponse({ cmds: [{ - id: 10, type: 'install_rule_rule', handle_type: 'rule', slug: 'shared', - version: '1.0.0', download_url: 'http://127.0.0.1:42100/shared.md', scope: 'user', + id: 20, type: 'install_hook_rule', handle_type: 'hook', slug: 'hk1', + event: 'SessionStart', cmd: 'echo hi', scope: 'user', }], }); - const manifest1 = await fse.readJson(path.join(tmpDir, '.teamai', 'local-agent', 'manifest.json')); - expect(manifest1.scopes.user.rules?.['shared']).toBeDefined(); + expect(acks.find((a) => a.id === 20)?.status).toBe('success'); + const settings = await fse.readJson(path.join(tmpDir, '.codebuddy', 'settings.json')); + const entries = settings.hooks.SessionStart; + const mine = entries.find((e: any) => e.description === agentHookDescription('hk1')); + expect(mine).toBeDefined(); + expect(mine.hooks[0].command).toBe('echo hi'); + expect(mine.hooks[0].timeout).toBe(10); + const manifest = await fse.readJson(path.join(tmpDir, '.teamai', 'local-agent', 'agent-hooks.json')); + expect(manifest.hk1).toMatchObject({ tool: 'codebuddy', event: 'SessionStart', command: 'echo hi', timeout: 10 }); + }); - // Step 2: send uninstall_hook_rule targeting same slug — must be silently skipped. - const acks2 = await runResponse({ - cmds: [{ id: 5, type: 'uninstall_hook_rule', handle_type: 'hook', slug: 'shared', scope: 'user' }], - }); + it('honors explicit timeout and replaces on re-install (idempotent)', async () => { + const cmd21 = { + id: 21, type: 'install_hook_rule', handle_type: 'hook', slug: 'hk2', + event: 'Stop', cmd: 'echo a', timeout: 30, scope: 'user', + }; + await runResponse({ cmds: [cmd21] }); + const cmd22 = { + id: 22, type: 'install_hook_rule', handle_type: 'hook', slug: 'hk2', + event: 'Stop', cmd: 'echo b', timeout: 45, scope: 'user', + }; + const acks2 = await runResponse({ cmds: [cmd22] }); + expect(acks2.find((a) => a.id === 22)?.status).toBe('success'); + const settings = await fse.readJson(path.join(tmpDir, '.codebuddy', 'settings.json')); + const mine = settings.hooks.Stop.filter((e: any) => e.description === agentHookDescription('hk2')); + expect(mine).toHaveLength(1); + expect(mine[0].hooks[0].command).toBe('echo b'); + expect(mine[0].hooks[0].timeout).toBe(45); + }); - const manifest2 = await fse.readJson(path.join(tmpDir, '.teamai', 'local-agent', 'manifest.json')); - expect(manifest2.scopes.user.rules?.['shared']).toBeDefined(); - expect(acks2.find((a) => a.id === 5)).toBeUndefined(); + it('install_hook_rule writes a codex hook (no description, command-matched)', async () => { + await fse.ensureDir(path.join(tmpDir, '.codex')); + const cmd23 = { + id: 23, type: 'install_hook_rule', handle_type: 'hook', slug: 'hk3', + event: 'PreToolUse', cmd: 'echo cx', scope: 'user', + }; + const acks = await runResponse({ cmds: [cmd23] }, undefined, 'codex'); + expect(acks.find((a) => a.id === 23)?.status).toBe('success'); + const hooksJson = await fse.readJson(path.join(tmpDir, '.codex', 'hooks.json')); + const mine = hooksJson.hooks.PreToolUse.find((e: any) => e.hooks[0].command === 'echo cx'); + expect(mine).toBeDefined(); }); - it('skips install_hook_rule silently', async () => { - const acks = await runResponse({ - cmds: [{ - id: 7, type: 'install_hook_rule', handle_type: 'hook', slug: 'hook-x', - version: '1.0.0', event: 'SessionStart', cmd: 'echo hi', scope: 'user', - }], - }); + it('re-install same codex slug with a new command leaves no stale entry', async () => { + const first = { + id: 30, type: 'install_hook_rule', handle_type: 'hook', slug: 'cxr', + event: 'PreToolUse', cmd: 'echo old', scope: 'user', + }; + await fse.ensureDir(path.join(tmpDir, '.codex')); + await runResponse({ cmds: [first] }, undefined, 'codex'); + const second = { + id: 31, type: 'install_hook_rule', handle_type: 'hook', slug: 'cxr', + event: 'PreToolUse', cmd: 'echo new', scope: 'user', + }; + const acks = await runResponse({ cmds: [second] }, undefined, 'codex'); + expect(acks.find((a) => a.id === 31)?.status).toBe('success'); + const hooksJson = await fse.readJson(path.join(tmpDir, '.codex', 'hooks.json')); + const cmds = (hooksJson.hooks.PreToolUse ?? []).map((e: any) => e.hooks[0].command); + expect(cmds).toContain('echo new'); + expect(cmds).not.toContain('echo old'); + const manifest = await fse.readJson( + path.join(tmpDir, '.teamai', 'local-agent', 'agent-hooks.json'), + ); + expect(manifest.cxr?.command).toBe('echo new'); + }); + + it('uninstall_hook_rule removes the entry and manifest record; missing slug is success', async () => { + const cmd24 = { + id: 24, type: 'install_hook_rule', handle_type: 'hook', slug: 'hk4', + event: 'SessionStart', cmd: 'echo x', scope: 'user', + }; + await runResponse({ cmds: [cmd24] }); + const cmd25 = { + id: 25, type: 'uninstall_hook_rule', handle_type: 'hook', slug: 'hk4', scope: 'user', + }; + const acks = await runResponse({ cmds: [cmd25] }); + expect(acks.find((a) => a.id === 25)?.status).toBe('success'); + const settings = await fse.readJson(path.join(tmpDir, '.codebuddy', 'settings.json')); + const isHk4 = (e: any) => e.description === agentHookDescription('hk4'); + const remaining = (settings.hooks.SessionStart ?? []).filter(isHk4); + expect(remaining).toHaveLength(0); + const manifest = await fse.readJson(path.join(tmpDir, '.teamai', 'local-agent', 'agent-hooks.json')); + expect(manifest.hk4).toBeUndefined(); + // Missing slug → idempotent success. + const cmd26 = { + id: 26, type: 'uninstall_hook_rule', handle_type: 'hook', slug: 'nope', scope: 'user', + }; + const acks2 = await runResponse({ cmds: [cmd26] }); + expect(acks2.find((a) => a.id === 26)?.status).toBe('success'); + }); + + it('rejects invalid install_hook_rule with a failed ack', async () => { + const cmd27 = { + id: 27, type: 'install_hook_rule', handle_type: 'hook', slug: 'bad1', + cmd: 'echo x', scope: 'user', + }; + const a1 = await runResponse({ cmds: [cmd27] }); + expect(a1.find((a) => a.id === 27)?.status).toBe('failed'); + expect(String(a1.find((a) => a.id === 27)?.error)).toMatch(/event|cmd/i); - expect(acks.find((a) => a.id === 7)).toBeUndefined(); - // A skipped install must write nothing. The manifest is only created by a - // real install, so its absence is itself proof no resource was written; - // if it does exist, hook-x must not appear in any bucket. - const manifestPath = path.join(tmpDir, '.teamai', 'local-agent', 'manifest.json'); - const manifest = (await fse.pathExists(manifestPath)) ? await fse.readJson(manifestPath) : {}; - expect(manifest.scopes?.user?.rules?.['hook-x']).toBeUndefined(); - expect(manifest.scopes?.user?.claudemd?.['hook-x']).toBeUndefined(); + const cmd28 = { + id: 28, type: 'install_hook_rule', handle_type: 'hook', slug: 'bad2', + event: 'NotAnEvent', cmd: 'echo x', scope: 'user', + }; + const a2 = await runResponse({ cmds: [cmd28] }); + expect(a2.find((a) => a.id === 28)?.status).toBe('failed'); + expect(String(a2.find((a) => a.id === 28)?.error)).toMatch(/unsupported event/i); + + await fse.ensureDir(path.join(tmpDir, '.cursor')); + const cmd29 = { + id: 29, type: 'install_hook_rule', handle_type: 'hook', slug: 'bad3', + event: 'SessionStart', cmd: 'echo x', scope: 'user', + }; + const a3 = await runResponse({ cmds: [cmd29] }, undefined, 'cursor'); + expect(a3.find((a) => a.id === 29)?.status).toBe('failed'); + expect(String(a3.find((a) => a.id === 29)?.error)).toMatch(/unsupported tool/i); + }); + + it('rejects install_hook_rule when TEAMAI_DISABLE_REMOTE_CMD=1', async () => { + const prev = process.env.TEAMAI_DISABLE_REMOTE_CMD; + process.env.TEAMAI_DISABLE_REMOTE_CMD = '1'; + try { + const cmd40 = { + id: 40, type: 'install_hook_rule', handle_type: 'hook', slug: 'gated', + event: 'SessionStart', cmd: 'echo x', scope: 'user', + }; + const acks = await runResponse({ cmds: [cmd40] }); + expect(acks.find((a) => a.id === 40)?.status).toBe('failed'); + expect(String(acks.find((a) => a.id === 40)?.error)).toMatch(/disabled/i); + const settings = path.join(tmpDir, '.codebuddy', 'settings.json'); + const written = (await fse.pathExists(settings)) ? await fse.readJson(settings) : {}; + const hooks = written.hooks?.SessionStart ?? []; + expect(hooks.some((e: any) => e.description === agentHookDescription('gated'))).toBe(false); + } finally { + if (prev === undefined) delete process.env.TEAMAI_DISABLE_REMOTE_CMD; + else process.env.TEAMAI_DISABLE_REMOTE_CMD = prev; + } }); it('skips an unknown hook type (handle_type=hook) without a failed ack', async () => { diff --git a/src/hooks.ts b/src/hooks.ts index be7b5eb1..5958d56f 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { readJson, writeJson, expandHome, ensureDir, pathExists } from './utils/fs.js'; import { log } from './utils/logger.js'; -import { TEAMAI_HOOK_DESCRIPTION_PREFIX, TEAMAI_CUSTOM_HOOK_PREFIX, getManagedHooksPath, resolveBaseDir } from './types.js'; +import { TEAMAI_HOOK_DESCRIPTION_PREFIX, TEAMAI_CUSTOM_HOOK_PREFIX, TEAMAI_AGENT_HOOK_PREFIX, getManagedHooksPath, resolveBaseDir } from './types.js'; import type { HookDef, TeamaiConfig, LocalConfig } from './types.js'; import { builtinHookDefs, applyBuiltinOverride, ensureTeamaiWrapper } from './builtin-hooks.js'; import type { BuiltinHookOverride } from './builtin-hooks.js'; @@ -248,7 +248,7 @@ async function reconcileClaudeFormat( // when a team pass is active (manifest present). This keeps the builtin-only // refresh path (injectHooks / autoMigrate) non-destructive to team hooks (§5). const isManaged = (e: HookMatcher): boolean => - isBuiltinClaudeEntry(e) || (teamActive && isTeamClaudeEntry(e)); + isBuiltinClaudeEntry(e) || (teamActive && isTeamClaudeEntry(e)) || (!!opts.removeAll && isAgentClaudeEntry(e)); const expanded = expandHome(settingsPath); await ensureDir(path.dirname(expanded)); const settings: ClaudeSettingsJson = (await readJson(expanded)) ?? {}; @@ -397,6 +397,176 @@ async function reconcileCodexFormat( } } +// ─── Agent hooks (HTTP-source, issue #238) ────────────────── + +/** + * Whitelisted hook events for HTTP-source agent hooks + * (Claude PascalCase, native in both claude & codex formats). + */ +export const AGENT_HOOK_EVENTS = new Set([ + 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', +]); + +/** One HTTP-source agent hook to install. */ +export interface AgentHookDef { + slug: string; + event: string; + command: string; + matcher?: string; + timeout?: number; +} + +/** + * Return true if the tool supports agent hooks (claude/codex formats only; + * cursor and openclaw family do not support agent hooks). + */ +export function isAgentHookSupportedTool(tool: string): boolean { + return !CURSOR_TOOLS.has(tool) && !OPENCLAW_TOOLS.has(tool); +} + +/** + * Return true if the event is in the whitelisted agent hook event set. + */ +export function isAgentHookEvent(event: string): boolean { + return AGENT_HOOK_EVENTS.has(event); +} + +/** + * Generate the description marker for an agent hook entry. + * Produces: `[teamai:agent-hook:]` + */ +export function agentHookDescription(slug: string): string { + return `${TEAMAI_AGENT_HOOK_PREFIX}${slug}]`; +} + +/** True if the HookMatcher entry is a teamai agent hook (optionally scoped to a slug). */ +function isAgentClaudeEntry(entry: HookMatcher, slug?: string): boolean { + const desc = entry.description ?? ''; + if (slug !== undefined) { + return desc === agentHookDescription(slug); + } + return desc.startsWith(TEAMAI_AGENT_HOOK_PREFIX); +} + +/** + * Idempotently install or replace a single HTTP-source agent hook into a tool's + * settings file. Claude format uses the marker description for precise replacement; + * codex format uses the command for precise replacement. Only writes when content + * has actually changed. + */ +export async function applyAgentHook( + settingsPath: string, + tool: string, + def: AgentHookDef, +): Promise { + const format = detectFormat(tool); + const expanded = expandHome(settingsPath); + await ensureDir(path.dirname(expanded)); + + const hookDef: HookDef = { + source: 'team', + key: def.slug, + event: def.event, + matcher: def.matcher ?? '*', + command: def.command, + ...(def.timeout !== undefined ? { timeout: def.timeout } : {}), + description: agentHookDescription(def.slug), + }; + + // Codex entries carry no description field, so agent hooks are matched by + // their exact command string (and tracked in the local-agent agent-hook + // manifest, the authoritative record for codex teardown). Backends must use + // a unique command per codex agent-hook slug so replace/remove stay precise. + if (format === 'codex') { + const hooksJson: CodexHooksJson = (await readJson(expanded)) ?? {}; + if (!hooksJson.hooks) hooksJson.hooks = {}; + const existing = hooksJson.hooks[def.event] ?? []; + const untouched = existing.filter((e) => (e.hooks?.[0]?.command ?? '') !== def.command); + const newArr = [...untouched, toCodexEntry(hookDef)]; + if (JSON.stringify(existing) !== JSON.stringify(newArr)) { + hooksJson.hooks[def.event] = newArr; + await writeJson(expanded, hooksJson); + log.success(`Installed agent hook [${def.slug}] in ${settingsPath}`); + } else { + log.debug(`agent hook [${def.slug}] already up-to-date in ${settingsPath}`); + } + } else { + const settings: ClaudeSettingsJson = (await readJson(expanded)) ?? {}; + if (!settings.hooks) settings.hooks = {}; + const existing = settings.hooks[def.event] ?? []; + const untouched = existing.filter((e) => !isAgentClaudeEntry(e, def.slug)); + const newArr = [...untouched, toClaudeEntry(hookDef)]; + if (JSON.stringify(existing) !== JSON.stringify(newArr)) { + settings.hooks[def.event] = newArr; + await writeJson(expanded, settings); + log.success(`Installed agent hook [${def.slug}] in ${settingsPath}`); + } else { + log.debug(`agent hook [${def.slug}] already up-to-date in ${settingsPath}`); + } + } +} + +/** + * Remove a single agent hook from a tool's settings file by slug (claude) or + * command (codex). Silent if the file does not exist or there is no matching entry. + * Only writes when content has actually changed. + */ +export async function removeAgentHook( + settingsPath: string, + tool: string, + opts: { slug: string; command?: string }, +): Promise { + const expanded = expandHome(settingsPath); + if (!(await pathExists(expanded))) return; + const format = detectFormat(tool); + + // Codex removal matches by command (no marker in the file); callers pass the + // command recorded in the agent-hook manifest, which is the source of truth + // for codex teardown. + if (format === 'codex') { + if (!opts.command) return; + const hooksJson: CodexHooksJson = (await readJson(expanded)) ?? {}; + if (!hooksJson.hooks) return; + let changed = false; + for (const event of Object.keys(hooksJson.hooks)) { + const before = hooksJson.hooks[event]; + const after = before.filter((e) => (e.hooks?.[0]?.command ?? '') !== opts.command); + if (after.length !== before.length) { + changed = true; + if (after.length === 0) { + delete hooksJson.hooks[event]; + } else { + hooksJson.hooks[event] = after; + } + } + } + if (changed) { + await writeJson(expanded, hooksJson); + log.success(`Removed agent hook [${opts.slug}] from ${settingsPath}`); + } + } else { + const settings: ClaudeSettingsJson = (await readJson(expanded)) ?? {}; + if (!settings.hooks) return; + let changed = false; + for (const event of Object.keys(settings.hooks)) { + const before = settings.hooks[event]; + const after = before.filter((e) => !isAgentClaudeEntry(e, opts.slug)); + if (after.length !== before.length) { + changed = true; + if (after.length === 0) { + delete settings.hooks[event]; + } else { + settings.hooks[event] = after; + } + } + } + if (changed) { + await writeJson(expanded, settings); + log.success(`Removed agent hook [${opts.slug}] from ${settingsPath}`); + } + } +} + // ─── Public reconcile API ─────────────────────────────────── /** diff --git a/src/local-agent.ts b/src/local-agent.ts index 32a527bf..35a05fb4 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -21,7 +21,7 @@ import { } from './utils/fs.js'; import { ResourceHandler } from './resources/base.js'; import { RulesHandler, SkillsHandler } from './resources/index.js'; -import { injectHooksToAllTools } from './hooks.js'; +import { injectHooksToAllTools, applyAgentHook, removeAgentHook, isAgentHookSupportedTool, isAgentHookEvent } from './hooks.js'; import { parseHookEvent } from './dashboard-collector.js'; import { getAgentVersion } from './agent-version.js'; import { getMachineId, deriveLocalAgentId } from './machine-id.js'; @@ -84,10 +84,12 @@ type CommandResourceKind = 'skill' | 'rule' | 'claudemd'; // treated as a destructive rule uninstall). uninstall_teamai is NOT here — it // carries a `cmd` and is executed by runCmdCommand (see executeCommand), so the // local agent actually uninstalls itself and acks. -const UNIMPLEMENTED_COMMAND_TYPES = new Set([ - 'install_hook_rule', - 'uninstall_hook_rule', -]); +// install_hook_rule / uninstall_hook_rule are now implemented (see runHookRuleCommand) and are NOT skipped. +const UNIMPLEMENTED_COMMAND_TYPES = new Set([]); + +/** Hook commands this reporter implements (see runHookRuleCommand). Excluded from + * the handle_type==='hook' skip so they dispatch instead of being silently dropped. */ +const IMPLEMENTED_HOOK_COMMAND_TYPES = new Set(['install_hook_rule', 'uninstall_hook_rule']); interface WorkspaceBinding { projectId: number; @@ -186,6 +188,9 @@ interface LocalAgentCommand { version?: string; display_name?: string; cmd?: string; + event?: string; + matcher?: string; + timeout?: number; } /** @@ -196,7 +201,9 @@ interface LocalAgentCommand { * to commandKind() and being acked as a failure. */ function isUnimplementedCommand(command: LocalAgentCommand): boolean { - return UNIMPLEMENTED_COMMAND_TYPES.has(command.type ?? '') || command.handle_type === 'hook'; + const type = command.type ?? ''; + if (IMPLEMENTED_HOOK_COMMAND_TYPES.has(type)) return false; + return UNIMPLEMENTED_COMMAND_TYPES.has(type) || command.handle_type === 'hook'; } interface LocalAgentContext { @@ -341,6 +348,42 @@ async function saveManifest(manifest: LocalAgentManifest): Promise { await writeJson(getManifestPath(), manifest); } +/** One HTTP-source agent hook recorded locally so teardown can find & remove it + * across all formats (codex has no in-file marker, so its command is stored). */ +interface AgentHookRecord { + tool: string; + event: string; + command: string; + matcher?: string; + timeout?: number; +} + +/** slug → record. Kept separate from the resource manifest and from the team + * managed-hooks.json so a team pull never treats agent hooks as stale. */ +type AgentHookManifest = Record; + +function getAgentHookManifestPath(): string { + return path.join(getLocalAgentHome(), 'agent-hooks.json'); +} + +async function loadAgentHookManifest(): Promise { + const data = await readJson(getAgentHookManifestPath()); + return data && typeof data === 'object' ? data : {}; +} + +async function saveAgentHookManifest(manifest: AgentHookManifest): Promise { + await writeJsonAtomic(getAgentHookManifestPath(), manifest); +} + +/** Resolve the current tool's settings file absolute path (user scope, $HOME base). */ +function resolveToolSettingsPath(config: LocalAgentConfig, tool: string): string { + const toolPath = createLocalAgentTeamConfig(config.endpoint).toolPaths[tool]; + if (!toolPath?.settings) { + throw new Error(`unsupported tool: ${tool} (no settings path)`); + } + return path.join(process.env.HOME ?? '', toolPath.settings); +} + function getPluginStatePath(): string { return path.join(getLocalAgentHome(), 'plugins.json'); } @@ -1815,6 +1858,85 @@ async function runCmdCommand( } } +/** + * Execute an install_hook_rule / uninstall_hook_rule sync command (issue #238): + * write or remove a single HTTP-source agent hook in the CURRENT tool's settings, + * tracked in the agent-hook manifest. Only claude / codex format tools are + * supported; cursor and openclaw are rejected. Throws on validation failure so + * the caller acks 'failed' with the message. + * + * Gated by the same TEAMAI_DISABLE_REMOTE_CMD kill-switch as runCmdCommand: an + * agent hook writes a backend-supplied command that the tool auto-runs on session + * events, so the client's single remote-command opt-out disables this surface too. + */ +async function runHookRuleCommand( + config: LocalAgentConfig, + command: LocalAgentCommand, + context: LocalAgentContext, +): Promise { + if (process.env.TEAMAI_DISABLE_REMOTE_CMD === '1') { + throw new Error('remote cmd disabled by client'); + } + const tool = context.tool; + if (!tool) { + throw new Error('install_hook_rule: missing current tool in context'); + } + if (!isAgentHookSupportedTool(tool)) { + throw new Error(`unsupported tool: ${tool}`); + } + const slug = command.slug; + if (!slug) { + throw new Error(`${command.type}: missing slug`); + } + const manifest = await loadAgentHookManifest(); + + if (command.type === 'uninstall_hook_rule') { + const rec = manifest[slug]; + if (rec) { + const settingsPath = resolveToolSettingsPath(config, rec.tool); + await removeAgentHook(settingsPath, rec.tool, { slug, command: rec.command }); + delete manifest[slug]; + await saveAgentHookManifest(manifest); + } + // Missing slug is idempotent success. + return undefined; + } + + // install_hook_rule + const event = command.event; + const cmd = command.cmd; + if (!event || !cmd) { + throw new Error('install_hook_rule: missing event or cmd'); + } + if (!isAgentHookEvent(event)) { + throw new Error(`unsupported event: ${event}`); + } + const timeout = command.timeout ?? 10; + const matcher = command.matcher; // may be undefined → applyAgentHook defaults to '*' + + // If this slug was previously installed, remove the old entry first so re-install + // never leaves a stale hook behind. This must run even when the tool is unchanged: + // applyAgentHook only replaces within the new event (claude) or by the new command + // (codex), so a same-tool re-install that changes the event or command would + // otherwise orphan the old entry. removeAgentHook scans all events by slug (claude) + // and matches prior.command (codex), covering both cases. + const prior = manifest[slug]; + if (prior) { + try { + const priorPath = resolveToolSettingsPath(config, prior.tool); + await removeAgentHook(priorPath, prior.tool, { slug, command: prior.command }); + } catch (e) { + log.debug(`agent hook [${slug}] prior cleanup failed: ${(e as Error).message}`); + } + } + + const settingsPath = resolveToolSettingsPath(config, tool); + await applyAgentHook(settingsPath, tool, { slug, event, command: cmd, matcher, timeout }); + manifest[slug] = { tool, event, command: cmd, matcher, timeout }; + await saveAgentHookManifest(manifest); + return undefined; +} + async function executeCommand( config: LocalAgentConfig, command: LocalAgentCommand, @@ -1825,6 +1947,9 @@ async function executeCommand( if (command.type === 'uninstall_teamai') { return runCmdCommand(command, context); } + if (command.type === 'install_hook_rule' || command.type === 'uninstall_hook_rule') { + return runHookRuleCommand(config, command, context); + } const kind = commandKind(command); const action = commandAction(command); if (!kind || !action) { @@ -2133,6 +2258,30 @@ export async function teardownLocalAgentPlugins(): Promise { } } +/** + * Remove every HTTP-source agent hook recorded in the agent-hook manifest from + * each tool's settings, then clear the manifest. Best-effort; used by + * `source remove-http` and `teamai uninstall` teardown (issue #238). Safe to call + * when no config / no manifest exists. + */ +export async function removeAllAgentHooks(): Promise { + const config = await loadLocalAgentConfig(); + if (!config) return; + const manifest = await loadAgentHookManifest(); + const slugs = Object.keys(manifest); + if (slugs.length === 0) return; + for (const slug of slugs) { + const rec = manifest[slug]; + try { + const settingsPath = resolveToolSettingsPath(config, rec.tool); + await removeAgentHook(settingsPath, rec.tool, { slug, command: rec.command }); + } catch (e) { + log.debug(`agent hook [${slug}] teardown failed: ${(e as Error).message}`); + } + } + await saveAgentHookManifest({}); +} + /** * Tear down the HTTP local-agent bypass: uninstall every resource recorded in the * manifest (skills/rules/claudemd, across all scopes) from the AI tool dirs, then @@ -2168,6 +2317,7 @@ export async function removeLocalAgentHttp(): Promise { } } + await removeAllAgentHooks(); await remove(getLocalAgentHome()); log.success('HTTP source removed (resources uninstalled, config cleared).'); } diff --git a/src/types.ts b/src/types.ts index df3477c0..cc89590a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -407,6 +407,16 @@ export const TEAMAI_HOOK_DESCRIPTION_PREFIX = '[teamai]'; */ export const TEAMAI_CUSTOM_HOOK_PREFIX = '[teamai:hook:'; +/** + * Description prefix for HTTP-source agent hooks (issue #238) installed via the + * `install_hook_rule` sync command. A third, isolated marker namespace: it does + * NOT start with "[teamai] " (built-in) nor "[teamai:hook:" (team), so team-pull + * full-reconcile treats agent hooks as untouched and never deletes them. Only + * `install_hook_rule` / `uninstall_hook_rule` and teardown manage this namespace. + * Format: "[teamai:agent-hook:]". + */ +export const TEAMAI_AGENT_HOOK_PREFIX = '[teamai:agent-hook:'; + export const TEAMAI_ENV_START = '# [teamai:env:start]'; export const TEAMAI_ENV_END = '# [teamai:env:end]'; diff --git a/src/uninstall.ts b/src/uninstall.ts index f64e0875..cfdcbfa1 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -515,6 +515,16 @@ async function executeRemoval(plan: RemovalPlan): Promise { } } + // (a3) Remove HTTP-source agent hooks across all formats via their manifest + // (issue #238). Dynamic import mirrors teardownPlugins — keeps local-agent's + // heavy dependency graph out of uninstall's static import chain. Best-effort. + try { + const { removeAllAgentHooks } = await import('./local-agent.js'); + await removeAllAgentHooks(); + } catch (e) { + log.warn(`Failed to remove agent hooks: ${(e as Error).message}`); + } + // (b) Clean CLAUDE.md teamai section blocks for (const claudeMdPath of plan.claudeMdFiles) { try {