fix(hooks): run hooks in the session cwd; honest process-level config + best-effort session-start; surface systemMessage drop
Address review on the bridges: - Hook cwd (blocking): the bridges never passed a workdir to runHook, so hooks ran in the executor default (the ACP server launch dir), not the session cwd — a hook doing `pwd`/relative reads/marker writes operated in the wrong tree. Both bridges now thread the agent's session `header.cwd` (the session/new.cwd) as the hook workdir for agent-scoped points. Regression per bridge: server cwd ≠ session cwd, a `pwd` hook proves it ran in the session workspace (proven red without the workdir). - Example config honesty (blocking): `configPath: ./hooks.json` is read ONCE at load against the PROCESS cwd, not per-session — the comment/README now say so explicitly (a project-local per-session hooks.json is not discovered; TODO(per-session-hook-config)). The hooks-run-in-session-cwd fix above is the distinct, separately-documented half. - Session-start timing (blocking): agent/session-start is a synchronous emit and the hook runs on a detached .then, so injected context is BEST-EFFORT — not guaranteed before the first request. Downgrade the contract in code comments + README + RFC (TODO(session-start-gating)) rather than implying "first request sees it", and add a no-wait regression that asserts the safe properties without pre-waiting for the inject. - systemMessage (non-blocking): the merge collects merged.systemMessages but no bridge surfaced it. Warn per hook (like updatedInput) and document it as deferred in both READMEs + the RFC; tests assert the warn + non-surfacing.
This commit is contained in:
@@ -31,7 +31,9 @@ In a `cordis.yml`:
|
||||
model: deepseek-v4
|
||||
```
|
||||
|
||||
The config is parsed **once** at load; a read/parse failure is contained (logs + registers nothing). 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. Events outside the five Codex 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). 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. Events outside the five Codex 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.
|
||||
|
||||
## Hook points → seam Decisions
|
||||
|
||||
@@ -52,3 +54,5 @@ Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-codex' }`
|
||||
## Deferred
|
||||
|
||||
**Stop loop-guard** (`TODO(stop-loop-guard)`): as in CC, a Stop hook that unconditionally blocks would force-continue every step (`stop_hook_active` is always `false` here); the loop-guard is deferred. A hook author must self-limit until it lands.
|
||||
|
||||
**`systemMessage`**: a hook's user-facing warning is logged + warned, not surfaced — there is no user-message channel on these seams yet (only model-facing `additionalContext`).
|
||||
|
||||
@@ -38,7 +38,12 @@ export const inject = ['bash']
|
||||
|
||||
/** Plugin config: where the Codex hooks.json lives + the model name for payloads. */
|
||||
export interface Config {
|
||||
/** Path to a Codex `hooks.json`. */
|
||||
/**
|
||||
* Path to a Codex `hooks.json`. PROCESS-LEVEL: read once at load, a relative
|
||||
* path resolves against the process launch cwd.
|
||||
* TODO(per-session-hook-config): per-session project-local discovery from each
|
||||
* `session/new.cwd` is not yet implemented.
|
||||
*/
|
||||
configPath: string
|
||||
/** The model name stamped on every payload (Codex includes `model` on each event). */
|
||||
model?: string
|
||||
@@ -90,6 +95,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
): Promise<MergedHookOutcome> {
|
||||
const groups: MatcherGroup[] = parsed[point] ?? []
|
||||
const outputs: HookOutput[] = []
|
||||
// Run the hook in the agent's session workspace (the `session/new` cwd), not
|
||||
// the executor default (the server launch dir) — a hook reading a relative
|
||||
// file or `pwd` must see the user's project tree. Absent for a no-agent run.
|
||||
const workdir = opts.agent?.session.header.cwd
|
||||
for (const group of groups) {
|
||||
// Codex matches with PURE regex (no literal fast path).
|
||||
if (!matchesMatcher(group.matcher, matchQuery, 'codex')) continue
|
||||
@@ -104,6 +113,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
const { output, durationMs } = await runHook(ctx.bash, hook, {
|
||||
payload,
|
||||
...workdir !== undefined ? { cwd: workdir } : {},
|
||||
...opts.signal ? { signal: opts.signal } : {},
|
||||
defaultTimeoutMs,
|
||||
trailingNewline: false, // Codex writes stdin WITHOUT a trailing newline.
|
||||
@@ -126,6 +136,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
output.additionalContext = output.stdout
|
||||
}
|
||||
outputs.push(output)
|
||||
if (output.systemMessage !== undefined) {
|
||||
ctx.logger.warn(`hooks-codex: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
|
||||
}
|
||||
if (session && opts.turn !== undefined) {
|
||||
const stderrSummary = summarize(output.stderr)
|
||||
appendHookResult(session, {
|
||||
@@ -154,6 +167,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
|
||||
// SessionStart: emit. Codex passes a plain-stdout hook's output as additionalContext.
|
||||
// TODO(session-start-gating): a synchronous emit + detached `.then`, so the
|
||||
// injected context is BEST-EFFORT — not guaranteed before the first turn reaches
|
||||
// the model (a slow hook can miss the first request). Gating is a deferred
|
||||
// loop-level change; the contract is "injected as soon as the hook resolves".
|
||||
ctx.on('agent/session-start', (agent, source) => {
|
||||
void runPoint('SessionStart', source, { ...base(agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true })
|
||||
.then((merged) => {
|
||||
|
||||
@@ -436,4 +436,41 @@ describe('hooks-codex coverage — decision mapping paths', () => {
|
||||
expect(ran).toBe(false) // the matcher fired → the hook denied the tool
|
||||
expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'PreToolUse')).toBe(true)
|
||||
})
|
||||
|
||||
it('a hook emitting a systemMessage is warned as not-yet-surfaced', async () => {
|
||||
const d = dir()
|
||||
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'sm.sh', '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n') }] }] })
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(join(d, 'hooks.json'), adapter)
|
||||
const warn = vi.fn(); ctx.logger.warn = warn as never
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent)
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage'))
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up')
|
||||
})
|
||||
|
||||
it('runs an agent-scoped hook in the session cwd, not the executor default', async () => {
|
||||
// Same regression as the CC bridge: the Codex bridge must thread the session
|
||||
// cwd as the hook workdir. Executor default = serverDir; session cwd =
|
||||
// sessionDir; the PreToolUse hook's `pwd` marker must land in sessionDir.
|
||||
const serverDir = dir()
|
||||
const sessionDir = dir()
|
||||
const marker = join(sessionDir, 'where')
|
||||
hooks(serverDir, { PreToolUse: [{ hooks: [{ type: 'command', command: 'pwd > where' }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
|
||||
await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } }))
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } })
|
||||
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
|
||||
expect(existsSync(marker)).toBe(true)
|
||||
expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true)
|
||||
await handle.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user