@aerostack/gateway 0.13.2 → 0.15.0
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.
- package/dist/approval-store.js +1 -0
- package/dist/exec-approval-server.js +3 -0
- package/dist/hook-server.js +5 -334
- package/dist/index.js +18 -5
- package/dist/openclaw-connector.js +1 -0
- package/dist/resolution.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const o=500,c=72e5,l=6e4;class a{store=new Map;cancelHandles=new Map;cleanupTimer=null;constructor(){this.cleanupTimer=setInterval(()=>this.cleanup(),6e4)}set(e,t){if(!this.store.has(e)&&this.store.size>=500){const s=this.store.keys().next().value;s&&this.evict(s)}this.store.set(e,{...t,delivered:t.delivered??!1})}get(e){return this.store.get(e)}getResolved(){const e=[];for(const t of this.store.values())!t.delivered&&t.status!=="pending"&&e.push(t);return e}markDelivered(e){const t=this.store.get(e);t&&(t.delivered=!0)}getPending(){const e=[];for(const t of this.store.values())t.status==="pending"&&e.push(t);return e}setCancelHandle(e,t){this.cancelHandles.set(e,t)}resolve(e,t,s,r){const n=this.store.get(e);n&&(n.status=t,n.resolvedAt=Date.now(),n.reviewerNote=s,r!==void 0&&(n.result=r))}get size(){return this.store.size}allIds(){return Array.from(this.store.keys())}getUndeliveredIds(){const e=[];for(const[t,s]of this.store)!s.delivered&&s.status!=="pending"&&e.push(t);return e}cleanup(){const e=Date.now();for(const[t,s]of this.store)e-s.createdAt>72e5&&this.evict(t)}evict(e){const t=this.cancelHandles.get(e);if(t){try{t()}catch{}this.cancelHandles.delete(e)}this.store.delete(e)}destroy(){this.cleanupTimer&&(clearInterval(this.cleanupTimer),this.cleanupTimer=null);for(const e of this.cancelHandles.values())try{e()}catch{}this.cancelHandles.clear(),this.store.clear()}}export{a as ApprovalStore};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import{createServer as f}from"node:net";import{unlink as u,mkdir as g}from"node:fs/promises";import{dirname as h}from"node:path";import{info as p,warn as m,debug as b,error as d}from"./logger.js";import{addToBatch as y}from"./hook-server.js";function v(e){const t=e.toLowerCase();return/\brm\s+-rf?\b|\bdrop\s|\bdelete\s|\btruncate\s|\bformat\b/.test(t)?"critical":/\brm\b|\bgit\s+push\b|\bgit\s+reset\b|\bdeploy\b|\bkill\b/.test(t)?"high":/\binstall\b|\bpip\b|\bnpm\b|\bcurl\b|\bwget\b/.test(t)?"medium":"low"}function E(e){const t=e.toLowerCase();return/\brm\b|\bunlink\b|\brmdir\b/.test(t)?"file_delete":/\bgit\s+push\b|\bdeploy\b|\bwrangler\b/.test(t)?"deploy":/\binstall\b|\bpip\b|\bnpm\b|\byarn\b/.test(t)?"package_install":/\bcurl\b|\bwget\b|\bfetch\b/.test(t)?"api_call":/\bwrite\b|\btee\b|\b>\b|\bcat\s.*>/.test(t)?"file_write":"exec_command"}function D(e){let t=null,a=!1;return(async()=>{await g(h(e.socketPath),{recursive:!0}).catch(()=>{}),await u(e.socketPath).catch(()=>{}),t=f(r=>{k(r,e)}),t.on("error",r=>{r.code==="EADDRINUSE"?m("Exec approval socket in use, skipping",{path:e.socketPath}):d("Exec approval server error",{error:r.message})}),t.listen(e.socketPath,()=>{p("Exec approval server started",{path:e.socketPath})})})().catch(r=>{m("Exec approval server failed to start",{error:r instanceof Error?r.message:String(r)})}),{stop:()=>{a||(a=!0,t&&(t.close(),t=null),u(e.socketPath).catch(()=>{}))}}}function k(e,t){let a="";const n=setTimeout(()=>{b("Exec approval timeout, denying"),o(e,"deny")},14e3);e.on("data",r=>{a+=r.toString("utf-8");const i=a.indexOf(`
|
|
2
|
+
`);if(i===-1)return;const c=a.slice(0,i).trim();a=a.slice(i+1),clearTimeout(n);try{const s=JSON.parse(c);if(s.token!==t.token){b("Exec approval token mismatch"),o(e,"deny");return}w(s,e,t).catch(l=>{d("Exec approval processing error",{error:l instanceof Error?l.message:String(l)}),o(e,"deny")})}catch{o(e,"deny")}}),e.on("error",()=>{clearTimeout(n)})}async function w(e,t,a){const n=e.request.command??e.request.args?.join(" ")??"unknown",r=v(n),i=E(n),c=e.request.sessionKey??"",s=e.request.agentId??"";y({action:`[exec-approval] ${n}`.slice(0,500),category:i,risk_level:r,details:JSON.stringify({command:n,host:e.request.host,agent:s,session:c,security:e.request.security}).slice(0,500)}),a.rpcCall("tools/call",{name:"aerostack__guardian_report",arguments:{action:`OpenClaw exec: ${n}`.slice(0,500),category:i,risk_level:r,details:JSON.stringify({command:n,agent:s,session:c}).slice(0,500)}}).catch(()=>{}),r==="critical"||r==="high"?(p("Exec approval DENIED (high risk)",{command:n.slice(0,100),risk:r}),a.rpcCall("tools/call",{name:"aerostack__local_guardian",arguments:{action:n.slice(0,500),category:i,risk_level:r,details:`OpenClaw agent ${s} wants to execute: ${n}`}}).catch(()=>{}),o(t,"deny")):(b("Exec approval ALLOWED",{command:n.slice(0,100),risk:r}),o(t,"allow-once"))}function o(e,t){try{e.write(JSON.stringify({type:"decision",decision:t})+`
|
|
3
|
+
`),e.end()}catch{}}export{D as startExecApprovalServer};
|
package/dist/hook-server.js
CHANGED
|
@@ -1,334 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
* Claude Code → PreToolUse hook → POST http://localhost:{port}/hook
|
|
7
|
-
* → batched in memory (30s window)
|
|
8
|
-
* → flush: single POST to gateway with guardian_report for each event
|
|
9
|
-
*
|
|
10
|
-
* Also manages ~/.claude/settings.json hook entries when enabled/disabled.
|
|
11
|
-
*/
|
|
12
|
-
import { createServer } from 'node:http';
|
|
13
|
-
import { readFile, writeFile, appendFile, stat } from 'node:fs/promises';
|
|
14
|
-
import { watch } from 'node:fs';
|
|
15
|
-
import { homedir } from 'node:os';
|
|
16
|
-
import { join } from 'node:path';
|
|
17
|
-
import { info, warn, debug } from './logger.js';
|
|
18
|
-
// ─── Config ───────────────────────────────────────────────────────────────
|
|
19
|
-
const DEFAULT_PORT = 18321;
|
|
20
|
-
const BATCH_INTERVAL_MS = 30_000; // 30 seconds
|
|
21
|
-
const MAX_BATCH_SIZE = 200;
|
|
22
|
-
// Tools that are mutations (worth tracking)
|
|
23
|
-
const MUTATION_TOOLS = new Set([
|
|
24
|
-
'Bash', 'Write', 'Edit', 'NotebookEdit',
|
|
25
|
-
// MCP tools caught by bridge auto-report, but hook might see them too
|
|
26
|
-
]);
|
|
27
|
-
// Tools that are read-only (skip by default)
|
|
28
|
-
const READONLY_TOOLS = new Set([
|
|
29
|
-
'Read', 'Glob', 'Grep', 'LS', 'WebSearch', 'WebFetch',
|
|
30
|
-
'Agent', 'AskUserQuestion', 'TodoRead', 'TaskList', 'TaskGet',
|
|
31
|
-
]);
|
|
32
|
-
// ─── Category detection ───────────────────────────────────────────────────
|
|
33
|
-
function detectCategory(toolName, toolInput) {
|
|
34
|
-
if (toolName === 'Bash') {
|
|
35
|
-
const cmd = toolInput.command || '';
|
|
36
|
-
const lower = cmd.toLowerCase();
|
|
37
|
-
if (lower.includes('rm ') || lower.includes('rm\t') || lower.includes('rmdir') || lower.includes('unlink'))
|
|
38
|
-
return { category: 'file_delete', risk: 'high' };
|
|
39
|
-
if (lower.includes('git push') || lower.includes('git reset'))
|
|
40
|
-
return { category: 'deploy', risk: 'high' };
|
|
41
|
-
if (lower.includes('npm install') || lower.includes('pip install') || lower.includes('yarn add'))
|
|
42
|
-
return { category: 'package_install', risk: 'medium' };
|
|
43
|
-
if (lower.includes('curl ') || lower.includes('wget ') || lower.includes('fetch'))
|
|
44
|
-
return { category: 'api_call', risk: 'low' };
|
|
45
|
-
if (lower.includes('deploy') || lower.includes('wrangler'))
|
|
46
|
-
return { category: 'deploy', risk: 'high' };
|
|
47
|
-
return { category: 'exec_command', risk: 'low' };
|
|
48
|
-
}
|
|
49
|
-
if (toolName === 'Write')
|
|
50
|
-
return { category: 'file_write', risk: 'medium' };
|
|
51
|
-
if (toolName === 'Edit' || toolName === 'NotebookEdit')
|
|
52
|
-
return { category: 'file_write', risk: 'low' };
|
|
53
|
-
return { category: 'other', risk: 'low' };
|
|
54
|
-
}
|
|
55
|
-
function summarizeToolInput(toolName, toolInput) {
|
|
56
|
-
if (toolName === 'Bash')
|
|
57
|
-
return toolInput.command?.slice(0, 200) || '';
|
|
58
|
-
if (toolName === 'Write')
|
|
59
|
-
return toolInput.file_path || '';
|
|
60
|
-
if (toolName === 'Edit')
|
|
61
|
-
return toolInput.file_path || '';
|
|
62
|
-
// Generic
|
|
63
|
-
const keys = Object.keys(toolInput);
|
|
64
|
-
if (keys.length === 0)
|
|
65
|
-
return '';
|
|
66
|
-
const first = toolInput[keys[0]];
|
|
67
|
-
return typeof first === 'string' ? first.slice(0, 200) : JSON.stringify(toolInput).slice(0, 200);
|
|
68
|
-
}
|
|
69
|
-
// ─── Batch buffer ─────────────────────────────────────────────────────────
|
|
70
|
-
let eventBatch = [];
|
|
71
|
-
let flushTimer = null;
|
|
72
|
-
let batchFlushFn = null;
|
|
73
|
-
// Bridge config — updated from gateway response on each batch flush
|
|
74
|
-
let bridgeConfig = {
|
|
75
|
-
enabled: true,
|
|
76
|
-
tools: ['Bash', 'Write', 'Edit'],
|
|
77
|
-
batch_interval_seconds: 30,
|
|
78
|
-
categories: ['exec_command', 'file_write', 'file_delete', 'deploy', 'config_change'],
|
|
79
|
-
};
|
|
80
|
-
let currentBatchInterval = BATCH_INTERVAL_MS;
|
|
81
|
-
export function getBridgeConfig() { return bridgeConfig; }
|
|
82
|
-
function addToBatch(event) {
|
|
83
|
-
if (!bridgeConfig.enabled)
|
|
84
|
-
return; // tracking disabled from dashboard
|
|
85
|
-
if (eventBatch.length >= MAX_BATCH_SIZE) {
|
|
86
|
-
eventBatch.shift();
|
|
87
|
-
}
|
|
88
|
-
eventBatch.push(event);
|
|
89
|
-
}
|
|
90
|
-
async function flushBatch() {
|
|
91
|
-
if (eventBatch.length === 0 || !batchFlushFn)
|
|
92
|
-
return;
|
|
93
|
-
const batch = eventBatch.splice(0);
|
|
94
|
-
debug(`Flushing ${batch.length} hook events`);
|
|
95
|
-
const newConfig = await batchFlushFn(batch);
|
|
96
|
-
// Update bridge config from gateway response (live config sync)
|
|
97
|
-
if (newConfig) {
|
|
98
|
-
const changed = JSON.stringify(bridgeConfig) !== JSON.stringify(newConfig);
|
|
99
|
-
bridgeConfig = newConfig;
|
|
100
|
-
if (changed) {
|
|
101
|
-
info('Bridge config updated from gateway', { enabled: newConfig.enabled, tools: newConfig.tools });
|
|
102
|
-
// Update batch interval if changed
|
|
103
|
-
if (newConfig.batch_interval_seconds * 1000 !== currentBatchInterval) {
|
|
104
|
-
currentBatchInterval = newConfig.batch_interval_seconds * 1000;
|
|
105
|
-
if (flushTimer) {
|
|
106
|
-
clearInterval(flushTimer);
|
|
107
|
-
flushTimer = setInterval(() => { flushBatch().catch(() => { }); }, currentBatchInterval);
|
|
108
|
-
info('Batch interval updated', { seconds: newConfig.batch_interval_seconds });
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
info(`Flushed ${batch.length} hook events to gateway`);
|
|
114
|
-
}
|
|
115
|
-
// ─── HTTP Server ──────────────────────────────────────────────────────────
|
|
116
|
-
let httpServer = null;
|
|
117
|
-
let serverPort = null;
|
|
118
|
-
function handleHookRequest(req, res) {
|
|
119
|
-
if (req.method !== 'POST' || !req.url?.startsWith('/hook')) {
|
|
120
|
-
res.writeHead(404);
|
|
121
|
-
res.end();
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
const chunks = [];
|
|
125
|
-
req.on('data', (chunk) => chunks.push(chunk));
|
|
126
|
-
req.on('end', () => {
|
|
127
|
-
try {
|
|
128
|
-
const body = JSON.parse(Buffer.concat(chunks).toString());
|
|
129
|
-
const toolName = body.tool_name ?? '';
|
|
130
|
-
// Skip tools not in the configured tracking list
|
|
131
|
-
if (!bridgeConfig.tools.includes(toolName) && !MUTATION_TOOLS.has(toolName)) {
|
|
132
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
133
|
-
res.end('{"status":"skipped"}');
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
// Skip read-only tools unless explicitly configured
|
|
137
|
-
if (READONLY_TOOLS.has(toolName) && !bridgeConfig.tools.includes(toolName)) {
|
|
138
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
139
|
-
res.end('{"status":"skipped"}');
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
const toolInput = body.tool_input ?? {};
|
|
143
|
-
const { category, risk } = detectCategory(toolName, toolInput);
|
|
144
|
-
const summary = summarizeToolInput(toolName, toolInput);
|
|
145
|
-
addToBatch({
|
|
146
|
-
action: `${toolName}: ${summary}`.slice(0, 500),
|
|
147
|
-
category,
|
|
148
|
-
risk_level: risk,
|
|
149
|
-
details: JSON.stringify({ tool: toolName, ...toolInput }).slice(0, 500),
|
|
150
|
-
});
|
|
151
|
-
debug(`Hook received: ${toolName}`, { category, risk });
|
|
152
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
153
|
-
res.end('{"status":"queued"}');
|
|
154
|
-
}
|
|
155
|
-
catch {
|
|
156
|
-
res.writeHead(400);
|
|
157
|
-
res.end('{"error":"invalid JSON"}');
|
|
158
|
-
}
|
|
159
|
-
});
|
|
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
|
-
}
|
|
206
|
-
export async function startHookServer(flushFn, port = DEFAULT_PORT) {
|
|
207
|
-
batchFlushFn = flushFn;
|
|
208
|
-
// Start HTTP server (still useful for non-Claude clients)
|
|
209
|
-
const actualPort = await new Promise((resolve, reject) => {
|
|
210
|
-
httpServer = createServer(handleHookRequest);
|
|
211
|
-
httpServer.on('error', (err) => {
|
|
212
|
-
if (err.code === 'EADDRINUSE') {
|
|
213
|
-
info(`Port ${port} in use, trying ${port + 1}`);
|
|
214
|
-
httpServer.close();
|
|
215
|
-
startHookServer(flushFn, port + 1).then(resolve).catch(reject);
|
|
216
|
-
}
|
|
217
|
-
else {
|
|
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);
|
|
221
|
-
}
|
|
222
|
-
});
|
|
223
|
-
httpServer.listen(port, '127.0.0.1', () => {
|
|
224
|
-
serverPort = port;
|
|
225
|
-
info(`Hook server listening on http://127.0.0.1:${port}/hook`);
|
|
226
|
-
resolve(port);
|
|
227
|
-
});
|
|
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;
|
|
249
|
-
}
|
|
250
|
-
export function stopHookServer() {
|
|
251
|
-
if (flushTimer) {
|
|
252
|
-
clearInterval(flushTimer);
|
|
253
|
-
flushTimer = null;
|
|
254
|
-
}
|
|
255
|
-
// Final flush
|
|
256
|
-
flushBatch().catch(() => { });
|
|
257
|
-
if (fileWatcher) {
|
|
258
|
-
fileWatcher.close();
|
|
259
|
-
fileWatcher = null;
|
|
260
|
-
}
|
|
261
|
-
if (httpServer) {
|
|
262
|
-
httpServer.close();
|
|
263
|
-
httpServer = null;
|
|
264
|
-
}
|
|
265
|
-
serverPort = null;
|
|
266
|
-
}
|
|
267
|
-
// ─── Claude Code hook management ──────────────────────────────────────────
|
|
268
|
-
const HOOK_MARKER = '/* aerostack-guardian-hook */';
|
|
269
|
-
/** Path where hook events are written as JSONL (one JSON object per line).
|
|
270
|
-
* Use /tmp/ explicitly — macOS tmpdir() returns /var/folders/... which differs between processes. */
|
|
271
|
-
export const HOOK_EVENTS_FILE = '/tmp/aerostack-guardian-events.jsonl';
|
|
272
|
-
export async function installClaudeHook(_port) {
|
|
273
|
-
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
|
274
|
-
try {
|
|
275
|
-
let settings = {};
|
|
276
|
-
try {
|
|
277
|
-
const raw = await readFile(settingsPath, 'utf-8');
|
|
278
|
-
settings = JSON.parse(raw);
|
|
279
|
-
}
|
|
280
|
-
catch {
|
|
281
|
-
// File doesn't exist or invalid — create fresh
|
|
282
|
-
}
|
|
283
|
-
const hooks = (settings.hooks ?? {});
|
|
284
|
-
const preToolUse = (hooks.PreToolUse ?? []);
|
|
285
|
-
// Remove any old HTTP-based aerostack hooks
|
|
286
|
-
const cleaned = preToolUse.filter(h => {
|
|
287
|
-
const innerHooks = (h.hooks ?? []);
|
|
288
|
-
return !innerHooks.some(ih => (ih.url?.includes('127.0.0.1') && ih.url?.includes('/hook')) ||
|
|
289
|
-
(ih.command?.includes('aerostack-guardian')));
|
|
290
|
-
});
|
|
291
|
-
// Command hook: reads stdin JSON, appends to JSONL file
|
|
292
|
-
// No HTTP, no port, no conflicts between sessions
|
|
293
|
-
const hookEntry = {
|
|
294
|
-
matcher: 'Bash|Write|Edit',
|
|
295
|
-
hooks: [{
|
|
296
|
-
type: 'command',
|
|
297
|
-
command: `cat >> ${HOOK_EVENTS_FILE}`,
|
|
298
|
-
}],
|
|
299
|
-
};
|
|
300
|
-
cleaned.push(hookEntry);
|
|
301
|
-
hooks.PreToolUse = cleaned;
|
|
302
|
-
settings.hooks = hooks;
|
|
303
|
-
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
304
|
-
info('Installed Claude Code hook (file-based)', { eventsFile: HOOK_EVENTS_FILE, path: settingsPath });
|
|
305
|
-
return true;
|
|
306
|
-
}
|
|
307
|
-
catch (err) {
|
|
308
|
-
warn('Failed to install Claude Code hook', { error: err instanceof Error ? err.message : String(err) });
|
|
309
|
-
return false;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
export async function uninstallClaudeHook() {
|
|
313
|
-
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
|
314
|
-
try {
|
|
315
|
-
const raw = await readFile(settingsPath, 'utf-8');
|
|
316
|
-
const settings = JSON.parse(raw);
|
|
317
|
-
const hooks = (settings.hooks ?? {});
|
|
318
|
-
const preToolUse = (hooks.PreToolUse ?? []);
|
|
319
|
-
const filtered = preToolUse.filter(h => {
|
|
320
|
-
const innerHooks = (h.hooks ?? []);
|
|
321
|
-
return !innerHooks.some(ih => ih.url?.includes('127.0.0.1') && ih.url?.includes('/hook'));
|
|
322
|
-
});
|
|
323
|
-
if (filtered.length === preToolUse.length)
|
|
324
|
-
return false; // nothing to remove
|
|
325
|
-
hooks.PreToolUse = filtered;
|
|
326
|
-
settings.hooks = hooks;
|
|
327
|
-
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
328
|
-
info('Uninstalled Claude Code hook');
|
|
329
|
-
return true;
|
|
330
|
-
}
|
|
331
|
-
catch {
|
|
332
|
-
return false;
|
|
333
|
-
}
|
|
334
|
-
}
|
|
1
|
+
import{createServer as A}from"node:http";import{readFile as w,writeFile as _,appendFile as L,stat as I}from"node:fs/promises";import{watch as R}from"node:fs";import{homedir as E}from"node:os";import{join as b}from"node:path";import{info as l,warn as p,debug as S}from"./logger.js";const W=18321,H=3e4,j=200,C=new Set(["Bash","Write","Edit","NotebookEdit"]),B=new Set(["Read","Glob","Grep","LS","WebSearch","WebFetch","Agent","AskUserQuestion","TodoRead","TaskList","TaskGet"]);function N(n,e){if(n==="Bash"){const t=(e.command||"").toLowerCase();return t.includes("rm ")||t.includes("rm ")||t.includes("rmdir")||t.includes("unlink")?{category:"file_delete",risk:"high"}:t.includes("git push")||t.includes("git reset")?{category:"deploy",risk:"high"}:t.includes("npm install")||t.includes("pip install")||t.includes("yarn add")?{category:"package_install",risk:"medium"}:t.includes("curl ")||t.includes("wget ")||t.includes("fetch")?{category:"api_call",risk:"low"}:t.includes("deploy")||t.includes("wrangler")?{category:"deploy",risk:"high"}:{category:"exec_command",risk:"low"}}return n==="Write"?{category:"file_write",risk:"medium"}:n==="Edit"||n==="NotebookEdit"?{category:"file_write",risk:"low"}:{category:"other",risk:"low"}}function F(n,e){if(n==="Bash")return e.command?.slice(0,200)||"";if(n==="Write"||n==="Edit")return e.file_path||"";const o=Object.keys(e);if(o.length===0)return"";const t=e[o[0]];return typeof t=="string"?t.slice(0,200):JSON.stringify(e).slice(0,200)}let k=[],d=null,T=null,h={enabled:!0,tools:["Bash","Write","Edit"],batch_interval_seconds:30,categories:["exec_command","file_write","file_delete","deploy","config_change"]},v=H;function Y(){return h}function P(n){h.enabled&&(k.length>=j&&k.shift(),k.push(n))}async function O(){if(k.length===0||!T)return;const n=k.splice(0);S(`Flushing ${n.length} hook events`);const e=await T(n);if(e){const o=JSON.stringify(h)!==JSON.stringify(e);h=e,o&&(l("Bridge config updated from gateway",{enabled:e.enabled,tools:e.tools}),e.batch_interval_seconds*1e3!==v&&(v=e.batch_interval_seconds*1e3,d&&(clearInterval(d),d=setInterval(()=>{O().catch(()=>{})},v),l("Batch interval updated",{seconds:e.batch_interval_seconds}))))}l(`Flushed ${n.length} hook events to gateway`)}let f=null,J=null;function z(n,e){if(n.method!=="POST"||!n.url?.startsWith("/hook")){e.writeHead(404),e.end();return}const o=[];n.on("data",t=>o.push(t)),n.on("end",()=>{try{const t=JSON.parse(Buffer.concat(o).toString()),s=t.tool_name??"";if(!h.tools.includes(s)&&!C.has(s)){e.writeHead(200,{"Content-Type":"application/json"}),e.end('{"status":"skipped"}');return}if(B.has(s)&&!h.tools.includes(s)){e.writeHead(200,{"Content-Type":"application/json"}),e.end('{"status":"skipped"}');return}const r=t.tool_input??{},{category:a,risk:i}=N(s,r),c=F(s,r);P({action:`${s}: ${c}`.slice(0,500),category:a,risk_level:i,details:JSON.stringify({tool:s,...r}).slice(0,500)}),S(`Hook received: ${s}`,{category:a,risk:i}),e.writeHead(200,{"Content-Type":"application/json"}),e.end('{"status":"queued"}')}catch{e.writeHead(400),e.end('{"error":"invalid JSON"}')}})}let m=null,y=0;async function U(){try{const n=await I(u).catch(()=>null);if(!n||n.size<=y)return;const e=await w(u,"utf-8"),o=e.split(`
|
|
2
|
+
`).filter(Boolean),t=e.slice(y);y=n.size;const s=t.split(`
|
|
3
|
+
`).filter(Boolean);for(const r of s)try{const a=JSON.parse(r),i=a.tool_name??"";if(B.has(i)||!C.has(i)&&!h.tools.includes(i))continue;const c=a.tool_input??{},{category:g,risk:$}=N(i,c),x=F(i,c);P({action:`${i}: ${x}`.slice(0,500),category:g,risk_level:$,details:JSON.stringify({tool:i,...c}).slice(0,500)}),S(`File hook event: ${i}`,{category:g})}catch{}n.size>1048576&&(await _(u,"","utf-8"),y=0)}catch{}}async function D(n,e=W){T=n;const o=await new Promise((t,s)=>{f=A(z),f.on("error",r=>{r.code==="EADDRINUSE"?(l(`Port ${e} in use, trying ${e+1}`),f.close(),D(n,e+1).then(t).catch(s)):(p("HTTP hook server failed, using file-based hooks only",{error:r.message}),t(0))}),f.listen(e,"127.0.0.1",()=>{J=e,l(`Hook server listening on http://127.0.0.1:${e}/hook`),t(e)})});try{await L(u,""),m=R(u,()=>{U().catch(()=>{})}),l("File watcher started",{path:u})}catch(t){p("File watcher failed",{error:t instanceof Error?t.message:String(t)})}return d=setInterval(()=>{U().catch(()=>{}),O().catch(t=>{p("Batch flush failed",{error:t instanceof Error?t.message:String(t)})})},H),o}function Z(){d&&(clearInterval(d),d=null),O().catch(()=>{}),m&&(m.close(),m=null),f&&(f.close(),f=null),J=null}const q="/* aerostack-guardian-hook */",u="/tmp/aerostack-guardian-events.jsonl";async function ee(n){const e=b(E(),".claude","settings.json");try{let o={};try{const i=await w(e,"utf-8");o=JSON.parse(i)}catch{}const t=o.hooks??{},r=(t.PreToolUse??[]).filter(i=>!(i.hooks??[]).some(g=>g.url?.includes("127.0.0.1")&&g.url?.includes("/hook")||g.command?.includes("aerostack-guardian"))),a={matcher:"Bash|Write|Edit",hooks:[{type:"command",command:`cat >> ${u}`}]};return r.push(a),t.PreToolUse=r,o.hooks=t,await _(e,JSON.stringify(o,null,2)+`
|
|
4
|
+
`,"utf-8"),l("Installed Claude Code hook (file-based)",{eventsFile:u,path:e}),!0}catch(o){return p("Failed to install Claude Code hook",{error:o instanceof Error?o.message:String(o)}),!1}}async function te(){const n=b(E(),".claude","settings.json");try{const e=await w(n,"utf-8"),o=JSON.parse(e),t=o.hooks??{},s=t.PreToolUse??[],r=s.filter(a=>!(a.hooks??[]).some(c=>c.url?.includes("127.0.0.1")&&c.url?.includes("/hook")));return r.length===s.length?!1:(t.PreToolUse=r,o.hooks=t,await _(n,JSON.stringify(o,null,2)+`
|
|
5
|
+
`,"utf-8"),l("Uninstalled Claude Code hook"),!0)}catch{return!1}}export{u as HOOK_EVENTS_FILE,P as addToBatch,N as detectCategory,Y as getBridgeConfig,ee as installClaudeHook,D as startHookServer,Z as stopHookServer,F as summarizeToolInput,te as uninstallClaudeHook};
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import{Server as
|
|
3
|
+
import{Server as x}from"@modelcontextprotocol/sdk/server/index.js";import{StdioServerTransport as b}from"@modelcontextprotocol/sdk/server/stdio.js";import{ListToolsRequestSchema as j,CallToolRequestSchema as q,ListResourcesRequestSchema as D,ReadResourceRequestSchema as H,ListPromptsRequestSchema as M,GetPromptRequestSchema as V}from"@modelcontextprotocol/sdk/types.js";import{resolveApproval as y,startBackgroundResolver as k}from"./resolution.js";import{ApprovalStore as W}from"./approval-store.js";import{startHookServer as B,installClaudeHook as z,stopHookServer as G}from"./hook-server.js";import{OpenClawConnector as J,resolveOpenClawToken as Y,resolveExecApprovalToken as X}from"./openclaw-connector.js";import{startExecApprovalServer as F}from"./exec-approval-server.js";import{info as c,warn as C,error as Q}from"./logger.js";const T=process.env.AEROSTACK_WORKSPACE_URL,g=process.env.AEROSTACK_TOKEN;function v(t,r,s){const e=parseInt(t??String(r),10);return Number.isFinite(e)&&e>=s?e:r}const m=v(process.env.AEROSTACK_APPROVAL_POLL_MS,3e3,500),P=v(process.env.AEROSTACK_APPROVAL_TIMEOUT_MS,864e5,5e3),Z=v(process.env.AEROSTACK_REQUEST_TIMEOUT_MS,3e4,1e3),h=process.env.AEROSTACK_APPROVAL_MODE==="async"?"async":"blocking",ee=process.env.AEROSTACK_HOOK_SERVER!=="false",te=v(process.env.AEROSTACK_HOOK_PORT,18321,1024),re=process.env.AEROSTACK_HOOK_AUTO_INSTALL!=="false",se=process.env.AEROSTACK_OPENCLAW_ENABLED!=="false",N=v(process.env.AEROSTACK_OPENCLAW_PORT,18789,1024),oe=process.env.AEROSTACK_OPENCLAW_TOKEN;T||(process.stderr.write(`ERROR: AEROSTACK_WORKSPACE_URL is required
|
|
4
4
|
`),process.exit(1)),g||(process.stderr.write(`ERROR: AEROSTACK_TOKEN is required
|
|
5
|
-
`),process.exit(1));let
|
|
6
|
-
`),process.exit(1)}
|
|
7
|
-
`);const
|
|
8
|
-
`);let e=null;for(const n of
|
|
5
|
+
`),process.exit(1));let w;try{if(w=new URL(T),w.protocol!=="https:"&&w.protocol!=="http:")throw new Error("must be http or https")}catch{process.stderr.write(`ERROR: AEROSTACK_WORKSPACE_URL must be a valid HTTP(S) URL
|
|
6
|
+
`),process.exit(1)}w.protocol==="http:"&&!w.hostname.match(/^(localhost|127\.0\.0\.1)$/)&&process.stderr.write(`WARNING: Using HTTP (not HTTPS) \u2014 token will be sent in plaintext
|
|
7
|
+
`);const u=T.replace(/\/+$/,""),S=crypto.randomUUID(),ne=process.env.AEROSTACK_AGENT_TYPE||"unknown",l=new W;let A=null,L=null;async function p(t,r){const s={jsonrpc:"2.0",id:Date.now(),method:t,params:r??{}},e=new AbortController,n=setTimeout(()=>e.abort(),Z);try{const o=await fetch(u,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${g}`,"User-Agent":"aerostack-gateway/0.15.0","X-Agent-Id":"aerostack-gateway","X-Bridge-Id":S,"X-Agent-Type":ne},body:JSON.stringify(s),signal:e.signal});if(clearTimeout(n),(o.headers.get("content-type")??"").includes("text/event-stream")){const i=await o.text();return ae(i,s.id)}return await o.json()}catch(o){clearTimeout(n);const a=o instanceof Error?o.message:"Unknown error";return o instanceof Error&&o.name==="AbortError"?{jsonrpc:"2.0",id:s.id,error:{code:-32603,message:"Request timed out"}}:{jsonrpc:"2.0",id:s.id,error:{code:-32603,message:`HTTP error: ${a}`}}}}function ae(t,r){const s=t.split(`
|
|
8
|
+
`);let e=null;for(const n of s)if(n.startsWith("data: "))try{e=JSON.parse(n.slice(6))}catch{}return e??{jsonrpc:"2.0",id:r,error:{code:-32603,message:"Empty SSE response"}}}const ie=new Set(["aerostack__guardian_report","aerostack__check_approval","aerostack__guardian_check"]);function ce(t,r){if(ie.has(t))return;let s="other";const e=t.toLowerCase();e.includes("exec")||e.includes("bash")||e.includes("shell")||e.includes("command")||e.includes("run")?s="exec_command":e.includes("write")||e.includes("edit")||e.includes("create")||e.includes("patch")?s="file_write":e.includes("delete")||e.includes("remove")||e.includes("trash")||e.includes("unlink")?s="file_delete":e.includes("fetch")||e.includes("http")||e.includes("request")||e.includes("api")||e.includes("get")||e.includes("post")?s="api_call":e.includes("install")||e.includes("package")||e.includes("npm")||e.includes("pip")?s="package_install":e.includes("config")||e.includes("setting")||e.includes("env")?s="config_change":e.includes("deploy")||e.includes("publish")||e.includes("release")?s="deploy":e.includes("send")||e.includes("message")||e.includes("email")||e.includes("notify")||e.includes("slack")||e.includes("telegram")?s="message_send":(e.includes("read")||e.includes("query")||e.includes("search")||e.includes("list")||e.includes("get"))&&(s="data_access");let n;try{const o=JSON.stringify(r);n=o.length>500?o.slice(0,500)+"...":o}catch{n="(unable to serialize)"}p("tools/call",{name:"aerostack__guardian_report",arguments:{action:`${t}(${Object.keys(r).join(", ")})`,category:s,risk_level:"low",details:n}}).catch(()=>{})}function I(t,r){return fetch(`${u}/approval-delivery-status`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${g}`,"X-Bridge-Id":S},body:JSON.stringify({approvals:t.map(s=>({id:s,delivery_status:r,delivery_channel:"bridge_check_approval"}))})}).then(()=>{}).catch(()=>{})}const le=/^[a-zA-Z0-9_][a-zA-Z0-9_-]{0,63}$/;function U(t){return t&&le.test(t)?t:"aerostack__check_approval"}async function pe(t,r){if(ce(t,r),h==="async"&&t==="aerostack__check_approval"){const o=r.approval_id;if(o){const a=l.get(o);if(a&&a.status!=="pending")return l.markDelivered(o),I([o],"delivered"),{jsonrpc:"2.0",id:Date.now(),result:{content:[{type:"text",text:JSON.stringify({approval_id:o,status:a.status,reviewer_note:a.reviewerNote??null})}]}}}}const s=await p("tools/call",{name:t,arguments:r});if(s.error?.code===-32050){const o=s.error.data,a=o?.approval_id;if(!a||!/^[a-zA-Z0-9_-]{4,128}$/.test(a))return{jsonrpc:"2.0",id:s.id,error:{code:-32603,message:"Approval required but no approval_id returned"}};if(h==="async"){c("Tool gate (async): returning pending to LLM",{approvalId:a}),l.set(a,{approvalId:a,toolName:t,toolArgs:r,status:"pending",createdAt:Date.now()});const E=o?.polling_url??`${u}/approval-status/${a}`,O=k({approvalId:a,wsUrl:o?.ws_url,pollUrl:E,pollIntervalMs:m},l);l.setCancelHandle(a,O.cancel);const R=U(o?.check_tool);return{jsonrpc:"2.0",id:s.id,result:{content:[{type:"text",text:`APPROVAL REQUIRED \u2014 This action needs human approval.
|
|
9
|
+
Approval ID: ${a}
|
|
10
|
+
Status: pending
|
|
11
|
+
|
|
12
|
+
The workspace owner has been notified. To check approval status, call:
|
|
13
|
+
${R}({ "approval_id": "${a}" })
|
|
14
|
+
|
|
15
|
+
When status is "executed", retry the original tool call to get the result.
|
|
16
|
+
Do NOT proceed with the action until approved.`}]}}}c("Tool gate: waiting for approval",{approvalId:a,transport:o?.ws_url?"ws":"poll"});const d=o?.polling_url??`${u}/approval-status/${a}`,i=await y({approvalId:a,wsUrl:o?.ws_url,pollUrl:d,pollIntervalMs:m,timeoutMs:P});return i.status==="rejected"?{jsonrpc:"2.0",id:s.id,error:{code:-32603,message:`Tool call rejected: ${i.reviewer_note??"no reason given"}`}}:i.status==="changes_requested"?{jsonrpc:"2.0",id:s.id,error:{code:-32603,message:`Changes requested: ${i.reviewer_note??"no details given"}. Revise and resubmit.`}}:i.status==="expired"?{jsonrpc:"2.0",id:s.id,error:{code:-32603,message:"Approval request expired"}}:(c("Retrying tool call after approval",{approvalId:a,status:i.status}),p("tools/call",{name:t,arguments:r}))}const n=s.result?._meta;if(n?.approval_id&&n?.status==="pending"){const o=n.approval_id;if(!/^[a-zA-Z0-9_-]{4,128}$/.test(o))return s;if(h==="async"){c("Permission gate (async): returning pending to LLM",{approvalId:o}),l.set(o,{approvalId:o,toolName:t,toolArgs:r,status:"pending",createdAt:Date.now()});const E=n.polling_url??`${u}/approval-status/${o}`,O=k({approvalId:o,wsUrl:n.ws_url,pollUrl:E,pollIntervalMs:m},l);l.setCancelHandle(o,O.cancel);const R=U(n.check_tool);return{jsonrpc:"2.0",id:s.id,result:{content:[{type:"text",text:`PERMISSION PENDING \u2014 Your request requires human approval.
|
|
17
|
+
Approval ID: ${o}
|
|
18
|
+
Status: pending
|
|
19
|
+
|
|
20
|
+
Call ${R}({ "approval_id": "${o}" }) to check status.
|
|
21
|
+
You MUST NOT proceed with this action until approved.`}]}}}c("Permission gate: waiting for approval",{approvalId:o,transport:n.ws_url?"ws":"poll"});const a=n.polling_url??`${u}/approval-status/${o}`,d=await y({approvalId:o,wsUrl:n.ws_url,pollUrl:a,pollIntervalMs:m,timeoutMs:P});let i;return d.status==="approved"||d.status==="executed"?i="APPROVED \u2014 Your request has been approved. You may proceed with the action.":d.status==="rejected"?i=`REJECTED \u2014 Your request was denied. Reason: ${d.reviewer_note??"No reason given."}. Do NOT proceed.`:d.status==="changes_requested"?i=`CHANGES REQUESTED \u2014 ${d.reviewer_note??"No details given."}. Revise and resubmit your request.`:i="EXPIRED \u2014 Your approval request timed out. Submit a new request if needed.",{jsonrpc:"2.0",id:s.id,result:{content:[{type:"text",text:i}]}}}return s}let $=null;async function f(){if($)return;const t=await p("initialize",{protocolVersion:"2024-11-05",capabilities:{},clientInfo:{name:"aerostack-gateway",version:"0.15.0"}});if(t.result){const r=t.result;$={protocolVersion:r.protocolVersion??"2024-11-05",instructions:r.instructions}}}const _=new x({name:"aerostack-gateway",version:"0.15.0"},{capabilities:{tools:{},resources:{},prompts:{}}});_.setRequestHandler(j,async()=>{await f();const t=await p("tools/list");if(t.error)throw new Error(t.error.message);return{tools:t.result.tools??[]}}),_.setRequestHandler(q,async t=>{await f();const{name:r,arguments:s}=t.params,e=await pe(r,s??{});return e.error?{content:[{type:"text",text:`Error: ${e.error.message}`}],isError:!0}:{content:e.result.content??[{type:"text",text:JSON.stringify(e.result)}]}}),_.setRequestHandler(D,async()=>{await f();const t=await p("resources/list");if(t.error)throw new Error(t.error.message);return{resources:t.result.resources??[]}}),_.setRequestHandler(H,async t=>{await f();const r=await p("resources/read",{uri:t.params.uri});if(r.error)throw new Error(r.error.message);return{contents:r.result.contents??[]}}),_.setRequestHandler(M,async()=>{await f();const t=await p("prompts/list");if(t.error)throw new Error(t.error.message);return{prompts:t.result.prompts??[]}}),_.setRequestHandler(V,async t=>{await f();const r=await p("prompts/get",{name:t.params.name,arguments:t.params.arguments});if(r.error)throw new Error(r.error.message);return{messages:r.result.messages??[]}});async function ue(){try{const t=await fetch(`${u}/undelivered-approvals`,{headers:{Authorization:`Bearer ${g}`,"X-Bridge-Id":S}});if(!t.ok)return;const r=await t.json();if(!r.approvals?.length)return;for(const s of r.approvals)l.set(s.id,{approvalId:s.id,toolName:s.tool_name,toolArgs:{},status:s.status,reviewerNote:s.reviewer_note??void 0,resolvedAt:s.resolved_at??void 0,createdAt:s.resolved_at??Date.now()});c(`Loaded ${r.approvals.length} undelivered approvals from server`)}catch{}}async function de(){c("Connecting to workspace",{url:u});const t=new b;if(await _.connect(t),c("Ready",{url:u}),h==="async"&&ue().catch(()=>{}),ee)try{const s=await B(async e=>{try{const n=await fetch(`${u}/guardian-batch`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${g}`,"User-Agent":"aerostack-gateway/0.15.0","X-Agent-Id":"aerostack-gateway"},body:JSON.stringify({events:e})});return n.ok?(await n.json()).config?.hook_tracking??null:null}catch{return null}},te);re&&await z(s)&&c("Claude Code hook auto-installed",{port:s})}catch(r){C("Hook server failed to start (non-fatal)",{error:r instanceof Error?r.message:String(r)})}if(se)try{const r=oe??await Y();if(r)if(A=new J({port:N,token:r,rpcCall:p}),await A.connect()){c("OpenClaw connector started",{port:N});const e=await X();if(e){const{join:n}=await import("node:path"),{homedir:o}=await import("node:os");L=F({socketPath:n(o(),".openclaw","exec-approvals.sock"),token:e,rpcCall:p})}}else c("OpenClaw gateway not reachable, skipping connector"),A=null;else c("OpenClaw integration skipped (no token found)")}catch(r){C("OpenClaw connector failed (non-fatal)",{error:r instanceof Error?r.message:String(r)})}}async function K(){const t=l.getUndeliveredIds();t.length>0&&await I(t,"agent_disconnected"),A?.stop(),L?.stop(),l.destroy(),G(),process.exit(0)}process.on("SIGTERM",()=>{K()}),process.on("SIGINT",()=>{K()}),de().catch(t=>{Q("Fatal error",{error:t instanceof Error?t.message:String(t)}),process.exit(1)});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{readFile as h}from"node:fs/promises";import{join as d}from"node:path";import{homedir as u}from"node:os";import{info as f,warn as w,debug as a}from"./logger.js";import{addToBatch as y,detectCategory as g,summarizeToolInput as T}from"./hook-server.js";async function v(){try{const c=d(u(),".openclaw","openclaw.json"),e=await h(c,"utf-8");return JSON.parse(e)?.gateway?.auth?.token??null}catch{return null}}async function E(){try{const c=d(u(),".openclaw","exec-approvals.json"),e=await h(c,"utf-8");return JSON.parse(e)?.socket?.token??null}catch{return null}}const p=1e3,S=3e4;class b{opts;ws=null;destroyed=!1;reconnectMs=p;reconnectTimer=null;requestId=0;seenSessions=new Set;constructor(e){this.opts=e}async connect(){if(this.destroyed)return!1;const e=await this.getWebSocket(),t=`ws://127.0.0.1:${this.opts.port}`;return new Promise(s=>{try{const n=new e(t);this.ws=n;const o=setTimeout(()=>{a("OpenClaw connect timeout");try{n.close()}catch{}s(!1)},1e4);n.onopen=()=>{clearTimeout(o),a("OpenClaw WS connected, sending handshake"),this.sendHandshake()},n.onmessage=i=>{try{const r=JSON.parse(String(i.data));this.handleFrame(r,s)}catch{}},n.onerror=()=>{clearTimeout(o),s(!1)},n.onclose=()=>{clearTimeout(o),this.ws=null,this.destroyed||this.scheduleReconnect()}}catch{s(!1)}})}stop(){if(this.destroyed=!0,this.reconnectTimer&&(clearTimeout(this.reconnectTimer),this.reconnectTimer=null),this.ws){try{this.ws.close(1e3)}catch{}this.ws=null}}sendHandshake(){this.send({type:"req",id:String(++this.requestId),method:"connect",params:{minProtocol:3,maxProtocol:3,client:{id:"aerostack-bridge",displayName:"Aerostack Guardian",version:"0.15.0",platform:process.platform,mode:"operator"},auth:{token:this.opts.token}}})}connected=!1;handleFrame(e,t){if(e.type==="res"&&!this.connected){e.ok?(this.connected=!0,this.reconnectMs=p,f("OpenClaw connector connected",{port:this.opts.port}),t?.(!0)):(w("OpenClaw connect rejected",{error:e.error?.message}),t?.(!1));return}e.type==="event"&&this.handleEvent(e)}handleEvent(e){const t=e.event,s=e.payload??{};t==="session.tool"?this.handleToolEvent(s):t==="session.message"?this.handleMessageEvent(s):t==="sessions.changed"&&this.handleSessionChanged(s)}handleToolEvent(e){const t=e.data;if(!t)return;const s=t.toolName??"unknown",n=t.phase??"",o=t.input??{},i=e.sessionKey??"";if(n!=="start"&&n!=="end"||n==="end")return;const{category:r,risk:l}=g(s,o),m=T(s,o);y({action:`${s}: ${m}`.slice(0,500),category:r,risk_level:l,details:JSON.stringify({tool:s,session:i,...o}).slice(0,500)}),a("OpenClaw tool event",{tool:s,category:r,risk:l,session:i}),i&&this.seenSessions.add(i)}handleMessageEvent(e){const t=e.sessionKey??"";t&&this.seenSessions.add(t)}handleSessionChanged(e){const t=e.sessionKey??"";t&&this.seenSessions.add(t)}send(e){if(this.ws)try{this.ws.send(JSON.stringify(e))}catch{}}scheduleReconnect(){this.destroyed||this.reconnectTimer||(a("OpenClaw reconnecting in",{ms:this.reconnectMs}),this.reconnectTimer=setTimeout(async()=>{this.reconnectTimer=null,this.connected=!1,this.seenSessions.clear(),await this.connect()},this.reconnectMs),this.reconnectMs=Math.min(this.reconnectMs*2,S))}async getWebSocket(){return typeof globalThis.WebSocket<"u"?globalThis.WebSocket:(await import("ws")).default}}export{b as OpenClawConnector,E as resolveExecApprovalToken,v as resolveOpenClawToken};
|
package/dist/resolution.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{info as
|
|
1
|
+
import{info as v,warn as b,debug as y}from"./logger.js";function J(e,n){const{approvalId:r}=e;let o=!1,a=null,g=null,w=null;const c=()=>{if(o=!0,g&&(clearInterval(g),g=null),w&&(clearTimeout(w),w=null),a){try{a.close(1e3)}catch{}a=null}},p=S=>{o||(n.resolve(r,S.status,S.reviewer_note),v("Background resolver: approval resolved",{approvalId:r,status:S.status}),c())};return(async()=>{if(e.wsUrl)try{const u=await x(),d=new u(e.wsUrl);a=d,d.onmessage=s=>{try{const h=typeof s.data=="string"?JSON.parse(s.data):JSON.parse(String(s.data)),f=h?.status;(f==="executed"||f==="approved"||f==="rejected"||f==="expired"||f==="changes_requested")&&p({status:f,reviewer_note:h?.reviewer_note})}catch{}},d.onerror=()=>{y("Background resolver WS error",{approvalId:r})},d.onclose=()=>{a=null},typeof d.on=="function"&&d.on("unexpected-response",async(s,h)=>{try{const f=[];for await(const l of h)f.push(l);const t=Buffer.concat(f).toString(),i=JSON.parse(t);i?.status&&i.status!=="pending"&&p({status:i.status,reviewer_note:i?.reviewer_note})}catch{}})}catch{y("Background resolver WS connect failed, using polling only",{approvalId:r})}g=setInterval(async()=>{if(!o)try{const u=await _(e.pollUrl);u&&p(u)}catch{}},3e4);try{const u=await _(e.pollUrl);if(u){p(u);return}}catch{}const m=e.timeoutMs??2*60*60*1e3;w=setTimeout(()=>{o||(n.resolve(r,"expired"),c())},m)})().catch(()=>{}),{cancel:c}}async function N(e){const{approvalId:n,wsUrl:r,pollUrl:o,pollIntervalMs:a,timeoutMs:g}=e,w=Date.now()+g;if(r)try{return await L(n,r,o,w)}catch(c){const p=c instanceof Error?c.message:"Unknown WS error";b("WebSocket resolution failed, falling back to polling",{approvalId:n,error:p})}return A(n,o,a,w)}async function L(e,n,r,o){const a=await x();return new Promise((g,w)=>{let c=!1,p=null,S=null;const m=()=>{c=!0,p&&clearInterval(p),S&&clearTimeout(S)},u=t=>{c||(m(),g(t))},d=t=>{c||(m(),w(t))};y("Connecting WebSocket",{approvalId:e,wsUrl:n});const s=new a(n);s.onopen=()=>{v("WebSocket connected, waiting for resolution",{approvalId:e})},s.onmessage=t=>{try{const i=typeof t.data=="string"?JSON.parse(t.data):JSON.parse(String(t.data)),l=i?.status;if(y("WebSocket message received",{approvalId:e,status:l}),l==="executed"||l==="approved"||l==="rejected"||l==="expired"||l==="changes_requested"){v("Approval resolved via WebSocket",{approvalId:e,status:l}),u({status:l,reviewer_note:i?.reviewer_note});try{s.close(1e3)}catch{}}}catch{b("Failed to parse WebSocket message",{approvalId:e})}},s.onerror=t=>{const i=t instanceof Error?t.message:"WebSocket error";y("WebSocket error",{approvalId:e,error:i}),d(new Error(`WebSocket error: ${i}`))},s.onclose=t=>{c||(y("WebSocket closed without resolution",{approvalId:e,code:t.code}),d(new Error(`WebSocket closed unexpectedly (code ${t.code})`)))},typeof s.on=="function"&&s.on("unexpected-response",async(t,i)=>{try{const l=[];for await(const T of i)l.push(T);const O=Buffer.concat(l).toString(),W=JSON.parse(O),k=W?.status;if(k&&k!=="pending"){v("Approval already resolved (WS endpoint returned JSON)",{approvalId:e,status:k}),u({status:k,reviewer_note:W?.reviewer_note});return}}catch{}d(new Error("WebSocket upgrade rejected by server"))}),p=setInterval(async()=>{if(!c)try{const t=await _(r);if(t){v("Approval resolved via safety-net poll",{approvalId:e,status:t.status}),u(t);try{s.close(1e3)}catch{}}}catch{}},3e4);const f=o-Date.now();if(f<=0){d(new Error("Approval timeout"));return}S=setTimeout(()=>{if(!c){b("Approval timed out",{approvalId:e}),u({status:"expired"});try{s.close(1e3)}catch{}}},f)})}async function A(e,n,r,o){for(v("Polling for approval resolution",{approvalId:e,intervalMs:r});Date.now()<o;){await P(r);try{const a=await _(n);if(a)return v("Approval resolved via polling",{approvalId:e,status:a.status}),a}catch{}}return b("Approval polling timed out",{approvalId:e}),{status:"expired"}}async function _(e){const n=await fetch(e,{headers:{"User-Agent":"aerostack-gateway/0.14.0"}});if(!n.ok)return null;const r=await n.json(),o=r.status;return o==="executed"||o==="approved"?{status:o,reviewer_note:r.reviewer_note}:o==="rejected"?{status:"rejected",reviewer_note:r.reviewer_note}:o==="changes_requested"?{status:"changes_requested",reviewer_note:r.reviewer_note}:o==="expired"?{status:"expired"}:null}async function x(){if(typeof globalThis.WebSocket<"u")return globalThis.WebSocket;try{return(await import("ws")).default}catch{throw new Error('WebSocket not available. Install the "ws" package: npm install ws')}}function P(e){return new Promise(n=>setTimeout(n,e))}export{N as resolveApproval,J as startBackgroundResolver};
|