Files
oh-my-opencode/src/hooks/claude-code-hooks/execute-http-hook.ts
T

115 lines
3.1 KiB
TypeScript
Raw Normal View History

2026-02-28 11:38:34 +09:00
import type { HookHttp } from "./types"
import type { CommandResult } from "../../shared/command-executor/execute-hook-command"
import { log } from "../../shared"
2026-02-28 11:38:34 +09:00
const DEFAULT_HTTP_HOOK_TIMEOUT_S = 30
const ALLOWED_SCHEMES = new Set(["http:", "https:"])
2026-04-03 17:07:17 +09:00
const LOCALHOST_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"])
function isLocalhost(url: URL): boolean {
2026-04-03 17:07:17 +09:00
return LOCALHOST_HOSTNAMES.has(url.hostname)
}
function isPlainHttp(url: URL): boolean {
return url.protocol === "http:"
}
2026-02-28 11:38:34 +09:00
export function interpolateEnvVars(
value: string,
allowedEnvVars: string[]
): string {
const allowedSet = new Set(allowedEnvVars)
2026-02-28 12:00:02 +09:00
return value.replace(/\$\{(\w+)\}|\$(\w+)/g, (_match, bracedVar: string | undefined, bareVar: string | undefined) => {
const varName = (bracedVar ?? bareVar) as string
2026-02-28 11:38:34 +09:00
if (allowedSet.has(varName)) {
return process.env[varName] ?? ""
}
return ""
})
}
2026-02-28 11:38:34 +09:00
function resolveHeaders(
hook: HookHttp
): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
if (!hook.headers) return headers
const allowedEnvVars = hook.allowedEnvVars ?? []
for (const [key, value] of Object.entries(hook.headers)) {
headers[key] = interpolateEnvVars(value, allowedEnvVars)
}
return headers
}
export async function executeHttpHook(
hook: HookHttp,
stdin: string
): Promise<CommandResult> {
let parsed: URL
try {
parsed = new URL(hook.url)
if (!ALLOWED_SCHEMES.has(parsed.protocol)) {
return {
exitCode: 1,
stderr: `HTTP hook URL scheme "${parsed.protocol}" is not allowed. Only http: and https: are permitted.`,
}
}
} catch {
return { exitCode: 1, stderr: `HTTP hook URL is invalid: ${hook.url}` }
}
if (isPlainHttp(parsed)) {
log("HTTP hook URL uses insecure protocol", { url: hook.url })
2026-04-03 17:07:17 +09:00
if (!isLocalhost(parsed)) {
return {
exitCode: 1,
2026-04-03 17:07:17 +09:00
stderr: "HTTP hook URL must use HTTPS. Plain HTTP is only allowed for localhost, 127.0.0.1, and ::1.",
}
}
}
2026-02-28 11:38:34 +09:00
const timeoutS = hook.timeout ?? DEFAULT_HTTP_HOOK_TIMEOUT_S
const headers = resolveHeaders(hook)
try {
const response = await fetch(hook.url, {
method: "POST",
headers,
body: stdin,
2026-04-03 17:07:17 +09:00
redirect: "manual",
2026-02-28 11:38:34 +09:00
signal: AbortSignal.timeout(timeoutS * 1000),
})
if (!response.ok) {
return {
exitCode: 1,
stderr: `HTTP hook returned status ${response.status}: ${response.statusText}`,
stdout: await response.text().catch(() => ""),
}
}
const body = await response.text()
if (!body) {
return { exitCode: 0, stdout: "", stderr: "" }
}
try {
const parsed = JSON.parse(body) as { exitCode?: number }
if (typeof parsed.exitCode === "number") {
return { exitCode: parsed.exitCode, stdout: body, stderr: "" }
}
} catch {
}
return { exitCode: 0, stdout: body, stderr: "" }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { exitCode: 1, stderr: `HTTP hook error: ${message}` }
}
}