Skip to content

Commit 3da7554

Browse files
authored
Merge pull request #281 from LeXwDeX/fix/http-hook-headers
fix(hook): apply http-hook headers and harden hookAdd validation
2 parents ece0db5 + 4efbff4 commit 3da7554

6 files changed

Lines changed: 217 additions & 17 deletions

File tree

packages/opencode/src/goal/loop.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -474,24 +474,26 @@ const serviceLayer = Layer.effect(
474474
Effect.catchCause((cause) =>
475475
Effect.gen(function* () {
476476
// F1: Only pause for non-interrupt causes. An interrupt (user
477-
// pressed ESC during continuation) is safe to drop because the
478-
// session ALWAYS re-emits idle afterwards, which re-drives this
479-
// loop: SessionRunState.cancel (run-state.ts) and the runner's
480-
// onIdle callback both call status.set(idle), and
481-
// SessionStatus.set (status.ts) publishes the Status+Idle event
482-
// pair unconditionally — even when the session was already idle.
483-
// That fresh idle event forks a new afterIdle fiber whose
484-
// shouldPreempt guard detects the user's newer message and pauses
485-
// there if needed. Pausing HERE would race that replacement
486-
// afterIdle fiber and emit a spurious pause. Real dispatch
487-
// failures (provider fault, session write error) still get the
488-
// recoverable pause below.
477+
// pressed ESC during continuation) is safe to drop because
478+
// SessionPrompt.cancel pauses goal-driven turns SYNCHRONOUSLY via
479+
// goal.pauseForUserCancel (prompt.ts) BEFORE state.cancel lets the
480+
// interrupt propagate — by the time this catchCause observes the
481+
// cause, the goal is already paused, and pausing again HERE would
482+
// double-publish. The session still ALWAYS re-emits idle
483+
// afterwards, which re-drives this loop: SessionRunState.cancel
484+
// (run-state.ts) and the runner's onIdle callback both call
485+
// status.set(idle), and SessionStatus.set (status.ts) publishes
486+
// the Status+Idle event pair unconditionally — even when the
487+
// session was already idle. On that next cycle shouldPreempt is
488+
// only the DB-failure fallback for a pauseForUserCancel that could
489+
// not persist. Real dispatch failures (provider fault, session
490+
// write error) still get the recoverable pause below.
489491
// F1: hasInterrupts is a structural check; Cause.interruptors only
490492
// collects DEFINED fiber ids and silently ignores interrupts
491493
// carrying none (e.g. Cause.interrupt()), which would otherwise be
492494
// misclassified as a dispatch failure and spuriously paused here.
493495
if (Cause.hasInterrupts(cause)) {
494-
yield* Effect.logInfo("goal continuation interrupted (likely user ESC) — not pausing; shouldPreempt handles next cycle")
496+
yield* Effect.logInfo("goal continuation interrupted (likely user ESC) — not pausing; cancel path already paused the goal")
495497
return Option.none()
496498
}
497499
const errMsg = `continuation dispatch failed: ${Cause.pretty(cause)}`

packages/opencode/src/hook/settings.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1432,10 +1432,11 @@ const mcpHandler: HookHandler = {
14321432

14331433
/**
14341434
* `type: "http"` handler. Per CC protocol, `entry.command` is the endpoint URL;
1435-
* the envelope is POSTed as JSON. 2xx → body parsed via the same parseStdout path
1436-
* as command stdout. Non-2xx → synthetic `exitBlock` so the trigger aggregator
1437-
* surfaces it as a block. Network errors / timeouts → log.warn + silent allow,
1438-
* mirroring commandHandler's spawnError behavior (hooks must never crash the host).
1435+
* the envelope is POSTed as JSON with `entry.headers` applied verbatim (auth
1436+
* tokens etc.). 2xx → body parsed via the same parseStdout path as command
1437+
* stdout. Non-2xx → synthetic `exitBlock` so the trigger aggregator surfaces it
1438+
* as a block. Network errors / timeouts → log.warn + silent allow, mirroring
1439+
* commandHandler's spawnError behavior (hooks must never crash the host).
14391440
*
14401441
* Factory takes the resolved HttpClient so the HookHandler.run signature stays
14411442
* `R = never` (the WP-4A interface contract). Captures `http` in closure scope —
@@ -1454,6 +1455,7 @@ const httpHandler: HookHandler = {
14541455

14551456
const url = httpUrl(entry)
14561457
const exit = yield* HttpClientRequest.post(url).pipe(
1458+
HttpClientRequest.setHeaders(entry.headers ?? {}),
14571459
HttpClientRequest.bodyJson(envelope),
14581460
Effect.flatMap((req) => httpRead.execute(req)),
14591461
Effect.flatMap((res) =>

packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,16 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
440440
// Non-empty hooks[] guard → 4xx. event/type membership is already enforced
441441
// by the payload's literal schemas; this completes the validation contract.
442442
if (ctx.payload.hooks.length === 0) return yield* new HttpApiError.BadRequest({})
443+
// Per-entry guards: a command hook must carry a runnable command line
444+
// (blank-only strings would spawn nothing), and timeout is a positive
445+
// seconds multiplier — negative values time the request out immediately,
446+
// and 0 silently falls back to the default instead of meaning "no timeout".
447+
const invalidEntry = ctx.payload.hooks.some(
448+
(hook) =>
449+
(hook.type === "command" && !(hook.command ?? "").trim()) ||
450+
(hook.timeout !== undefined && hook.timeout <= 0),
451+
)
452+
if (invalidEntry) return yield* new HttpApiError.BadRequest({})
443453
const id = yield* sessionHooks.add(ctx.params.sessionID, {
444454
event: ctx.payload.event as HookEvent,
445455
matcher: ctx.payload.matcher,
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { describe, expect } from "bun:test"
2+
import { Effect, Layer } from "effect"
3+
import { FetchHttpClient } from "effect/unstable/http"
4+
import { SettingsHook } from "@/hook/settings"
5+
import { SessionHooks } from "@/hook/session-hooks"
6+
import { EventV2Bridge } from "@/event-v2-bridge"
7+
import { Database } from "@opencode-ai/core/database/database"
8+
import { SessionID } from "@/session/schema"
9+
import { testEffect } from "../lib/effect"
10+
11+
// httpHandler runtime contract against a real local server: configured
12+
// entry.headers MUST reach the wire (auth tokens were silently dropped before
13+
// this fix), and non-2xx responses MUST surface as the synthetic exitBlock so
14+
// the trigger aggregator reports a block.
15+
16+
const testLayer = SettingsHook.layer.pipe(
17+
Layer.provide(EventV2Bridge.defaultLayer),
18+
Layer.provide(Database.defaultLayer),
19+
Layer.provideMerge(SessionHooks.defaultLayer),
20+
Layer.provideMerge(FetchHttpClient.layer),
21+
)
22+
const it = testEffect(testLayer)
23+
24+
const withFetch = <A, E, R>(
25+
fetch: (req: Request) => Response | Promise<Response>,
26+
fn: (url: string) => Effect.Effect<A, E, R>,
27+
) =>
28+
Effect.acquireUseRelease(
29+
Effect.sync(() => Bun.serve({ port: 0, fetch })),
30+
(server) => fn(server.url.toString()),
31+
(server) => Effect.sync(() => server.stop(true)),
32+
)
33+
34+
describe("SettingsHook http handler", () => {
35+
it.instance("applies configured entry.headers to the outbound POST", () =>
36+
Effect.gen(function* () {
37+
const sessionHooks = yield* SessionHooks.Service
38+
const hook = yield* SettingsHook.Service
39+
const sessionID = SessionID.descending()
40+
let seen: Headers | undefined
41+
yield* withFetch(
42+
(req) => {
43+
seen = req.headers
44+
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } })
45+
},
46+
(url) =>
47+
Effect.gen(function* () {
48+
yield* sessionHooks.add(sessionID, {
49+
event: "UserPromptSubmit",
50+
hooks: [
51+
{
52+
type: "http",
53+
url,
54+
headers: { authorization: "Bearer hook-secret", "x-hook-test": "present" },
55+
},
56+
],
57+
})
58+
const r = yield* hook.trigger(
59+
{ event: "UserPromptSubmit", prompt: "hi" },
60+
{ sessionID, transcriptPath: "" },
61+
)
62+
expect(r.blocked).toBeUndefined()
63+
expect(seen).toBeDefined()
64+
expect(seen?.get("authorization")).toBe("Bearer hook-secret")
65+
expect(seen?.get("x-hook-test")).toBe("present")
66+
}),
67+
)
68+
}),
69+
)
70+
71+
it.instance("non-2xx response surfaces as exitBlock", () =>
72+
Effect.gen(function* () {
73+
const sessionHooks = yield* SessionHooks.Service
74+
const hook = yield* SettingsHook.Service
75+
const sessionID = SessionID.descending()
76+
yield* withFetch(
77+
() => new Response("nope", { status: 500 }),
78+
(url) =>
79+
Effect.gen(function* () {
80+
yield* sessionHooks.add(sessionID, {
81+
event: "UserPromptSubmit",
82+
hooks: [{ type: "http", url }],
83+
})
84+
const r = yield* hook.trigger(
85+
{ event: "UserPromptSubmit", prompt: "hi" },
86+
{ sessionID, transcriptPath: "" },
87+
)
88+
expect(r.blocked).toBeDefined()
89+
expect(r.blocked?.reason).toContain("500")
90+
}),
91+
)
92+
}),
93+
)
94+
})

packages/opencode/test/server/httpapi-exercise/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1237,6 +1237,24 @@ const scenarios: Scenario[] = [
12371237
body: { event: "NotAnEvent", hooks: [{ type: "command", command: "printf '%s' 'x'" }] },
12381238
}))
12391239
.status(400),
1240+
http.protected
1241+
.post("/session/{sessionID}/hook", "session.hook.add.empty_command")
1242+
.seeded((ctx) => ctx.session({ title: "Hook empty command session" }))
1243+
.at((ctx) => ({
1244+
path: route("/session/{sessionID}/hook", { sessionID: ctx.state.id }),
1245+
headers: ctx.headers(),
1246+
body: { event: "UserPromptSubmit", hooks: [{ type: "command", command: "" }] },
1247+
}))
1248+
.status(400),
1249+
http.protected
1250+
.post("/session/{sessionID}/hook", "session.hook.add.non_positive_timeout")
1251+
.seeded((ctx) => ctx.session({ title: "Hook bad timeout session" }))
1252+
.at((ctx) => ({
1253+
path: route("/session/{sessionID}/hook", { sessionID: ctx.state.id }),
1254+
headers: ctx.headers(),
1255+
body: { event: "UserPromptSubmit", hooks: [{ type: "command", command: "true", timeout: 0 }] },
1256+
}))
1257+
.status(400),
12401258
http.protected
12411259
.get("/session/{sessionID}/hook", "session.hook.list")
12421260
.seeded((ctx) => ctx.session({ title: "Hook list session" }))
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { afterEach, describe, expect } from "bun:test"
2+
import { Effect, Layer } from "effect"
3+
import { Session } from "@/session/session"
4+
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
5+
import { testEffect } from "../lib/effect"
6+
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
7+
8+
const it = testEffect(Layer.mergeAll(Session.defaultLayer, httpApiLayer))
9+
10+
afterEach(() => disposeAllInstances())
11+
12+
function addHook(directory: string, sessionID: string, hook: Record<string, unknown>) {
13+
return requestInDirectory(`/session/${sessionID}/hook`, directory, {
14+
method: "POST",
15+
headers: { "Content-Type": "application/json" },
16+
body: JSON.stringify({ event: "UserPromptSubmit", hooks: [hook] }),
17+
})
18+
}
19+
20+
describe("session hook add validation", () => {
21+
it.instance(
22+
"rejects command-type hooks with a missing or blank command",
23+
() =>
24+
Effect.gen(function* () {
25+
const test = yield* TestInstance
26+
const session = yield* Session.use.create({})
27+
28+
const missing = yield* addHook(test.directory, session.id, { type: "command" })
29+
expect(missing.status).toBe(400)
30+
31+
const blank = yield* addHook(test.directory, session.id, { type: "command", command: " " })
32+
expect(blank.status).toBe(400)
33+
}),
34+
{ git: true },
35+
)
36+
37+
it.instance(
38+
"rejects non-positive timeout",
39+
() =>
40+
Effect.gen(function* () {
41+
const test = yield* TestInstance
42+
const session = yield* Session.use.create({})
43+
44+
const zero = yield* addHook(test.directory, session.id, { type: "command", command: "true", timeout: 0 })
45+
expect(zero.status).toBe(400)
46+
47+
const negative = yield* addHook(test.directory, session.id, { type: "command", command: "true", timeout: -5 })
48+
expect(negative.status).toBe(400)
49+
}),
50+
{ git: true },
51+
)
52+
53+
it.instance(
54+
"accepts valid command and http hooks",
55+
() =>
56+
Effect.gen(function* () {
57+
const test = yield* TestInstance
58+
const session = yield* Session.use.create({})
59+
60+
const command = yield* addHook(test.directory, session.id, { type: "command", command: "true" })
61+
expect(command.status).toBe(200)
62+
expect(typeof ((yield* command.json) as { id: string }).id).toBe("string")
63+
64+
const http = yield* addHook(test.directory, session.id, {
65+
type: "http",
66+
url: "https://hooks.example.com/endpoint",
67+
timeout: 30,
68+
headers: { authorization: "Bearer token" },
69+
})
70+
expect(http.status).toBe(200)
71+
}),
72+
{ git: true },
73+
)
74+
})

0 commit comments

Comments
 (0)