@aerostack/gateway 0.13.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/hook-server.js +90 -26
  2. package/package.json +1 -1
@@ -10,8 +10,9 @@
10
10
  * Also manages ~/.claude/settings.json hook entries when enabled/disabled.
11
11
  */
12
12
  import { createServer } from 'node:http';
13
- import { readFile, writeFile } from 'node:fs/promises';
14
- import { homedir } from 'node:os';
13
+ import { readFile, writeFile, appendFile, stat } from 'node:fs/promises';
14
+ import { watch } from 'node:fs';
15
+ import { homedir, tmpdir } from 'node:os';
15
16
  import { join } from 'node:path';
16
17
  import { info, warn, debug } from './logger.js';
17
18
  // ─── Config ───────────────────────────────────────────────────────────────
@@ -158,34 +159,93 @@ function handleHookRequest(req, res) {
158
159
  });
159
160
  }
160
161
  // ─── Public API ───────────────────────────────────────────────────────────
162
+ let fileWatcher = null;
163
+ let lastFileSize = 0;
164
+ /** Process new lines appended to the JSONL events file. */
165
+ async function processNewEvents() {
166
+ try {
167
+ const st = await stat(HOOK_EVENTS_FILE).catch(() => null);
168
+ if (!st || st.size <= lastFileSize)
169
+ return;
170
+ const content = await readFile(HOOK_EVENTS_FILE, 'utf-8');
171
+ const lines = content.split('\n').filter(Boolean);
172
+ // Only process lines we haven't seen (based on byte offset)
173
+ const newContent = content.slice(lastFileSize);
174
+ lastFileSize = st.size;
175
+ const newLines = newContent.split('\n').filter(Boolean);
176
+ for (const line of newLines) {
177
+ try {
178
+ const data = JSON.parse(line);
179
+ const toolName = data.tool_name ?? '';
180
+ // Skip read-only tools
181
+ if (READONLY_TOOLS.has(toolName))
182
+ continue;
183
+ if (!MUTATION_TOOLS.has(toolName) && !bridgeConfig.tools.includes(toolName))
184
+ continue;
185
+ const toolInput = data.tool_input ?? {};
186
+ const { category, risk } = detectCategory(toolName, toolInput);
187
+ const summary = summarizeToolInput(toolName, toolInput);
188
+ addToBatch({
189
+ action: `${toolName}: ${summary}`.slice(0, 500),
190
+ category,
191
+ risk_level: risk,
192
+ details: JSON.stringify({ tool: toolName, ...toolInput }).slice(0, 500),
193
+ });
194
+ debug(`File hook event: ${toolName}`, { category });
195
+ }
196
+ catch { /* skip malformed lines */ }
197
+ }
198
+ // Truncate file if it gets too large (>1MB)
199
+ if (st.size > 1_048_576) {
200
+ await writeFile(HOOK_EVENTS_FILE, '', 'utf-8');
201
+ lastFileSize = 0;
202
+ }
203
+ }
204
+ catch { /* file may not exist yet */ }
205
+ }
161
206
  export async function startHookServer(flushFn, port = DEFAULT_PORT) {
162
207
  batchFlushFn = flushFn;
163
- // Find available port (try configured, then increment)
164
- return new Promise((resolve, reject) => {
208
+ // Start HTTP server (still useful for non-Claude clients)
209
+ const actualPort = await new Promise((resolve, reject) => {
165
210
  httpServer = createServer(handleHookRequest);
166
211
  httpServer.on('error', (err) => {
167
212
  if (err.code === 'EADDRINUSE') {
168
- // Try next port
169
213
  info(`Port ${port} in use, trying ${port + 1}`);
170
214
  httpServer.close();
171
215
  startHookServer(flushFn, port + 1).then(resolve).catch(reject);
172
216
  }
173
217
  else {
174
- reject(err);
218
+ // Non-fatal — file-based hooks still work without HTTP server
219
+ warn('HTTP hook server failed, using file-based hooks only', { error: err.message });
220
+ resolve(0);
175
221
  }
176
222
  });
177
223
  httpServer.listen(port, '127.0.0.1', () => {
178
224
  serverPort = port;
179
225
  info(`Hook server listening on http://127.0.0.1:${port}/hook`);
180
- // Start batch flush timer
181
- flushTimer = setInterval(() => {
182
- flushBatch().catch(err => {
183
- warn('Batch flush failed', { error: err instanceof Error ? err.message : String(err) });
184
- });
185
- }, BATCH_INTERVAL_MS);
186
226
  resolve(port);
187
227
  });
188
228
  });
229
+ // Start file watcher for JSONL events (primary hook mechanism — no port conflicts)
230
+ try {
231
+ // Touch file so watcher has something to watch
232
+ await appendFile(HOOK_EVENTS_FILE, '');
233
+ fileWatcher = watch(HOOK_EVENTS_FILE, () => {
234
+ processNewEvents().catch(() => { });
235
+ });
236
+ info('File watcher started', { path: HOOK_EVENTS_FILE });
237
+ }
238
+ catch (err) {
239
+ warn('File watcher failed', { error: err instanceof Error ? err.message : String(err) });
240
+ }
241
+ // Start batch flush timer — also polls JSONL file as safety net for fs.watch misses
242
+ flushTimer = setInterval(() => {
243
+ processNewEvents().catch(() => { });
244
+ flushBatch().catch(err => {
245
+ warn('Batch flush failed', { error: err instanceof Error ? err.message : String(err) });
246
+ });
247
+ }, BATCH_INTERVAL_MS);
248
+ return actualPort;
189
249
  }
190
250
  export function stopHookServer() {
191
251
  if (flushTimer) {
@@ -194,6 +254,10 @@ export function stopHookServer() {
194
254
  }
195
255
  // Final flush
196
256
  flushBatch().catch(() => { });
257
+ if (fileWatcher) {
258
+ fileWatcher.close();
259
+ fileWatcher = null;
260
+ }
197
261
  if (httpServer) {
198
262
  httpServer.close();
199
263
  httpServer = null;
@@ -202,7 +266,9 @@ export function stopHookServer() {
202
266
  }
203
267
  // ─── Claude Code hook management ──────────────────────────────────────────
204
268
  const HOOK_MARKER = '/* aerostack-guardian-hook */';
205
- export async function installClaudeHook(port) {
269
+ /** Path where hook events are written as JSONL (one JSON object per line). */
270
+ export const HOOK_EVENTS_FILE = join(tmpdir(), 'aerostack-guardian-events.jsonl');
271
+ export async function installClaudeHook(_port) {
206
272
  const settingsPath = join(homedir(), '.claude', 'settings.json');
207
273
  try {
208
274
  let settings = {};
@@ -215,28 +281,26 @@ export async function installClaudeHook(port) {
215
281
  }
216
282
  const hooks = (settings.hooks ?? {});
217
283
  const preToolUse = (hooks.PreToolUse ?? []);
218
- // Check if our hook already exists
219
- const existing = preToolUse.findIndex(h => {
284
+ // Remove any old HTTP-based aerostack hooks
285
+ const cleaned = preToolUse.filter(h => {
220
286
  const innerHooks = (h.hooks ?? []);
221
- return innerHooks.some(ih => ih.url?.includes('127.0.0.1') && ih.url?.includes('/hook'));
287
+ return !innerHooks.some(ih => (ih.url?.includes('127.0.0.1') && ih.url?.includes('/hook')) ||
288
+ (ih.command?.includes('aerostack-guardian')));
222
289
  });
290
+ // Command hook: reads stdin JSON, appends to JSONL file
291
+ // No HTTP, no port, no conflicts between sessions
223
292
  const hookEntry = {
224
293
  matcher: 'Bash|Write|Edit',
225
294
  hooks: [{
226
- type: 'http',
227
- url: `http://127.0.0.1:${port}/hook`,
295
+ type: 'command',
296
+ command: `cat >> ${HOOK_EVENTS_FILE}`,
228
297
  }],
229
298
  };
230
- if (existing >= 0) {
231
- preToolUse[existing] = hookEntry;
232
- }
233
- else {
234
- preToolUse.push(hookEntry);
235
- }
236
- hooks.PreToolUse = preToolUse;
299
+ cleaned.push(hookEntry);
300
+ hooks.PreToolUse = cleaned;
237
301
  settings.hooks = hooks;
238
302
  await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
239
- info('Installed Claude Code hook', { port, path: settingsPath });
303
+ info('Installed Claude Code hook (file-based)', { eventsFile: HOOK_EVENTS_FILE, path: settingsPath });
240
304
  return true;
241
305
  }
242
306
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aerostack/gateway",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "stdio-to-HTTP bridge connecting any MCP client to Aerostack Workspaces",
5
5
  "author": "Aerostack",
6
6
  "license": "MIT",