fix(hooks): remove speculative regex runtime
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/hooks/README.md
|
||||
README.md: 9084f93e6b76e366f81986a052eb35e3811a0c13
|
||||
README.zh.md: e61753b39d1f2af97f6ab4d5ab2fa18d8f84ec99
|
||||
README.md: 23478fb5e9b813a3370ce465104b1f9db8b0a26a
|
||||
README.zh.md: 741300a9a390a8f254c01733e5326be84541a78d
|
||||
|
||||
@@ -10,4 +10,4 @@ The hooks subsystem lets users extend the agent at lifecycle points the way Clau
|
||||
| `hooks-claude/` | Bridge for a Claude Code `hooks.json` / settings | plugin |
|
||||
| `hooks-codex/` | Bridge for a Codex `hooks.json` | plugin |
|
||||
|
||||
Codex deliberately reimplements a *subset* of the Claude Code protocol (same `hooks.json` shape, 5 events vs CC's many, command-only, a Rust-regex matcher dialect, no env/substitution), so `hook-protocol` owns the genuinely-identical primitives and each bridge owns only what differs (its per-event stdin payload, env, and the mapping of a hook's neutral outcome onto the harness's typed Decisions). See [hook-protocol/README.md](hook-protocol/README.md).
|
||||
Codex deliberately reimplements a *subset* of the Claude Code protocol (same `hooks.json` shape, 5 events vs CC's many, command-only, regex-only matcher, no env/substitution), so `hook-protocol` owns the genuinely-identical primitives and each bridge owns only what differs (its per-event stdin payload, env, and the mapping of a hook's neutral outcome onto the harness's typed Decisions). See [hook-protocol/README.md](hook-protocol/README.md).
|
||||
|
||||
@@ -10,4 +10,4 @@ hooks 子系统让用户可以像使用 Claude Code 和 Codex 一样,在 agent
|
||||
| `hooks-claude/` | Claude Code `hooks.json`/settings 的桥接 | 插件 |
|
||||
| `hooks-codex/` | Codex `hooks.json` 的桥接 | 插件 |
|
||||
|
||||
Codex 有意重新实现 Claude Code 协议的一个*子集*(`hooks.json` 结构相同、5 个事件而非 CC 的众多事件、仅命令、使用 Rust 正则 matcher 方言、没有 env/替换),因此 `hook-protocol` 负责真正相同的原语,每个桥接只负责不同部分(逐事件 stdin 载荷、env,以及把 hook 的中性结果映射到 harness 类型化 Decision 的方式)。参见 [hook-protocol/README.md](hook-protocol/README.md)。
|
||||
Codex 有意重新实现 Claude Code 协议的一个*子集*(`hooks.json` 结构相同、5 个事件而非 CC 的众多事件、仅命令、仅使用正则的 matcher、没有 env/替换),因此 `hook-protocol` 负责真正相同的原语,每个桥接只负责不同部分(逐事件 stdin 载荷、env,以及把 hook 的中性结果映射到 harness 类型化 Decision 的方式)。参见 [hook-protocol/README.md](hook-protocol/README.md)。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/hooks/hook-protocol/README.md
|
||||
README.md: 3a44aaaf17034b310a91ac9686bd9a0d6690de11
|
||||
README.zh.md: 3e3e56f9af740a973b5765aa57467ebe09a27c83
|
||||
README.md: 8cf4b95c95d43b8fbc27bbdcaf727dabf7d96805
|
||||
README.zh.md: 15a537b67677a401ab434a3e73af1973030780c0
|
||||
|
||||
@@ -10,7 +10,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
|
||||
|
||||
| Concern | Here (`dsh-hook-protocol`) | The bridge (`dsh-hooks-claude` / `-codex`) |
|
||||
|---|---|---|
|
||||
| Matcher validation + test | `compileMatchers(patterns, mode)` exposes diagnostics and repeated config-lifetime matching from one registry; Codex uses a bounded reload-stable Rust-regex interner; `matcherDiagnostic` / `matchesMatcher` are contained one-shot helpers | picks its native regex `mode` (`claude` = JavaScript, `codex` = Rust `regex`), compiles the unique runnable patterns once, rejects a group carrying a registry diagnostic, and disposes its config registry on failure or teardown |
|
||||
| Matcher validation + test | `matcherDiagnostic(pattern, mode)` for parse-time diagnostics; `matchesMatcher(pattern, query, mode)` for contained runtime matching | picks its `mode` (`claude` = literal-or-regex, `codex` = always regex) and rejects a config group carrying a diagnostic |
|
||||
| Run a hook | `runHook(bash, hook, opts, now)` — stdin payload + env via `ctx.bash`, decode | builds the per-event stdin **payload** + the dialect's **env** |
|
||||
| Decode output | `parseHookOutput(exit, stdout, stderr)` → neutral `HookOutput` | maps the neutral `HookOutput` onto a seam-specific typed Decision |
|
||||
| Merge N hooks | `mergeHookOutputs(outputs)` → most-restrictive `MergedHookOutcome` | — |
|
||||
@@ -19,7 +19,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
|
||||
|
||||
## Primitives
|
||||
|
||||
- **`compileMatchers(matchers, mode)` / `matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; both dialects treat a pure `[A-Za-z0-9_|]+` pattern as exact pipe-separated alternatives. Other patterns are unanchored regexes compiled in the native dialect: JavaScript for Claude Code, Rust `regex` for Codex (including inline flags such as `(?i)`). A bridge parser first discards matcher fields for events without matcher subjects and collects the remaining runnable groups, then compiles their unique patterns once. It reads `registry.diagnostic(pattern)` from those exact instances, disposes the config registry before throwing on a rejected pattern, or returns that registry for runtime matching and teardown after detached runs drain. Codex's valid instances and invalid diagnostics are interned on the synchronous `rregex` dependency module, so they survive hook-protocol/Cordis reloads without using `globalThis`; one-shot helpers share the same interner. Because `rregex` cannot shrink its WASM allocation after `free()`, the process deliberately retains at most `MAX_INTERNED_CODEX_REGEX_PATTERNS` (128) distinct non-literal patterns. Once full, a new distinct pattern is rejected with a capacity diagnostic before calling WASM; previously interned patterns continue to work, and a process restart resets the budget. This is bounded for both same-pattern and adversarial unique reloads without an unbounded cache.
|
||||
- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. Bridge parsers discard matcher fields for events without matcher subjects, then use `matcherDiagnostic` to reject an invalid consumed regex with a stable diagnostic before registering any hooks. The runtime predicate still contains an invalid pattern as a non-match, so a direct library caller cannot throw into the agent loop.
|
||||
- **`runHook(bash, hook, options, now)`** — require and forward the caller-owned `options.signal`, serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Cancellation therefore reaches the executor's process-group kill and join boundary. Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations.
|
||||
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** decodes exit status and structured stdout. Exit 2 blocks with stderr; other failures are non-blocking. A matching hook-specific permission decision overrides the legacy top-level decision; mismatched or missing event discriminators suppress only event-specific fields. Top-level fields remain event-agnostic, and successful non-JSON output is left to the bridge.
|
||||
- **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order.
|
||||
|
||||
@@ -10,7 +10,7 @@ Claude Code/Codex hook 协议格式(wire format)的**共享核心**。它
|
||||
|
||||
| 关注点 | 此处(`dsh-hook-protocol`) | 桥接(`dsh-hooks-claude` / `-codex`) |
|
||||
|---|---|---|
|
||||
| Matcher 校验 + 测试 | `compileMatchers(patterns, mode)` 从同一注册表提供诊断与配置生命周期内的重复匹配;Codex 使用有界且跨重载稳定的 Rust 正则 interner;`matcherDiagnostic`/`matchesMatcher` 是隔离的一次性辅助函数 | 选择自身原生正则 `mode`(`claude` = JavaScript,`codex` = Rust `regex`),将可运行的唯一 pattern 只编译一次,拒绝带有注册表诊断的配置组,并在失败或 teardown 时释放配置注册表 |
|
||||
| Matcher 校验 + 测试 | `matcherDiagnostic(pattern, mode)` 用于解析时诊断;`matchesMatcher(pattern, query, mode)` 用于隔离的运行时匹配 | 选择自身的 `mode`(`claude` = 字面量或正则,`codex` = 始终使用正则),并拒绝带有诊断的配置组 |
|
||||
| 运行 hook | `runHook(bash, hook, opts, now)`:通过 `ctx.bash` 提供 stdin payload + env,再解码 | 构造每个事件的 stdin **payload** + 该方言的 **env** |
|
||||
| 解码输出 | `parseHookOutput(exit, stdout, stderr)` → 中性 `HookOutput` | 将中性 `HookOutput` 映射到 seam 特定的类型化 Decision |
|
||||
| 合并 N 个 hook | `mergeHookOutputs(outputs)` → 最严格的 `MergedHookOutcome` | (无) |
|
||||
@@ -19,7 +19,7 @@ Claude Code/Codex hook 协议格式(wire format)的**共享核心**。它
|
||||
|
||||
## 原语
|
||||
|
||||
- **`compileMatchers(matchers, mode)` / `matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;两种方言都将纯 `[A-Za-z0-9_|]+` pattern 视为按 pipe 分隔的精确多选。其他 pattern 会用原生方言编译为未锚定正则:Claude Code 使用 JavaScript,Codex 使用 Rust `regex`(包括 `(?i)` 等内联 flag)。桥接解析器会先丢弃没有 matcher 匹配对象的事件所带字段,收集其余可运行的配置组,再将它们的唯一 pattern 只编译一次。解析器直接从这些实例读取 `registry.diagnostic(pattern)`;pattern 被拒绝时,会先释放配置注册表再抛错,否则把该注册表交给运行时,并在 teardown 时先 drain 脱离运行,再释放它。Codex 的有效实例和无效诊断会 intern 在同步 `rregex` 依赖模块上,因此无需使用 `globalThis`,也能跨 hook-protocol/Cordis 重载保留;一次性辅助函数共享同一 interner。由于 `rregex` 在 `free()` 后也不能缩小 WASM 分配,进程会有意最多保留 `MAX_INTERNED_CODEX_REGEX_PATTERNS`(128)个不同的非字面 pattern。容量用满后,新的不同 pattern 会在调用 WASM 前被容量诊断拒绝;已经 intern 的 pattern 继续工作,重启进程会重置预算。这样既覆盖相同 pattern 重载,也能在恶意唯一 pattern 重载下保持有界,而无需无界缓存。
|
||||
- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;`claude` mode 将纯 `[A-Za-z0-9_|]+` pattern 视为字面量(管道符 = 精确匹配多选),其他 pattern 视为正则;`codex` mode 始终使用未锚定正则。桥接解析器会丢弃没有 matcher 匹配对象的事件所带字段,再用 `matcherDiagnostic` 拒绝事件实际使用的无效正则,并在注册任何钩子之前给出稳定诊断。运行时谓词仍会将无效 pattern 隔离为不匹配,因此直接调用本库不会向 agent loop(智能体循环)抛异常。
|
||||
- **`runHook(bash, hook, options, now)`**:要求并转发调用方拥有的 `options.signal`,将 `options.payload` 序列化到 hook stdin(当且仅当 `options.trailingNewline` 时添加尾随换行符),在执行器凭证清理后合并 `options.env`(`dsh-bash` 受信任插件接口),遵循 hook 的 `timeoutSec`(否则使用 `options.defaultTimeoutMs`;默认值属于桥接,其配置默认为 lib 的 `DEFAULT_HOOK_TIMEOUT_MS` 10 分钟参考值),再解码结果(将 `options.expectedEventName` 传递给 codec)。因此取消会到达执行器的进程组终止与 join 边界。它绝不抛出异常:执行器拒绝(基础设施故障)会变为 `HookOutput`,其 `exitCode: undefined`(非阻塞错误)。`now` 会被注入,以便测试持续时间。
|
||||
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** 解码退出状态与结构化 stdout。退出码为 2 时,会以 stderr 内容阻止执行;其他失败不阻塞。匹配的 hook 特定权限决策会覆盖遗留顶层决策;事件判别字段不匹配或缺失只会抑制事件特定字段。顶层字段仍与事件无关,成功但非 JSON 的输出会留给桥接处理。
|
||||
- **`mergeHookOutputs(outputs)`**:折叠在一个点上匹配的每个 hook 结果:权限优先级为 **deny > ask > allow**,从首个 `continue:false` 起,halt 状态保持不变,阻塞原因用 `\n\n` 连接,`additionalContext`/`systemMessages` 按顺序累积。
|
||||
@@ -29,7 +29,7 @@ Claude Code/Codex hook 协议格式(wire format)的**共享核心**。它
|
||||
|
||||
通过 declaration merging 合并到 `SessionEventMap`(仅日志,与 `compact/*` 相同;不是 `SurfaceEventType`,没有 `surfaceOp`):`hook/invoked`(hook 命令已运行)与 `hook/result`(其结果,按 `handlerId` 配对,决策规则由 `appendHookResult` 负责)。Payload 与每事件 JSDoc 位于生成的 [持久化日志事件目录](../../../docs/persistence-catalog.md);`stderrSummary` 会截断到记录的 `stderrSummaryMaxChars`(桥接配置,参考默认值 `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500;为空时省略)。
|
||||
|
||||
Hook 溯源记录必须位于一个尚未结束的轮次内。轮次中的点(`PreToolUse`/`PostToolUse`/`Stop`)按构造满足这条由所有者定义的关系。`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Note(agent 决策记录)。
|
||||
Hook 溯源记录必须位于一个尚未结束的轮次内。轮次中的点(`PreToolUse`/`PostToolUse`/`Stop`)按构造满足这条由所有者定义的关系。`SessionStart` 与轮次前的 `UserPromptSubmit` 准入 seam 没有 `hook/*` 记录;获准的上下文改由其带来源的 `user/message` 作为证据,详见 hooks Agent Note。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -26,9 +26,6 @@
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"rregex": "1.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
|
||||
@@ -13,13 +13,7 @@ export type {
|
||||
MatcherGroup,
|
||||
MatcherMode,
|
||||
} from './types.ts'
|
||||
export {
|
||||
compileMatchers,
|
||||
matcherDiagnostic,
|
||||
matchesMatcher,
|
||||
MAX_INTERNED_CODEX_REGEX_PATTERNS,
|
||||
} from './matcher.ts'
|
||||
export type { CompiledMatchers } from './matcher.ts'
|
||||
export { matcherDiagnostic, matchesMatcher } from './matcher.ts'
|
||||
export { parseHookOutput } from './codec.ts'
|
||||
export { DEFAULT_HOOK_TIMEOUT_MS, runHook } from './runner.ts'
|
||||
export type { RunHookOptions, RunHookResult } from './runner.ts'
|
||||
|
||||
@@ -1,178 +1,65 @@
|
||||
/**
|
||||
* Matcher shared by both hook dialects. Claude treats alphanumeric/underscore/
|
||||
* pipe patterns as literal alternatives and other patterns as regex. Codex
|
||||
* uses the same literal fast path, then compiles regex patterns with Rust's
|
||||
* `regex` dialect. Missing, empty, and `*` match all. Runtime matching contains
|
||||
* invalid regexes as non-matches. Codex regexes are interned in a bounded pool
|
||||
* shared across module reloads; a config registry leases those instances for
|
||||
* diagnostics and runtime matching without reconstructing them.
|
||||
* pipe patterns as literal alternatives and other patterns as regex; Codex
|
||||
* treats every non-empty pattern as an unanchored regex. Missing, empty, and
|
||||
* `*` match all. Runtime matching contains invalid regexes as non-matches;
|
||||
* config parsers use {@link matcherDiagnostic} to reject them with a diagnostic.
|
||||
* @module @deepseek-ai/dsh-hook-protocol/matcher
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
import type { RRegex as RustRegex } from 'rregex'
|
||||
import type { MatcherMode } from './types.ts'
|
||||
|
||||
type CodexRegexPoolEntry =
|
||||
| { regex: RustRegex }
|
||||
| { diagnostic: string }
|
||||
|
||||
type RRegexModule = {
|
||||
RRegex: new(pattern: string) => RustRegex
|
||||
} & Record<symbol, unknown>
|
||||
|
||||
/** Process-wide ceiling for distinct non-literal Codex matcher patterns. */
|
||||
export const MAX_INTERNED_CODEX_REGEX_PATTERNS = 128
|
||||
|
||||
// rregex's ESM entry initializes WASM with top-level await. Hook plugins are
|
||||
// discovered through Cordis Loader's synchronous module boundary, so use the
|
||||
// package's equivalent synchronous Node entry rather than making both bridge
|
||||
// modules async merely by importing this shared matcher. The versioned symbol
|
||||
// lives on that CJS module instance: Cordis may reload this library module, but
|
||||
// Node retains the dependency module and therefore its bounded intern pool.
|
||||
const rregexModule = createRequire(import.meta.url)('rregex') as RRegexModule
|
||||
const { RRegex } = rregexModule
|
||||
const CODEX_REGEX_POOL_KEY = Symbol.for('@deepseek-ai/dsh-hook-protocol/rregex-pool/v1')
|
||||
const priorPool = rregexModule[CODEX_REGEX_POOL_KEY]
|
||||
const codexRegexPool = priorPool instanceof Map
|
||||
? priorPool as Map<string, CodexRegexPoolEntry>
|
||||
: new Map<string, CodexRegexPoolEntry>()
|
||||
if (!(priorPool instanceof Map)) {
|
||||
rregexModule[CODEX_REGEX_POOL_KEY] = codexRegexPool
|
||||
}
|
||||
|
||||
/** True for an absent / empty / `'*'` pattern — the match-all sentinels. */
|
||||
function isMatchAll(matcher: string | undefined): boolean {
|
||||
return matcher === undefined || matcher === '' || matcher === '*'
|
||||
}
|
||||
|
||||
/** An exact pattern is purely word chars + `|` (the regex-vs-literal discriminator). */
|
||||
const EXACT_MATCHER = /^[A-Za-z0-9_|]+$/
|
||||
/** A Claude-literal pattern is purely word chars + `|` (the regex-vs-literal discriminator). */
|
||||
const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/
|
||||
|
||||
interface CompiledMatcher {
|
||||
matches(query: string): boolean
|
||||
diagnostic?: string
|
||||
}
|
||||
|
||||
/** A config-lifetime matcher set compiled once and explicitly disconnected. */
|
||||
export interface CompiledMatchers {
|
||||
/** Match one of the patterns supplied to {@link compileMatchers}. */
|
||||
matches(matcher: string | undefined, query: string): boolean
|
||||
/** Diagnose one supplied pattern using the already-compiled instance. */
|
||||
diagnostic(matcher: string | undefined): string | undefined
|
||||
/** Release this registry's references. Safe to call more than once. */
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/** Intern one Codex regex or its diagnostic without exceeding the process budget. */
|
||||
function internCodexRegex(pattern: string): CodexRegexPoolEntry {
|
||||
const existing = codexRegexPool.get(pattern)
|
||||
if (existing !== undefined) return existing
|
||||
if (codexRegexPool.size >= MAX_INTERNED_CODEX_REGEX_PATTERNS) {
|
||||
return {
|
||||
diagnostic: `codex regex matcher capacity exceeded (${MAX_INTERNED_CODEX_REGEX_PATTERNS} distinct patterns per process) for ${JSON.stringify(pattern)}`,
|
||||
}
|
||||
}
|
||||
|
||||
let entry: CodexRegexPoolEntry
|
||||
/** Compile an unanchored matcher regex; invalid patterns return `undefined`. */
|
||||
function compileRegex(pattern: string): RegExp | undefined {
|
||||
try {
|
||||
entry = { regex: new RRegex(pattern) }
|
||||
return new RegExp(pattern)
|
||||
} catch (_syntaxError) {
|
||||
// Regex construction is the try's only operation, so malformed syntax in
|
||||
// Rust's dialect is the only expected failure. Cache failures too: a bad
|
||||
// config repeatedly reloaded must not keep growing WASM memory.
|
||||
entry = { diagnostic: `invalid codex regex matcher ${JSON.stringify(pattern)}` }
|
||||
}
|
||||
codexRegexPool.set(pattern, entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
/** Compile one matcher into a reusable predicate. */
|
||||
function compileMatcher(matcher: string | undefined, mode: MatcherMode): CompiledMatcher {
|
||||
if (isMatchAll(matcher)) return { matches: () => true }
|
||||
const pattern = matcher as string
|
||||
if (EXACT_MATCHER.test(pattern)) {
|
||||
const alternatives = new Set(pattern.split('|'))
|
||||
return { matches: query => alternatives.has(query) }
|
||||
}
|
||||
|
||||
if (mode === 'codex') {
|
||||
const entry = internCodexRegex(pattern)
|
||||
if ('regex' in entry) {
|
||||
const regex = entry.regex
|
||||
return { matches: query => regex.isMatch(query) }
|
||||
}
|
||||
return {
|
||||
matches: () => false,
|
||||
diagnostic: entry.diagnostic,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const regex = new RegExp(pattern)
|
||||
return { matches: query => regex.test(query) }
|
||||
} catch (_syntaxError) {
|
||||
return {
|
||||
matches: () => false,
|
||||
diagnostic: `invalid claude regex matcher ${JSON.stringify(pattern)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a finite config's unique matcher patterns for repeated evaluation.
|
||||
* The returned registry owns one config's references. Codex native instances
|
||||
* live in a bounded, reload-stable process pool; disposal disconnects this
|
||||
* config but deliberately keeps interned instances for later reloads.
|
||||
* @param matchers - the complete finite set of patterns in one loaded config.
|
||||
* @param mode - the native regex dialect used for non-literal patterns.
|
||||
* @returns a reusable registry that disconnects its config-local lookups on disposal.
|
||||
*/
|
||||
export function compileMatchers(matchers: Iterable<string | undefined>, mode: MatcherMode): CompiledMatchers {
|
||||
const compiled = new Map<string | undefined, CompiledMatcher>()
|
||||
for (const matcher of matchers) {
|
||||
if (!compiled.has(matcher)) compiled.set(matcher, compileMatcher(matcher, mode))
|
||||
}
|
||||
let disposed = false
|
||||
return {
|
||||
matches(matcher, query) {
|
||||
if (disposed) return false
|
||||
return compiled.get(matcher)?.matches(query) ?? false
|
||||
},
|
||||
diagnostic(matcher) {
|
||||
if (disposed) return undefined
|
||||
return compiled.get(matcher)?.diagnostic
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
compiled.clear()
|
||||
},
|
||||
// RegExp construction is the try's only operation, so malformed pattern
|
||||
// syntax is the only expected failure.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one matcher before a bridge accepts its config group.
|
||||
* @param matcher - configured pattern; match-all sentinels are valid.
|
||||
* @param mode - dialect deciding which regex engine validates non-literal patterns.
|
||||
* @param mode - dialect deciding whether a word-and-pipe pattern is literal.
|
||||
* @returns `undefined` for a valid matcher, otherwise a stable diagnostic.
|
||||
*/
|
||||
export function matcherDiagnostic(matcher: string | undefined, mode: MatcherMode): string | undefined {
|
||||
return compileMatcher(matcher, mode).diagnostic
|
||||
if (isMatchAll(matcher)) return undefined
|
||||
const pattern = matcher as string
|
||||
if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) return undefined
|
||||
return compileRegex(pattern) === undefined
|
||||
? `invalid ${mode} regex matcher ${JSON.stringify(pattern)}`
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `matcher` selects `query` under the given dialect. Literal patterns
|
||||
* exact-match pipe-separated alternatives; all other patterns are unanchored
|
||||
* regexes in the selected dialect. Invalid regexes return `false` rather than
|
||||
* throwing; bridge config parsers surface them through {@link matcherDiagnostic}
|
||||
* before use.
|
||||
* Whether `matcher` selects `query` under the given dialect. Claude literal
|
||||
* patterns exact-match pipe-separated alternatives; all other patterns are
|
||||
* unanchored regexes. Invalid regexes return `false` rather than throwing;
|
||||
* bridge config parsers surface them through {@link matcherDiagnostic} before use.
|
||||
* @param matcher - the configured pattern; absent/empty/`'*'` are the match-all sentinels.
|
||||
* @param query - the candidate value (a tool name, a session source, …).
|
||||
* @param mode - the dialect deciding which regex engine matches the pattern.
|
||||
* @param mode - the dialect deciding literal-vs-regex interpretation of the pattern.
|
||||
* @returns `true` when the pattern selects the query; `false` on a non-match or an invalid
|
||||
* regex.
|
||||
*/
|
||||
export function matchesMatcher(matcher: string | undefined, query: string, mode: MatcherMode): boolean {
|
||||
return compileMatcher(matcher, mode).matches(query)
|
||||
if (isMatchAll(matcher)) return true
|
||||
// matcher is a non-empty string past the match-all guard.
|
||||
const pattern = matcher as string
|
||||
if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) {
|
||||
return pattern.split('|').includes(query)
|
||||
}
|
||||
return compileRegex(pattern)?.test(query) ?? false
|
||||
}
|
||||
|
||||
@@ -71,10 +71,10 @@ export interface MatcherGroup {
|
||||
}
|
||||
|
||||
/**
|
||||
* How a matcher pattern is interpreted. Both dialects use an exact-match fast
|
||||
* path when the pattern is purely `[A-Za-z0-9_|]+` (pipe = alternation), then
|
||||
* use their native regex dialect otherwise: JavaScript for Claude Code and Rust
|
||||
* `regex` for Codex. The bridge picks the mode for its dialect.
|
||||
* How a matcher pattern is interpreted. Claude Code uses {@link literal} when the
|
||||
* pattern is purely `[A-Za-z0-9_|]+` (pipe = exact-match alternation) and
|
||||
* {@link regex} otherwise; Codex is always {@link regex}. The bridge picks the
|
||||
* mode for its dialect.
|
||||
*/
|
||||
export type MatcherMode = 'claude' | 'codex'
|
||||
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RRegex as RustRegex } from 'rregex'
|
||||
|
||||
const POOL_KEY = Symbol.for('@deepseek-ai/dsh-hook-protocol/rregex-pool/v1')
|
||||
|
||||
interface PoolEntry {
|
||||
regex?: RustRegex
|
||||
}
|
||||
|
||||
type RRegexModule = {
|
||||
RRegex: new(pattern: string) => RustRegex
|
||||
__wbindgen_memory(): WebAssembly.Memory
|
||||
} & Record<symbol, unknown>
|
||||
|
||||
function restorePool(rregex: RRegexModule, original: unknown): void {
|
||||
Reflect.deleteProperty(rregex, POOL_KEY)
|
||||
if (original !== undefined) rregex[POOL_KEY] = original
|
||||
}
|
||||
|
||||
describe('Codex regex intern lifecycle', () => {
|
||||
it('keeps 100,000 same-pattern reloads bounded and reuses across module reload', async () => {
|
||||
const require = createRequire(import.meta.url)
|
||||
const rregex = require('rregex') as RRegexModule
|
||||
const OriginalRRegex = rregex.RRegex
|
||||
const originalPool = rregex[POOL_KEY]
|
||||
const construct = vi.fn<(pattern: string) => void>()
|
||||
const free = vi.fn<() => void>()
|
||||
|
||||
class CountingRRegex extends OriginalRRegex {
|
||||
constructor(pattern: string) {
|
||||
super(pattern)
|
||||
construct(pattern)
|
||||
}
|
||||
|
||||
override free(): void {
|
||||
free()
|
||||
super.free()
|
||||
}
|
||||
}
|
||||
|
||||
Reflect.deleteProperty(rregex, POOL_KEY)
|
||||
rregex.RRegex = CountingRRegex
|
||||
vi.resetModules()
|
||||
const before = rregex.__wbindgen_memory().buffer.byteLength
|
||||
try {
|
||||
const first = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts')
|
||||
for (let i = 0; i < 100_000; i++) {
|
||||
first.compileMatchers(['(?i)^bash$'], 'codex').dispose()
|
||||
}
|
||||
expect(construct).toHaveBeenCalledExactlyOnceWith('(?i)^bash$')
|
||||
expect(free).not.toHaveBeenCalled()
|
||||
expect(rregex.__wbindgen_memory().buffer.byteLength - before).toBeLessThanOrEqual(4 * 1024 * 1024)
|
||||
|
||||
vi.resetModules()
|
||||
const reloaded = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts')
|
||||
expect(reloaded.matcherDiagnostic('(?i)^bash$', 'codex')).toBeUndefined()
|
||||
expect(reloaded.matchesMatcher('(?i)^bash$', 'BASH', 'codex')).toBe(true)
|
||||
expect(construct).toHaveBeenCalledTimes(1)
|
||||
expect(free).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
const temporaryPool = rregex[POOL_KEY]
|
||||
if (temporaryPool instanceof Map) {
|
||||
for (const entry of temporaryPool.values() as Iterable<PoolEntry>) entry.regex?.free()
|
||||
}
|
||||
rregex.RRegex = OriginalRRegex
|
||||
restorePool(rregex, originalPool)
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
|
||||
it('memoizes failures and rejects a new pattern before construction at the hard cap', async () => {
|
||||
const require = createRequire(import.meta.url)
|
||||
const rregex = require('rregex') as RRegexModule
|
||||
const OriginalRRegex = rregex.RRegex
|
||||
const originalPool = rregex[POOL_KEY]
|
||||
const construct = vi.fn<(pattern: string) => void>()
|
||||
|
||||
class FakeRRegex {
|
||||
constructor(pattern: string) {
|
||||
construct(pattern)
|
||||
if (pattern === 'invalid(') throw new SyntaxError('invalid test pattern')
|
||||
}
|
||||
|
||||
isMatch(): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
Reflect.deleteProperty(rregex, POOL_KEY)
|
||||
rregex.RRegex = FakeRRegex as unknown as typeof rregex.RRegex
|
||||
vi.resetModules()
|
||||
try {
|
||||
const matcher = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts')
|
||||
expect(matcher.matcherDiagnostic('invalid(', 'codex')).toBe('invalid codex regex matcher "invalid("')
|
||||
expect(matcher.matcherDiagnostic('invalid(', 'codex')).toBe('invalid codex regex matcher "invalid("')
|
||||
expect(construct).toHaveBeenCalledTimes(1)
|
||||
|
||||
for (let i = 0; i < matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS - 1; i++) {
|
||||
expect(matcher.matcherDiagnostic(`^value-${i}$`, 'codex')).toBeUndefined()
|
||||
}
|
||||
expect(construct).toHaveBeenCalledTimes(matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS)
|
||||
|
||||
expect(matcher.matcherDiagnostic('^overflow$', 'codex')).toBe(
|
||||
`codex regex matcher capacity exceeded (${matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS} distinct patterns per process) for "^overflow$"`,
|
||||
)
|
||||
expect(matcher.matchesMatcher('^overflow$', 'overflow', 'codex')).toBe(false)
|
||||
expect(construct).toHaveBeenCalledTimes(matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS)
|
||||
|
||||
vi.resetModules()
|
||||
const reloaded = await import('@deepseek-ai/dsh-hook-protocol/src/matcher.ts')
|
||||
expect(reloaded.matchesMatcher('^value-0$', 'anything', 'codex')).toBe(true)
|
||||
expect(reloaded.matcherDiagnostic('invalid(', 'codex')).toBe('invalid codex regex matcher "invalid("')
|
||||
expect(construct).toHaveBeenCalledTimes(matcher.MAX_INTERNED_CODEX_REGEX_PATTERNS)
|
||||
} finally {
|
||||
rregex.RRegex = OriginalRRegex
|
||||
restorePool(rregex, originalPool)
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { compileMatchers, matcherDiagnostic, matchesMatcher } from '@deepseek-ai/dsh-hook-protocol'
|
||||
import { matcherDiagnostic, matchesMatcher } from '@deepseek-ai/dsh-hook-protocol'
|
||||
|
||||
describe('matchesMatcher — match-all sentinels (both dialects)', () => {
|
||||
for (const mode of ['claude', 'codex'] as const) {
|
||||
@@ -34,10 +34,11 @@ describe('matchesMatcher — claude dialect (literal-or-regex)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchesMatcher — codex dialect (literal-or-Rust-regex)', () => {
|
||||
it('a word pattern uses Codex exact-match semantics', () => {
|
||||
describe('matchesMatcher — codex dialect (always regex)', () => {
|
||||
it('a word pattern is an unanchored regex (substring matches, unlike claude literal)', () => {
|
||||
expect(matchesMatcher('Bash', 'Bash', 'codex')).toBe(true)
|
||||
expect(matchesMatcher('Bash', 'BashOutput', 'codex')).toBe(false)
|
||||
// codex has NO literal fast path: "Bash" is /Bash/, so it DOES match a substring
|
||||
expect(matchesMatcher('Bash', 'BashOutput', 'codex')).toBe(true)
|
||||
})
|
||||
|
||||
it('regex alternation and anchors work', () => {
|
||||
@@ -45,14 +46,6 @@ describe('matchesMatcher — codex dialect (literal-or-Rust-regex)', () => {
|
||||
expect(matchesMatcher('^Bash$', 'Bash', 'codex')).toBe(true)
|
||||
expect(matchesMatcher('^Bash$', 'BashOutput', 'codex')).toBe(false)
|
||||
})
|
||||
|
||||
it('uses Rust regex syntax and matching semantics', () => {
|
||||
expect(matchesMatcher('(?i)bash', 'xxBASHyy', 'codex')).toBe(true)
|
||||
expect(matchesMatcher('(?x)^ b a s h $ # policy matcher', 'bash', 'codex')).toBe(true)
|
||||
expect(matchesMatcher('^\\p{Greek}+$', 'αβ', 'codex')).toBe(true)
|
||||
// JavaScript accepts look-around, but Rust regex deliberately does not.
|
||||
expect(matchesMatcher('(?=Bash)', 'Bash', 'codex')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchesMatcher — invalid regex is a non-match (never throws)', () => {
|
||||
@@ -72,42 +65,10 @@ describe('matcherDiagnostic — parse-time diagnostics', () => {
|
||||
expect(matcherDiagnostic('Edit|Write', 'claude')).toBeUndefined()
|
||||
expect(matcherDiagnostic('^Bash$', 'claude')).toBeUndefined()
|
||||
expect(matcherDiagnostic('Edit|Write', 'codex')).toBeUndefined()
|
||||
expect(matcherDiagnostic('(?i)bash', 'codex')).toBeUndefined()
|
||||
expect(matcherDiagnostic('(?x)^ b a s h $ # policy matcher', 'codex')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns a stable diagnostic for invalid regexes in either dialect', () => {
|
||||
expect(matcherDiagnostic('(', 'claude')).toBe('invalid claude regex matcher "("')
|
||||
expect(matcherDiagnostic('[', 'codex')).toBe('invalid codex regex matcher "["')
|
||||
expect(matcherDiagnostic('(?=Bash)', 'codex')).toBe('invalid codex regex matcher "(?=Bash)"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('compileMatchers — config-lifetime reuse', () => {
|
||||
it('compiles a finite set, contains unknown patterns, and stops after disposal', () => {
|
||||
const matchers = compileMatchers([undefined, 'Edit|Write', '(?i)^bash$', '['], 'codex')
|
||||
|
||||
expect(matchers.matches(undefined, 'anything')).toBe(true)
|
||||
expect(matchers.matches('Edit|Write', 'Write')).toBe(true)
|
||||
expect(matchers.matches('Edit|Write', 'WriteFile')).toBe(false)
|
||||
expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(true)
|
||||
expect(matchers.matches('[', 'anything')).toBe(false)
|
||||
expect(matchers.matches('not-compiled', 'not-compiled')).toBe(false)
|
||||
expect(matchers.diagnostic('(?i)^bash$')).toBeUndefined()
|
||||
expect(matchers.diagnostic('[')).toBe('invalid codex regex matcher "["')
|
||||
expect(matchers.diagnostic('not-compiled')).toBeUndefined()
|
||||
|
||||
matchers.dispose()
|
||||
expect(matchers.matches(undefined, 'anything')).toBe(false)
|
||||
expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(false)
|
||||
expect(matchers.diagnostic('[')).toBeUndefined()
|
||||
expect(() => { matchers.dispose() }).not.toThrow()
|
||||
})
|
||||
|
||||
it('reuses JavaScript regexes too', () => {
|
||||
const matchers = compileMatchers(['^Bash$', '^Bash$'], 'claude')
|
||||
expect(matchers.matches('^Bash$', 'Bash')).toBe(true)
|
||||
expect(matchers.matches('^Bash$', 'BashOutput')).toBe(false)
|
||||
matchers.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,11 +6,7 @@
|
||||
* @module @deepseek-ai/dsh-hooks-claude/config
|
||||
*/
|
||||
|
||||
import {
|
||||
compileMatchers,
|
||||
type CompiledMatchers,
|
||||
type MatcherGroup,
|
||||
} from '@deepseek-ai/dsh-hook-protocol'
|
||||
import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
|
||||
|
||||
const CLAUDE_EVENTS = [
|
||||
'SessionStart',
|
||||
@@ -35,8 +31,6 @@ export interface SkippedHook {
|
||||
export interface ParsedClaudeConfig {
|
||||
config: ClaudeHookConfig
|
||||
skipped: SkippedHook[]
|
||||
/** Config-scoped matcher registry; the caller owns and must dispose it. */
|
||||
matchers: CompiledMatchers
|
||||
}
|
||||
|
||||
/** Substitution variables applied to each `command` string at parse time. */
|
||||
@@ -74,7 +68,6 @@ export function substituteCommand(command: string, vars: SubstitutionVars): stri
|
||||
* command. Matcher fields on UserPromptSubmit and Stop are discarded because those events have no
|
||||
* matcher subject. A matcher-bearing supported runnable group with an invalid regex throws a
|
||||
* `SyntaxError`, allowing the bridge to reject the complete config before listener registration.
|
||||
* Validation and runtime matching share the returned compiled registry; its caller must dispose it.
|
||||
*
|
||||
* @param raw - the parsed JSON config: a settings object with a `hooks` key, or the bare
|
||||
* event map.
|
||||
@@ -88,54 +81,43 @@ export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): Pa
|
||||
// Accept either `{ hooks: { … } }` (a settings file) or the bare event map.
|
||||
const root = asObject(raw)
|
||||
const hooksMap = root ? asObject(root.hooks) ?? root : undefined
|
||||
if (hooksMap) {
|
||||
for (const event of CLAUDE_EVENTS) {
|
||||
const rawGroups = hooksMap[event]
|
||||
if (!Array.isArray(rawGroups)) continue
|
||||
const groups: MatcherGroup[] = []
|
||||
for (const rawGroup of rawGroups) {
|
||||
const group = asObject(rawGroup)
|
||||
if (!group || !Array.isArray(group.hooks)) continue
|
||||
const commands: MatcherGroup['hooks'] = []
|
||||
for (const rawHook of group.hooks) {
|
||||
const hook = asObject(rawHook)
|
||||
if (!hook) continue
|
||||
const type = typeof hook.type === 'string' ? hook.type : 'command'
|
||||
if (type !== 'command') {
|
||||
skipped.push({ event, type })
|
||||
continue
|
||||
}
|
||||
if (typeof hook.command !== 'string') continue
|
||||
commands.push({
|
||||
command: substituteCommand(hook.command, vars),
|
||||
...typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {},
|
||||
})
|
||||
if (!hooksMap) return { config, skipped }
|
||||
|
||||
for (const event of CLAUDE_EVENTS) {
|
||||
const rawGroups = hooksMap[event]
|
||||
if (!Array.isArray(rawGroups)) continue
|
||||
const groups: MatcherGroup[] = []
|
||||
for (const rawGroup of rawGroups) {
|
||||
const group = asObject(rawGroup)
|
||||
if (!group || !Array.isArray(group.hooks)) continue
|
||||
const commands: MatcherGroup['hooks'] = []
|
||||
for (const rawHook of group.hooks) {
|
||||
const hook = asObject(rawHook)
|
||||
if (!hook) continue
|
||||
const type = typeof hook.type === 'string' ? hook.type : 'command'
|
||||
if (type !== 'command') {
|
||||
skipped.push({ event, type })
|
||||
continue
|
||||
}
|
||||
if (commands.length === 0) continue
|
||||
const matcher = event === 'UserPromptSubmit' || event === 'Stop'
|
||||
? undefined
|
||||
: typeof group.matcher === 'string' ? group.matcher : undefined
|
||||
groups.push({
|
||||
...matcher !== undefined ? { matcher } : {},
|
||||
hooks: commands,
|
||||
if (typeof hook.command !== 'string') continue
|
||||
commands.push({
|
||||
command: substituteCommand(hook.command, vars),
|
||||
...typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {},
|
||||
})
|
||||
}
|
||||
if (groups.length > 0) config[event] = groups
|
||||
if (commands.length === 0) continue
|
||||
const matcher = event === 'UserPromptSubmit' || event === 'Stop'
|
||||
? undefined
|
||||
: typeof group.matcher === 'string' ? group.matcher : undefined
|
||||
const diagnostic = matcherDiagnostic(matcher, 'claude')
|
||||
if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`)
|
||||
groups.push({
|
||||
...matcher !== undefined ? { matcher } : {},
|
||||
hooks: commands,
|
||||
})
|
||||
}
|
||||
if (groups.length > 0) config[event] = groups
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- dialect-local event diagnostics intentionally stay beside parsing. */
|
||||
const entries = Object.entries(config).flatMap(([event, groups]) => (
|
||||
groups.map(group => ({ event, matcher: group.matcher }))
|
||||
))
|
||||
const matchers = compileMatchers(new Set(entries.map(entry => entry.matcher)), 'claude')
|
||||
for (const { event, matcher } of entries) {
|
||||
const diagnostic = matchers.diagnostic(matcher)
|
||||
if (diagnostic === undefined) continue
|
||||
matchers.dispose()
|
||||
throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`)
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
return { config, skipped, matchers }
|
||||
return { config, skipped }
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
createDetachedRuns,
|
||||
DEFAULT_HOOK_TIMEOUT_MS,
|
||||
DEFAULT_STDERR_SUMMARY_MAX_CHARS,
|
||||
matchesMatcher,
|
||||
mergeHookOutputs,
|
||||
runHook,
|
||||
type HookOutput,
|
||||
@@ -34,7 +35,7 @@ import {
|
||||
// declarations (declaration-merged into cordis `Events` by dsh-subagent) so the
|
||||
// SubagentStart/SubagentStop listeners below type-check.
|
||||
import type {} from '@deepseek-ai/dsh-subagent'
|
||||
import { parseClaudeConfig, type ParsedClaudeConfig } from './config.ts'
|
||||
import { parseClaudeConfig, type ClaudeHookConfig } from './config.ts'
|
||||
|
||||
export const name = 'hooks-claude'
|
||||
// `bash` is required to run hooks; the rest are read opportunistically via
|
||||
@@ -99,37 +100,26 @@ export function apply(ctx: Context, config: Config): void {
|
||||
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
|
||||
const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS
|
||||
// Parse once at load. A read or parse failure logs and registers nothing.
|
||||
let result: ParsedClaudeConfig
|
||||
let parsed: ClaudeHookConfig = {}
|
||||
try {
|
||||
const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8'))
|
||||
result = parseClaudeConfig(raw, {
|
||||
const result = parseClaudeConfig(raw, {
|
||||
...config.pluginRoot !== undefined ? { pluginRoot: config.pluginRoot } : {},
|
||||
...config.projectDir !== undefined ? { projectDir: config.projectDir } : {},
|
||||
})
|
||||
parsed = result.config
|
||||
for (const s of result.skipped) {
|
||||
ctx.logger.warn(`hooks-claude: skipping unsupported "${s.type}" hook on ${s.event} (only command hooks run)`)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`hooks-claude: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`)
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = result.config
|
||||
// Parsing validates through this same registry, so admission and runtime do
|
||||
// not construct separate matcher instances.
|
||||
const matchers = result.matchers
|
||||
|
||||
// Emit-shaped points run detached, so track their chains; disposal aborts
|
||||
// active hooks and drains continuations before releasing matchers.
|
||||
// active hooks and drains continuations before resolving.
|
||||
const detached = createDetachedRuns()
|
||||
ctx.effect(() => async () => {
|
||||
try {
|
||||
await detached.drain()
|
||||
} finally {
|
||||
matchers.dispose()
|
||||
}
|
||||
}, 'hooks-claude: drain detached hook runs and dispose matchers')
|
||||
|
||||
for (const s of result.skipped) {
|
||||
ctx.logger.warn(`hooks-claude: skipping unsupported "${s.type}" hook on ${s.event} (only command hooks run)`)
|
||||
}
|
||||
ctx.effect(() => () => detached.drain(), 'hooks-claude: drain detached hook runs')
|
||||
|
||||
/**
|
||||
* Run every command hook configured for `point` whose matcher selects
|
||||
@@ -157,7 +147,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const projectDir = config.projectDir ?? workdir
|
||||
const hookEnv = projectDir !== undefined ? { CLAUDE_PROJECT_DIR: projectDir } : undefined
|
||||
for (const group of groups) {
|
||||
if (!matchers.matches(group.matcher, matchQuery)) continue
|
||||
if (!matchesMatcher(group.matcher, matchQuery, 'claude')) continue
|
||||
for (const hook of group.hooks) {
|
||||
const handlerId = nextHandlerId(point)
|
||||
const session = opts.agent?.session
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { parseClaudeConfig as parseRawClaudeConfig, substituteCommand } from '@deepseek-ai/dsh-hooks-claude/src/config.ts'
|
||||
|
||||
const matcherSets: Array<ReturnType<typeof parseRawClaudeConfig>['matchers']> = []
|
||||
afterEach(() => { for (const matchers of matcherSets.splice(0)) matchers.dispose() })
|
||||
|
||||
function parseClaudeConfig(...args: Parameters<typeof parseRawClaudeConfig>): ReturnType<typeof parseRawClaudeConfig> {
|
||||
const result = parseRawClaudeConfig(...args)
|
||||
matcherSets.push(result.matchers)
|
||||
return result
|
||||
}
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseClaudeConfig, substituteCommand } from '@deepseek-ai/dsh-hooks-claude/src/config.ts'
|
||||
|
||||
describe('substituteCommand', () => {
|
||||
it('replaces CLAUDE_PLUGIN_ROOT and CLAUDE_PROJECT_DIR (all occurrences)', () => {
|
||||
@@ -73,13 +64,6 @@ describe('parseClaudeConfig', () => {
|
||||
expect('matcher' in config.Stop![0]!).toBe(false)
|
||||
})
|
||||
|
||||
it('returns the same validated matcher registry for runtime use', () => {
|
||||
const { matchers } = parseClaudeConfig({
|
||||
PreToolUse: [{ matcher: '^Bash$', hooks: [{ type: 'command', command: 'x.sh' }] }],
|
||||
})
|
||||
expect(matchers.matches('^Bash$', 'Bash')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects an invalid regex matcher with its event name', () => {
|
||||
expect(() => parseClaudeConfig({
|
||||
PreToolUse: [{ matcher: '(', hooks: [{ type: 'command', command: 'x.sh' }] }],
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/hooks/hooks-codex/README.md
|
||||
README.md: eb8882cda590293e21dd6011244f15359a797768
|
||||
README.zh.md: 641b7b57ad78f21d2df52dfc05ff4e8466a0f1c0
|
||||
README.md: e906810ed58c3d0204c618c32787af06c91cfb78
|
||||
README.zh.md: 4940fdb976dd963bbb2e41c0ec6ef274ee475334
|
||||
|
||||
@@ -7,7 +7,7 @@ A cordis plugin that runs the supported subset of a user's existing **Codex** ho
|
||||
This bridge implements a deliberate subset of Codex's current hook protocol:
|
||||
|
||||
- **Five of ten hook points:** `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, and `Stop`.
|
||||
- **Native Codex matcher semantics:** pure word/pipe patterns are exact alternatives; other patterns are unanchored Rust `regex` expressions (including inline flags such as `(?i)`).
|
||||
- **Regex-only matchers** (no literal fast path; the matcher is always an unanchored regex).
|
||||
- **snake_case stdin payloads** with `turn_id`/`model` extras, written **without** a trailing newline.
|
||||
- **No Codex plugin env injection and no config-time placeholder substitution** (the command still receives the executor's environment and runs through its shell).
|
||||
- **No pre-tool approval or rewrite path** — a hook can block, but the bridge does not pre-approve or replace tool input.
|
||||
@@ -34,7 +34,7 @@ In a `cordis.yml`:
|
||||
model: deepseek-v4
|
||||
```
|
||||
|
||||
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing); an invalid regex matcher on an event that consumes matchers is one such failure and reports its pattern and event. Non-literal Rust-regex patterns are interned across reloads under a process budget of 128 distinct patterns: once full, a new distinct pattern is rejected before WASM construction with a capacity diagnostic, while already interned patterns remain usable; restarting the process resets the budget. Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five bridge-supported points are dropped at parse.
|
||||
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing); an invalid regex matcher on an event that consumes matchers is one such failure and reports its pattern and event. Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five bridge-supported points are dropped at parse.
|
||||
|
||||
The hooks themselves run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` as the hook process's working directory, so a hook operates in the user's project tree, not the server launch dir.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
该桥接实现 Codex 当前 hook 协议的一个明确子集:
|
||||
|
||||
- **10 个 hook 点中的 5 个:** `PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。
|
||||
- **Codex 原生 matcher 语义:** 纯 word/pipe pattern 是精确匹配的多选;其他 pattern 是未锚定的 Rust `regex` 表达式(包括 `(?i)` 等内联 flag)。
|
||||
- **仅使用正则的 matcher**(没有字面量快速路径;matcher 始终是未锚定正则)。
|
||||
- **snake_case stdin payload**,携带 `turn_id`/`model` 额外字段,写入时**不带**尾随换行符。
|
||||
- **没有 Codex 插件 env 注入,也没有配置时 placeholder 替换**(命令仍会接收执行器环境,并通过其 shell 运行)。
|
||||
- **没有工具前审批或改写路径**:hook 可以阻塞,但桥接不会预审批或替换工具输入。
|
||||
@@ -34,7 +34,7 @@ const config: Config = {
|
||||
model: deepseek-v4
|
||||
```
|
||||
|
||||
配置只在加载时解析**一次**。`configPath` 是**进程级**配置:相对路径在加载时根据进程启动 cwd 解析,而非每会话解析(`TODO(per-session-hook-config)`)。读取/解析失败会被隔离处理(记录 + 不注册任何内容);实际消费 matcher 的事件所带的无效 matcher 正则属于此类失败,并报告其 pattern 与事件。非字面的 Rust-regex pattern 会跨重载 intern,并受每进程最多 128 个不同 pattern 的预算约束:容量用满后,新的不同 pattern 会在 WASM 构造前被容量诊断拒绝,已经 intern 的 pattern 仍可使用;重启进程会重置预算。只运行同步 `type: 'command'` hook;非 command 或 `async: true` hook 会被解析并跳过,同时记录警告。hook 接受 `timeout` 或 `timeoutSec` alias;两者都未设置时,使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟)。五个桥接支持点之外的事件会在解析时丢弃。
|
||||
配置只在加载时解析**一次**。`configPath` 是**进程级**配置:相对路径在加载时根据进程启动 cwd 解析,而非每会话解析(`TODO(per-session-hook-config)`)。读取/解析失败会被隔离处理(记录 + 不注册任何内容);实际消费 matcher 的事件所带的无效 matcher 正则属于此类失败,并报告其 pattern 与事件。只运行同步 `type: 'command'` hook;非 command 或 `async: true` hook 会被解析并跳过,同时记录警告。hook 接受 `timeout` 或 `timeoutSec` alias;两者都未设置时,使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`,10 分钟)。五个桥接支持点之外的事件会在解析时丢弃。
|
||||
|
||||
hook 本身会在 agent(智能体)的会话工作区中运行:对 agent scope 点,桥接会将会话 `cwd` 作为 hook 进程工作目录,因此 hook 作用于用户项目树,而非服务器启动目录。
|
||||
|
||||
|
||||
@@ -5,11 +5,7 @@
|
||||
* @module @deepseek-ai/dsh-hooks-codex/config
|
||||
*/
|
||||
|
||||
import {
|
||||
compileMatchers,
|
||||
type CompiledMatchers,
|
||||
type MatcherGroup,
|
||||
} from '@deepseek-ai/dsh-hook-protocol'
|
||||
import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
|
||||
|
||||
/** The five Codex hook points this bridge supports. */
|
||||
export const CODEX_EVENTS = ['PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'Stop'] as const
|
||||
@@ -27,8 +23,6 @@ export interface SkippedHook {
|
||||
export interface ParsedCodexConfig {
|
||||
config: CodexHookConfig
|
||||
skipped: SkippedHook[]
|
||||
/** Config-scoped matcher registry; the caller owns and must dispose it. */
|
||||
matchers: CompiledMatchers
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | undefined {
|
||||
@@ -42,8 +36,7 @@ function asObject(value: unknown): Record<string, unknown> | undefined {
|
||||
* than failing boot; unsupported or asynchronous hooks are returned in `skipped`. Matcher fields on
|
||||
* UserPromptSubmit and Stop are discarded because those events have no matcher subject. A
|
||||
* matcher-bearing runnable group with an invalid regex throws a `SyntaxError`, allowing the bridge
|
||||
* to reject the complete config before listener registration. Validation and runtime matching
|
||||
* share the returned compiled registry; its caller must dispose it.
|
||||
* to reject the complete config before listener registration.
|
||||
* @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map.
|
||||
* @returns the runnable per-event groups plus the skipped hooks with their reasons.
|
||||
*/
|
||||
@@ -52,51 +45,42 @@ export function parseCodexConfig(raw: unknown): ParsedCodexConfig {
|
||||
const skipped: SkippedHook[] = []
|
||||
const root = asObject(raw)
|
||||
const hooksMap = root ? asObject(root.hooks) ?? root : undefined
|
||||
if (hooksMap) {
|
||||
for (const event of CODEX_EVENTS) {
|
||||
const rawGroups = hooksMap[event]
|
||||
// Matcher-group parsing remains dialect-local because the supported hook
|
||||
// shapes and skip reasons differ from Claude Code's.
|
||||
/* jscpd:ignore-start */
|
||||
if (!Array.isArray(rawGroups)) continue
|
||||
const groups: MatcherGroup[] = []
|
||||
for (const rawGroup of rawGroups) {
|
||||
const group = asObject(rawGroup)
|
||||
if (!group || !Array.isArray(group.hooks)) continue
|
||||
const commands: MatcherGroup['hooks'] = []
|
||||
for (const rawHook of group.hooks) {
|
||||
const hook = asObject(rawHook)
|
||||
if (!hook) continue
|
||||
const type = typeof hook.type === 'string' ? hook.type : 'command'
|
||||
if (type !== 'command') { skipped.push({ event, reason: `unsupported "${type}" hook` }); continue }
|
||||
/* jscpd:ignore-end */
|
||||
if (hook.async === true) { skipped.push({ event, reason: 'async hook' }); continue }
|
||||
if (typeof hook.command !== 'string') continue
|
||||
// Codex accepts `timeout` or the `timeoutSec` alias.
|
||||
const timeout = typeof hook.timeout === 'number' ? hook.timeout
|
||||
: typeof hook.timeoutSec === 'number' ? hook.timeoutSec : undefined
|
||||
commands.push({ command: hook.command, ...timeout !== undefined ? { timeoutSec: timeout } : {} })
|
||||
}
|
||||
if (commands.length === 0) continue
|
||||
const matcher = event === 'UserPromptSubmit' || event === 'Stop'
|
||||
? undefined
|
||||
: typeof group.matcher === 'string' ? group.matcher : undefined
|
||||
groups.push({ ...matcher !== undefined ? { matcher } : {}, hooks: commands })
|
||||
if (!hooksMap) return { config, skipped }
|
||||
|
||||
for (const event of CODEX_EVENTS) {
|
||||
const rawGroups = hooksMap[event]
|
||||
// Matcher-group parsing remains dialect-local because the supported hook
|
||||
// shapes and skip reasons differ from Claude Code's.
|
||||
/* jscpd:ignore-start */
|
||||
if (!Array.isArray(rawGroups)) continue
|
||||
const groups: MatcherGroup[] = []
|
||||
for (const rawGroup of rawGroups) {
|
||||
const group = asObject(rawGroup)
|
||||
if (!group || !Array.isArray(group.hooks)) continue
|
||||
const commands: MatcherGroup['hooks'] = []
|
||||
for (const rawHook of group.hooks) {
|
||||
const hook = asObject(rawHook)
|
||||
if (!hook) continue
|
||||
const type = typeof hook.type === 'string' ? hook.type : 'command'
|
||||
if (type !== 'command') { skipped.push({ event, reason: `unsupported "${type}" hook` }); continue }
|
||||
/* jscpd:ignore-end */
|
||||
if (hook.async === true) { skipped.push({ event, reason: 'async hook' }); continue }
|
||||
if (typeof hook.command !== 'string') continue
|
||||
// Codex accepts `timeout` or the `timeoutSec` alias.
|
||||
const timeout = typeof hook.timeout === 'number' ? hook.timeout
|
||||
: typeof hook.timeoutSec === 'number' ? hook.timeoutSec : undefined
|
||||
commands.push({ command: hook.command, ...timeout !== undefined ? { timeoutSec: timeout } : {} })
|
||||
}
|
||||
if (groups.length > 0) config[event] = groups
|
||||
if (commands.length === 0) continue
|
||||
const matcher = event === 'UserPromptSubmit' || event === 'Stop'
|
||||
? undefined
|
||||
: typeof group.matcher === 'string' ? group.matcher : undefined
|
||||
const diagnostic = matcherDiagnostic(matcher, 'codex')
|
||||
if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`)
|
||||
groups.push({ ...matcher !== undefined ? { matcher } : {}, hooks: commands })
|
||||
}
|
||||
if (groups.length > 0) config[event] = groups
|
||||
}
|
||||
|
||||
const entries = Object.entries(config).flatMap(([event, groups]) => (
|
||||
groups.map(group => ({ event, matcher: group.matcher }))
|
||||
))
|
||||
const matchers = compileMatchers(new Set(entries.map(entry => entry.matcher)), 'codex')
|
||||
for (const { event, matcher } of entries) {
|
||||
const diagnostic = matchers.diagnostic(matcher)
|
||||
if (diagnostic === undefined) continue
|
||||
matchers.dispose()
|
||||
throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`)
|
||||
}
|
||||
|
||||
return { config, skipped, matchers }
|
||||
return { config, skipped }
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* Bridge for unmodified Codex command hooks on harness interception seams. It
|
||||
* supports five points (SessionStart, prompt/tool pre/post, Stop), native
|
||||
* literal-or-Rust-regex matchers, snake_case payloads without a trailing
|
||||
* newline, no hook environment or command substitution, and no pre-tool
|
||||
* approval or rewrite path; only blocking decisions are honored. Shared
|
||||
* execution and parsing live in
|
||||
* supports five points (SessionStart, prompt/tool pre/post, Stop), regex-only
|
||||
* matchers, snake_case payloads without a trailing newline, no hook environment
|
||||
* or command substitution, and no pre-tool approval or rewrite path; only
|
||||
* blocking decisions are honored. Shared execution and parsing live in
|
||||
* `dsh-hook-protocol`; see the
|
||||
* [hook-bridges Agent Note](../../../../.agents/notes/implemented/feature/2026-06-30-hook-bridges.md).
|
||||
* @module @deepseek-ai/dsh-hooks-codex
|
||||
@@ -28,13 +27,14 @@ import {
|
||||
createDetachedRuns,
|
||||
DEFAULT_HOOK_TIMEOUT_MS,
|
||||
DEFAULT_STDERR_SUMMARY_MAX_CHARS,
|
||||
matchesMatcher,
|
||||
mergeHookOutputs,
|
||||
runHook,
|
||||
type HookOutput,
|
||||
type MatcherGroup,
|
||||
type MergedHookOutcome,
|
||||
} from '@deepseek-ai/dsh-hook-protocol'
|
||||
import { parseCodexConfig, type ParsedCodexConfig } from './config.ts'
|
||||
import { parseCodexConfig, type CodexHookConfig } from './config.ts'
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
export const name = 'hooks-codex'
|
||||
@@ -83,37 +83,26 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS
|
||||
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
|
||||
const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS
|
||||
let result: ParsedCodexConfig
|
||||
let parsed: CodexHookConfig = {}
|
||||
try {
|
||||
const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8'))
|
||||
result = parseCodexConfig(raw)
|
||||
const result = parseCodexConfig(raw)
|
||||
parsed = result.config
|
||||
for (const s of result.skipped) {
|
||||
ctx.logger.warn(`hooks-codex: skipping ${s.reason} on ${s.event} (only sync command hooks run)`)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`hooks-codex: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`)
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = result.config
|
||||
const model = config.model ?? ''
|
||||
// Parsing validates through this same registry, so no native regex is rebuilt
|
||||
// between config admission and runtime matching.
|
||||
const matchers = result.matchers
|
||||
|
||||
// SessionStart is the one emit-shaped (detached) point Codex has: track its
|
||||
// run chains so disposal aborts a still-running hook process and drains the
|
||||
// continuation before releasing matchers (docs/defensive-patterns.md:
|
||||
// dispose must reach quiescence).
|
||||
// continuation (docs/defensive-patterns.md: dispose must reach quiescence).
|
||||
const detached = createDetachedRuns()
|
||||
ctx.effect(() => async () => {
|
||||
try {
|
||||
await detached.drain()
|
||||
} finally {
|
||||
matchers.dispose()
|
||||
}
|
||||
}, 'hooks-codex: drain detached hook runs and dispose matchers')
|
||||
|
||||
for (const s of result.skipped) {
|
||||
ctx.logger.warn(`hooks-codex: skipping ${s.reason} on ${s.event} (only sync command hooks run)`)
|
||||
}
|
||||
ctx.effect(() => () => detached.drain(), 'hooks-codex: drain detached hook runs')
|
||||
|
||||
/**
|
||||
* Run and fold one configured Codex hook point.
|
||||
@@ -137,11 +126,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// Run hooks in the agent's session workspace so relative paths address the
|
||||
// user's project rather than the server launch directory.
|
||||
const workdir = opts.agent?.session.header.cwd
|
||||
// Keep each dialect's audit stamping readable beside its payload mapping.
|
||||
/* jscpd:ignore-start */
|
||||
for (const group of groups) {
|
||||
// The protocol library owns Codex's exact-literal/Rust-regex split.
|
||||
if (!matchers.matches(group.matcher, matchQuery)) continue
|
||||
// Codex always interprets matchers as regexes; it has no literal fast path.
|
||||
if (!matchesMatcher(group.matcher, matchQuery, 'codex')) continue
|
||||
for (const hook of group.hooks) {
|
||||
const handlerId = nextHandlerId(point)
|
||||
const session = opts.agent?.session
|
||||
@@ -151,7 +138,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
...group.matcher !== undefined ? { matcher: group.matcher } : {},
|
||||
})
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
const { output, durationMs } = await runHook(ctx.bash, hook, {
|
||||
payload,
|
||||
defaultTimeoutMs,
|
||||
|
||||
@@ -66,11 +66,11 @@ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10):
|
||||
}
|
||||
|
||||
describe('hooks-codex bridge', () => {
|
||||
it('a PreToolUse hook (exit 2) honors a Rust-regex inline flag matcher', async () => {
|
||||
it('a PreToolUse hook (exit 2) denies a tool the regex matcher matches as a substring', async () => {
|
||||
const dir = configDir()
|
||||
const deny = script(dir, 'deny.sh', '#!/usr/bin/env bash\necho "codex blocked it" >&2\nexit 2\n')
|
||||
// `(?i)` is accepted by Rust regex but rejected by JavaScript RegExp.
|
||||
writeHooks(dir, { PreToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: deny }] }] })
|
||||
// Codex regex matcher: "Bash" is /Bash/ — matches the tool name "Bash".
|
||||
writeHooks(dir, { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: deny }] }] })
|
||||
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')])
|
||||
const ctx = await harness(dir, adapter)
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { parseCodexConfig as parseRawCodexConfig, CODEX_EVENTS } from '@deepseek-ai/dsh-hooks-codex/src/config.ts'
|
||||
|
||||
const matcherSets: Array<ReturnType<typeof parseRawCodexConfig>['matchers']> = []
|
||||
afterEach(() => { for (const matchers of matcherSets.splice(0)) matchers.dispose() })
|
||||
|
||||
function parseCodexConfig(...args: Parameters<typeof parseRawCodexConfig>): ReturnType<typeof parseRawCodexConfig> {
|
||||
const result = parseRawCodexConfig(...args)
|
||||
matcherSets.push(result.matchers)
|
||||
return result
|
||||
}
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseCodexConfig, CODEX_EVENTS } from '@deepseek-ai/dsh-hooks-codex/src/config.ts'
|
||||
|
||||
describe('parseCodexConfig', () => {
|
||||
it('honors only the five bridge-supported Codex events, dropping the rest', () => {
|
||||
@@ -70,10 +61,9 @@ describe('parseCodexConfig', () => {
|
||||
expect('matcher' in config.Stop![0]!).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a valid Rust-regex matcher when present', () => {
|
||||
const { config, matchers } = parseCodexConfig({ PreToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'b.sh' }] }] })
|
||||
expect(config.PreToolUse![0]!.matcher).toBe('(?i)^bash$')
|
||||
expect(matchers.matches('(?i)^bash$', 'BASH')).toBe(true)
|
||||
it('keeps a matcher when present', () => {
|
||||
const { config } = parseCodexConfig({ PreToolUse: [{ matcher: '^Bash$', hooks: [{ type: 'command', command: 'b.sh' }] }] })
|
||||
expect(config.PreToolUse![0]!.matcher).toBe('^Bash$')
|
||||
})
|
||||
|
||||
it('rejects an invalid regex matcher with its event name', () => {
|
||||
@@ -82,12 +72,6 @@ describe('parseCodexConfig', () => {
|
||||
})).toThrow('invalid codex regex matcher "[" on event "PreToolUse"')
|
||||
})
|
||||
|
||||
it('rejects JavaScript-only regex syntax that Codex cannot execute', () => {
|
||||
expect(() => parseCodexConfig({
|
||||
PreToolUse: [{ matcher: '(?=Bash)', hooks: [{ type: 'command', command: 's.sh' }] }],
|
||||
})).toThrow('invalid codex regex matcher "(?=Bash)" on event "PreToolUse"')
|
||||
})
|
||||
|
||||
it('discards matcher fields on events without matcher subjects before validation', () => {
|
||||
const { config } = parseCodexConfig({
|
||||
UserPromptSubmit: [{ matcher: '[', hooks: [{ type: 'command', command: 'prompt.sh' }] }],
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
|
||||
const matcherLifecycle = vi.hoisted(() => {
|
||||
const registry = {
|
||||
matches: vi.fn(() => true),
|
||||
diagnostic: vi.fn<(matcher: string | undefined) => string | undefined>(() => undefined),
|
||||
dispose: vi.fn<() => void>(),
|
||||
}
|
||||
return {
|
||||
registry,
|
||||
compileMatchers: vi.fn(() => registry),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@deepseek-ai/dsh-hook-protocol', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@deepseek-ai/dsh-hook-protocol')>()
|
||||
return { ...actual, compileMatchers: matcherLifecycle.compileMatchers }
|
||||
})
|
||||
|
||||
const dirs: string[] = []
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
vi.clearAllMocks()
|
||||
matcherLifecycle.registry.diagnostic.mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
describe('hooks-codex matcher lifecycle', () => {
|
||||
it('disposes the compiled set when one event-specific diagnostic rejects the config', async () => {
|
||||
const { parseCodexConfig } = await import('@deepseek-ai/dsh-hooks-codex/src/config.ts')
|
||||
matcherLifecycle.registry.diagnostic.mockImplementation((matcher: string | undefined) => (
|
||||
matcher === '[' ? 'invalid codex regex matcher "["' : undefined
|
||||
))
|
||||
|
||||
expect(() => parseCodexConfig({
|
||||
PreToolUse: [
|
||||
{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'first' }] },
|
||||
{ matcher: '[', hooks: [{ type: 'command', command: 'second' }] },
|
||||
],
|
||||
})).toThrow('invalid codex regex matcher "[" on event "PreToolUse"')
|
||||
|
||||
expect(matcherLifecycle.compileMatchers).toHaveBeenCalledExactlyOnceWith(
|
||||
new Set(['(?i)^bash$', '[']),
|
||||
'codex',
|
||||
)
|
||||
expect(matcherLifecycle.registry.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('gives the loaded config one matcher registry and disposes it on plugin teardown', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-codex-matchers-'))
|
||||
dirs.push(dir)
|
||||
const configPath = join(dir, 'hooks.json')
|
||||
writeFileSync(configPath, JSON.stringify({ hooks: {
|
||||
PreToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'true' }] }],
|
||||
PostToolUse: [{ matcher: '(?i)^bash$', hooks: [{ type: 'command', command: 'true' }] }],
|
||||
} }))
|
||||
|
||||
const HooksCodex = await import('@deepseek-ai/dsh-hooks-codex')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
const fiber = await ctx.plugin(HooksCodex, { configPath, model: 'm' })
|
||||
|
||||
expect(matcherLifecycle.compileMatchers).toHaveBeenCalledExactlyOnceWith(new Set([
|
||||
'(?i)^bash$',
|
||||
]), 'codex')
|
||||
expect(matcherLifecycle.registry.diagnostic).toHaveBeenCalledTimes(2)
|
||||
expect(matcherLifecycle.registry.dispose).not.toHaveBeenCalled()
|
||||
|
||||
await fiber.dispose()
|
||||
expect(matcherLifecycle.registry.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user