diff --git a/docs/usage-guide.md b/docs/usage-guide.md index fab7563..7b093ac 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 822451e..36c16e7 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 96d0249..3416702 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 09e7eec..6b0f6b9 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 be7b5eb..5958d56 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 32a527b..35a05fb 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 df3477c..cc89590 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 f64e087..cfdcbfa 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 {