mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat: add token-based authentication to session hooks
Implement hook token validation to secure session start requests. Each hook server generates a unique token that must be provided in the x-hapi-hook-token header. Updates hook-forwarder command to support both positional and named arguments for port and token.
This commit is contained in:
@@ -147,7 +147,7 @@ export async function runClaude(options: StartOptions = {}): Promise<void> {
|
||||
});
|
||||
logger.debug(`[START] Hook server started on port ${hookServer.port}`);
|
||||
|
||||
const hookSettingsPath = generateHookSettingsFile(hookServer.port);
|
||||
const hookSettingsPath = generateHookSettingsFile(hookServer.port, hookServer.token);
|
||||
logger.debug(`[START] Generated hook settings file: ${hookSettingsPath}`);
|
||||
|
||||
// Print log file path
|
||||
|
||||
@@ -30,14 +30,14 @@ function shellJoin(parts: string[]): string {
|
||||
/**
|
||||
* Generate a temporary settings file with SessionStart hook configuration.
|
||||
*/
|
||||
export function generateHookSettingsFile(port: number): string {
|
||||
export function generateHookSettingsFile(port: number, token: string): string {
|
||||
const hooksDir = join(configuration.happyHomeDir, 'tmp', 'hooks');
|
||||
mkdirSync(hooksDir, { recursive: true });
|
||||
|
||||
const filename = `session-hook-${process.pid}.json`;
|
||||
const filepath = join(hooksDir, filename);
|
||||
|
||||
const { command, args } = getHappyCliCommand(['hook-forwarder', String(port)]);
|
||||
const { command, args } = getHappyCliCommand(['hook-forwarder', '--port', String(port), '--token', token]);
|
||||
const hookCommand = shellJoin([command, ...args]);
|
||||
|
||||
const settings = {
|
||||
|
||||
@@ -11,22 +11,73 @@ function parsePort(value: string | undefined): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const port = Number.parseInt(value, 10);
|
||||
if (!port || Number.isNaN(port)) {
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): { port: number | null; token: string | null } {
|
||||
let port: number | null = null;
|
||||
let token: string | null = null;
|
||||
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (!arg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--port' || arg === '-p') {
|
||||
port = parsePort(args[i + 1]);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--port=')) {
|
||||
port = parsePort(arg.slice('--port='.length));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === '--token' || arg === '-t') {
|
||||
token = args[i + 1] ?? null;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith('--token=')) {
|
||||
token = arg.slice('--token='.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!port) {
|
||||
port = parsePort(arg);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
token = arg;
|
||||
}
|
||||
}
|
||||
|
||||
return { port, token };
|
||||
}
|
||||
|
||||
export async function runSessionHookForwarder(args: string[]): Promise<void> {
|
||||
const port = parsePort(args[0]);
|
||||
const { port, token } = parseArgs(args);
|
||||
if (!port) {
|
||||
logError('Invalid or missing port argument');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
logError('Missing hook token');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
process.stdin.resume();
|
||||
@@ -49,7 +100,8 @@ export async function runSessionHookForwarder(args: string[]): Promise<void> {
|
||||
path: '/hook/session-start',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': body.length
|
||||
'Content-Length': body.length,
|
||||
'x-hapi-hook-token': token
|
||||
}
|
||||
}, (res) => {
|
||||
if (res.statusCode && res.statusCode >= 400) {
|
||||
|
||||
@@ -2,17 +2,22 @@ import { describe, it, expect } from 'vitest'
|
||||
import { request } from 'node:http'
|
||||
import { startHookServer, type SessionHookData } from './startHookServer'
|
||||
|
||||
const sendHookRequest = async (port: number, body: string): Promise<{ statusCode?: number; body: string }> => {
|
||||
const sendHookRequest = async (port: number, body: string, token?: string): Promise<{ statusCode?: number; body: string }> => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const headers: Record<string, string | number> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body)
|
||||
}
|
||||
if (token) {
|
||||
headers['x-hapi-hook-token'] = token
|
||||
}
|
||||
|
||||
const req = request({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: '/hook/session-start',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body)
|
||||
}
|
||||
headers
|
||||
}, (res) => {
|
||||
const chunks: Buffer[] = []
|
||||
res.on('data', (chunk) => chunks.push(chunk as Buffer))
|
||||
@@ -41,7 +46,7 @@ describe('startHookServer', () => {
|
||||
|
||||
try {
|
||||
const body = JSON.stringify({ session_id: 'session-123', extra: 'ok' })
|
||||
const response = await sendHookRequest(server.port, body)
|
||||
const response = await sendHookRequest(server.port, body, server.token)
|
||||
expect(response.statusCode).toBe(200)
|
||||
} finally {
|
||||
server.stop()
|
||||
@@ -60,7 +65,7 @@ describe('startHookServer', () => {
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await sendHookRequest(server.port, '{"session_id":')
|
||||
const response = await sendHookRequest(server.port, '{"session_id":', server.token)
|
||||
expect(response.statusCode).toBe(400)
|
||||
expect(response.body).toBe('invalid json')
|
||||
} finally {
|
||||
@@ -80,7 +85,7 @@ describe('startHookServer', () => {
|
||||
|
||||
try {
|
||||
const body = JSON.stringify({ extra: 'ok' })
|
||||
const response = await sendHookRequest(server.port, body)
|
||||
const response = await sendHookRequest(server.port, body, server.token)
|
||||
expect(response.statusCode).toBe(422)
|
||||
expect(response.body).toBe('missing session_id')
|
||||
} finally {
|
||||
@@ -89,4 +94,24 @@ describe('startHookServer', () => {
|
||||
|
||||
expect(hookCalled).toBe(false)
|
||||
})
|
||||
|
||||
it('returns 401 when hook token is missing', async () => {
|
||||
let hookCalled = false
|
||||
const server = await startHookServer({
|
||||
onSessionHook: () => {
|
||||
hookCalled = true
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const body = JSON.stringify({ session_id: 'session-123' })
|
||||
const response = await sendHookRequest(server.port, body)
|
||||
expect(response.statusCode).toBe(401)
|
||||
expect(response.body).toBe('unauthorized')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
|
||||
expect(hookCalled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse, type Server } from 'node:http';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { logger } from '@/ui/logger';
|
||||
|
||||
/**
|
||||
@@ -24,24 +25,46 @@ export interface SessionHookData {
|
||||
export interface HookServerOptions {
|
||||
/** Called when a session hook is received with a valid session ID. */
|
||||
onSessionHook: (sessionId: string, data: SessionHookData) => void;
|
||||
/** Optional token to require for hook requests. */
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export interface HookServer {
|
||||
/** The port the server is listening on. */
|
||||
port: number;
|
||||
/** Token required for hook requests. */
|
||||
token: string;
|
||||
/** Stop the server. */
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
function readHookToken(req: IncomingMessage): string | null {
|
||||
const header = req.headers['x-hapi-hook-token'];
|
||||
if (Array.isArray(header)) {
|
||||
return header[0] ?? null;
|
||||
}
|
||||
return header ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a dedicated HTTP server for receiving Claude session hooks.
|
||||
*/
|
||||
export async function startHookServer(options: HookServerOptions): Promise<HookServer> {
|
||||
const { onSessionHook } = options;
|
||||
const hookToken = options.token || randomBytes(16).toString('hex');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const server: Server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
if (req.method === 'POST' && req.url === '/hook/session-start') {
|
||||
const requestPath = req.url?.split('?')[0];
|
||||
if (req.method === 'POST' && requestPath === '/hook/session-start') {
|
||||
const providedToken = readHookToken(req);
|
||||
if (providedToken !== hookToken) {
|
||||
logger.debug('[hookServer] Unauthorized hook request');
|
||||
res.writeHead(401, { 'Content-Type': 'text/plain' }).end('unauthorized');
|
||||
req.resume();
|
||||
return;
|
||||
}
|
||||
|
||||
let timedOut = false;
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
@@ -122,6 +145,7 @@ export async function startHookServer(options: HookServerOptions): Promise<HookS
|
||||
|
||||
resolve({
|
||||
port,
|
||||
token: hookToken,
|
||||
stop: () => {
|
||||
server.close();
|
||||
logger.debug('[hookServer] Stopped');
|
||||
|
||||
Reference in New Issue
Block a user