mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-09 04:02:35 +00:00
feat(webhook): create workspace sessions from GitHub events
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
/** Bounded raw HTTP body intake for GitHub signature verification. */
|
||||
|
||||
import type { IncomingMessage } from 'node:http'
|
||||
|
||||
/** HTTP refusal whose message is safe to return without request data. */
|
||||
export class WebhookHttpError extends Error {
|
||||
override readonly name = 'WebhookHttpError'
|
||||
|
||||
constructor(
|
||||
readonly status: 400 | 401 | 405 | 413 | 415 | 503,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a decimal Content-Length or reject an ambiguous header. */
|
||||
function contentLength(request: IncomingMessage): number | undefined {
|
||||
const value = request.headers['content-length']
|
||||
if (value === undefined) return undefined
|
||||
if (!/^(0|[1-9]\d*)$/.test(value)) {
|
||||
throw new WebhookHttpError(400, 'invalid Content-Length')
|
||||
}
|
||||
const length = Number(value)
|
||||
if (!Number.isSafeInteger(length)) throw new WebhookHttpError(413, 'request body is too large')
|
||||
return length
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one request body as exact, bounded UTF-8 text.
|
||||
* @param request - incoming request before any parser consumes it.
|
||||
* @param maxBodyBytes - positive byte ceiling.
|
||||
* @returns the decoded body after EOF.
|
||||
* @throws {WebhookHttpError} for invalid length, excessive bytes, invalid UTF-8, or an aborted stream.
|
||||
*/
|
||||
export async function readBoundedUtf8Body(
|
||||
request: IncomingMessage,
|
||||
maxBodyBytes: number,
|
||||
): Promise<string> {
|
||||
const declared = contentLength(request)
|
||||
if (declared !== undefined && declared > maxBodyBytes) {
|
||||
request.resume()
|
||||
throw new WebhookHttpError(413, 'request body is too large')
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = []
|
||||
let size = 0
|
||||
try {
|
||||
for await (const raw of request) {
|
||||
const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw as string)
|
||||
size += chunk.byteLength
|
||||
if (size > maxBodyBytes) {
|
||||
request.resume()
|
||||
throw new WebhookHttpError(413, 'request body is too large')
|
||||
}
|
||||
chunks.push(chunk)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof WebhookHttpError) throw error
|
||||
throw new WebhookHttpError(400, 'request body was aborted')
|
||||
}
|
||||
if (!request.complete) throw new WebhookHttpError(400, 'request body was aborted')
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks, size))
|
||||
} catch {
|
||||
// TextDecoder is the only statement in the try; GitHub JSON must be valid UTF-8.
|
||||
throw new WebhookHttpError(400, 'request body is not valid UTF-8')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/** GitHub HTTP authentication, parsing, and fire-and-forget dispatch. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { Webhooks } from '@octokit/webhooks'
|
||||
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
WebhookDeliveryId,
|
||||
WebhookSourceId,
|
||||
type VerifiedWebhookDelivery,
|
||||
} from '@deepseek-ai/dsh-webhook'
|
||||
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { readBoundedUtf8Body, WebhookHttpError } from './body.ts'
|
||||
import type { GitHubJsonObject } from './types.ts'
|
||||
|
||||
/** Handler values validated once at plugin load. */
|
||||
export interface GitHubWebhookHandlerConfig {
|
||||
readonly source: string
|
||||
readonly secretEnv: CredentialRef
|
||||
readonly maxBodyBytes: number
|
||||
}
|
||||
|
||||
/** Require one unambiguous non-empty request header. */
|
||||
function requiredHeader(request: IncomingMessage, name: string): string {
|
||||
const values = request.headersDistinct[name]
|
||||
const value = values?.[0]
|
||||
if (values?.length !== 1 || value === undefined || value.trim() === '') {
|
||||
throw new WebhookHttpError(400, `missing ${name} header`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** Whether Content-Type names JSON with at most one UTF-8 charset parameter. */
|
||||
function isJsonContentType(value: string | undefined): boolean {
|
||||
if (value === undefined) return false
|
||||
const parts = value.split(';').map(part => part.trim())
|
||||
const [mediaType, parameter, ...extra] = parts
|
||||
if (mediaType?.toLowerCase() !== 'application/json') return false
|
||||
if (parameter === undefined) return true
|
||||
return extra.length === 0 && /^charset=(?:utf-8|"utf-8")$/i.test(parameter)
|
||||
}
|
||||
|
||||
/** Send one empty or plain-text response exactly once. */
|
||||
function respond(response: ServerResponse, status: number, message?: string): void {
|
||||
if (message === undefined) {
|
||||
response.writeHead(status)
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
response.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' })
|
||||
response.end(message)
|
||||
}
|
||||
|
||||
/** Convert a parsed value into the adapter's generic signed-object guarantee. */
|
||||
function parsePayload(body: string): GitHubJsonObject {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(body)
|
||||
} catch {
|
||||
// JSON.parse is the only statement in the try; no other failure is normalized.
|
||||
throw new WebhookHttpError(400, 'request body is not valid JSON')
|
||||
}
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new WebhookHttpError(400, 'GitHub webhook payload must be a JSON object')
|
||||
}
|
||||
const snapshot = snapshotJsonValue(parsed)
|
||||
if (snapshot === undefined) throw new WebhookHttpError(400, 'GitHub webhook payload is not lossless JSON')
|
||||
return snapshot as GitHubJsonObject
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one exact-route GitHub handler.
|
||||
* @param ctx - adapter context carrying credentials and webhook runtime.
|
||||
* @param config - validated source, credential reference, and body ceiling.
|
||||
* @returns an HTTP handler that answers after in-memory dispatch, never rule settlement.
|
||||
*/
|
||||
export function createGitHubWebhookHandler(
|
||||
ctx: Context,
|
||||
config: GitHubWebhookHandlerConfig,
|
||||
): WebRoute['handler'] {
|
||||
return async (request, response) => {
|
||||
try {
|
||||
if (request.method !== 'POST') {
|
||||
response.setHeader('allow', 'POST')
|
||||
throw new WebhookHttpError(405, 'method not allowed')
|
||||
}
|
||||
if (!isJsonContentType(request.headers['content-type'])) {
|
||||
throw new WebhookHttpError(415, 'content type must be application/json')
|
||||
}
|
||||
const body = await readBoundedUtf8Body(request, config.maxBodyBytes)
|
||||
const signature = requiredHeader(request, 'x-hub-signature-256')
|
||||
const deliveryId = requiredHeader(request, 'x-github-delivery')
|
||||
const eventName = requiredHeader(request, 'x-github-event')
|
||||
const credential = await ctx.credentials.resolve(config.secretEnv)
|
||||
if (credential === undefined || credential.value === '') {
|
||||
throw new WebhookHttpError(503, 'GitHub webhook secret is unavailable')
|
||||
}
|
||||
let verified = false
|
||||
try {
|
||||
verified = await new Webhooks({ secret: credential.value }).verify(body, signature)
|
||||
} catch {
|
||||
// Octokit verification errors carry no response detail safe or useful to the sender.
|
||||
}
|
||||
if (!verified) throw new WebhookHttpError(401, 'invalid webhook signature')
|
||||
const payload = parsePayload(body)
|
||||
const delivery: VerifiedWebhookDelivery<'github'> = {
|
||||
kind: 'github',
|
||||
source: WebhookSourceId(config.source),
|
||||
deliveryId: WebhookDeliveryId(deliveryId),
|
||||
event: { name: eventName, payload },
|
||||
receivedAt: Date.now(),
|
||||
}
|
||||
try {
|
||||
ctx.webhookRuntime.dispatch(delivery)
|
||||
} catch {
|
||||
ctx.logger.warn('webhook-github: dispatch unavailable')
|
||||
throw new WebhookHttpError(503, 'webhook runtime is unavailable')
|
||||
}
|
||||
respond(response, 202)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof WebhookHttpError) {
|
||||
respond(response, error.status, error.message)
|
||||
return
|
||||
}
|
||||
ctx.logger.warn('webhook-github: request failed')
|
||||
respond(response, 503, 'webhook ingress is unavailable')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/** Signed GitHub HTTP adapter for the provider-neutral webhook runtime. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { createGitHubWebhookHandler } from './handler.ts'
|
||||
|
||||
/** Cordis function-plugin name. */
|
||||
export const name = 'webhook-github'
|
||||
/** Host services required before the exact route can register. */
|
||||
export const inject = ['webServer', 'webhookRuntime', 'credentials']
|
||||
|
||||
/** Required GitHub ingress configuration. */
|
||||
export interface Config {
|
||||
/** Adapter instance name carried to rules. */
|
||||
readonly source: string
|
||||
/** Exact absolute route path. */
|
||||
readonly path: string
|
||||
/** Credential reference containing the shared webhook secret. */
|
||||
readonly secretEnv: string
|
||||
/** Positive raw body ceiling in bytes. */
|
||||
readonly maxBodyBytes: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
source: z.string().required(),
|
||||
path: z.string().required(),
|
||||
secretEnv: z.string().role('credential-ref').required(),
|
||||
maxBodyBytes: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).required(),
|
||||
})
|
||||
|
||||
/** Validate route and source facts that Schemastery cannot express. */
|
||||
function assertConfig(config: Config): void {
|
||||
if (config.source.trim() !== config.source || config.source === '') {
|
||||
throw new Error('webhook-github source must be a non-empty trimmed string')
|
||||
}
|
||||
if (!config.path.startsWith('/') || config.path === '/' || config.path.endsWith('/')
|
||||
|| config.path.includes('?') || config.path.includes('#')) {
|
||||
throw new Error('webhook-github path must be an absolute non-root pathname without a trailing slash, query, or fragment')
|
||||
}
|
||||
}
|
||||
|
||||
/** Register one signed GitHub endpoint on the injected WebServer. */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
assertConfig(config)
|
||||
const route = {
|
||||
kind: 'exact' as const,
|
||||
path: config.path,
|
||||
handler: createGitHubWebhookHandler(ctx, {
|
||||
source: config.source,
|
||||
secretEnv: credentialRef(config.secretEnv),
|
||||
maxBodyBytes: config.maxBodyBytes,
|
||||
}),
|
||||
}
|
||||
ctx.effect(
|
||||
() => ctx.webServer.register(route),
|
||||
`webhook-github: ${config.path}`,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Package-owned invariant companion for the GitHub webhook adapter. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-webhook-github'
|
||||
|
||||
/** Cordis invariant-companion plugin name. */
|
||||
export const name = 'webhook-github-invariant'
|
||||
/** Registry required before reserving this package's invariant ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: authentication and input validation occur at the exact
|
||||
* HTTP operation; dsh-host-webserver owns route/disposer symmetry.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's explained empty invariant.
|
||||
* @param ctx - Cordis context carrying the invariant registry.
|
||||
* @returns the invariant registration disposer.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
@@ -0,0 +1,22 @@
|
||||
/** GitHub event values projected after signature verification. */
|
||||
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Signed GitHub JSON object. Event-specific field validation belongs to each rule. */
|
||||
export type GitHubJsonObject = { readonly [key: string]: JsonValue }
|
||||
|
||||
/** Provider event supplied to `WebhookRule<'github'>`. */
|
||||
export interface GitHubWebhookEvent {
|
||||
/** Raw `X-GitHub-Event` name such as `pull_request`. */
|
||||
readonly name: string
|
||||
/** Signed JSON object exactly as parsed from the request body. */
|
||||
readonly payload: GitHubJsonObject
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-webhook' {
|
||||
interface WebhookEventMap {
|
||||
github: GitHubWebhookEvent
|
||||
}
|
||||
}
|
||||
|
||||
export type { EmitterWebhookEvent, EmitterWebhookEventName } from '@octokit/webhooks'
|
||||
Reference in New Issue
Block a user