@solongate/proxy 0.83.56 → 0.83.58
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/commands/index.js +154 -71
- package/dist/global-install.d.ts +28 -0
- package/dist/global-install.js +349 -130
- package/dist/hook-health.d.ts +26 -0
- package/dist/hook-launcher.d.ts +57 -0
- package/dist/index.js +511 -252
- package/dist/tui/index.js +422 -167
- package/hooks/conversation.mjs +85 -30
- package/hooks/opencode-plugin.mjs +94 -1
- package/package.json +7 -7
package/hooks/conversation.mjs
CHANGED
|
@@ -43,7 +43,7 @@ import { resolve } from 'node:path';
|
|
|
43
43
|
import { homedir } from 'node:os';
|
|
44
44
|
|
|
45
45
|
// Bump on every change to this file, alongside the other hooks.
|
|
46
|
-
const HOOK_VERSION =
|
|
46
|
+
const HOOK_VERSION = 2;
|
|
47
47
|
|
|
48
48
|
const AGENT_ID = (process.env.SOLONGATE_AGENT_ID || process.argv[2] || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
49
49
|
|
|
@@ -121,6 +121,59 @@ function readStdin() {
|
|
|
121
121
|
try { return readFileSync(0, 'utf-8'); } catch { return ''; }
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
// send is one turn, redacted, to the API. Both payload shapes end here.
|
|
125
|
+
async function send(sessionId, role, body, agentName) {
|
|
126
|
+
if (!sessionId || !String(body).trim()) return;
|
|
127
|
+
|
|
128
|
+
const cfg = loadGlobalCloudConfig();
|
|
129
|
+
const apiKey = process.env.SOLONGATE_API_KEY || cfg.apiKey || '';
|
|
130
|
+
const apiUrl = process.env.SOLONGATE_API_URL || cfg.apiUrl || 'https://api.solongate.com';
|
|
131
|
+
if (!apiKey) return;
|
|
132
|
+
|
|
133
|
+
const cache = loadPolicyCache();
|
|
134
|
+
if (localLogsOn(cache)) return;
|
|
135
|
+
|
|
136
|
+
const cleaned = redact(body, cache);
|
|
137
|
+
try {
|
|
138
|
+
await fetch(`${apiUrl}/api/v1/conversations`, {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
headers: {
|
|
141
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
142
|
+
'Content-Type': 'application/json',
|
|
143
|
+
},
|
|
144
|
+
body: JSON.stringify({
|
|
145
|
+
session_id: sessionId,
|
|
146
|
+
role,
|
|
147
|
+
body: cleaned.text,
|
|
148
|
+
redacted: cleaned.masked,
|
|
149
|
+
agent_id: AGENT_ID,
|
|
150
|
+
agent_name: agentName || '',
|
|
151
|
+
source: `${AGENT_ID}-hook`,
|
|
152
|
+
hook_version: HOOK_VERSION,
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
155
|
+
} catch { /* silent: a record that could not be sent must not fail a turn */ }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// sendAntigravity writes both halves of the exchange from one Stop payload.
|
|
159
|
+
//
|
|
160
|
+
// The prompt goes first and the reply second, and they are sent in that order
|
|
161
|
+
// rather than concurrently: the rows are ordered by their timestamps, and two
|
|
162
|
+
// requests in flight at once can land in the other order on a fast link — which
|
|
163
|
+
// would draw the answer above the question.
|
|
164
|
+
async function sendAntigravity(common, stop) {
|
|
165
|
+
const sessionId = common.conversationId || common.conversation_id ||
|
|
166
|
+
common.sessionId || common.session_id || '';
|
|
167
|
+
if (!sessionId) return;
|
|
168
|
+
const name = common.agentName || common.agent_name || 'Antigravity';
|
|
169
|
+
|
|
170
|
+
const prompt = common.lastUserInput || common.last_user_input || '';
|
|
171
|
+
if (String(prompt).trim()) await send(sessionId, 'prompt', prompt, name);
|
|
172
|
+
|
|
173
|
+
const reply = stop ? (stop.finalModelOutput || stop.final_model_output || '') : '';
|
|
174
|
+
if (String(reply).trim()) await send(sessionId, 'reply', reply, name);
|
|
175
|
+
}
|
|
176
|
+
|
|
124
177
|
(async () => {
|
|
125
178
|
// Whatever happens below, this hook allows the turn. It has no opinion about
|
|
126
179
|
// whether the work should proceed; it is a record of it.
|
|
@@ -130,6 +183,36 @@ function readStdin() {
|
|
|
130
183
|
try { data = JSON.parse(readStdin() || '{}'); } catch { return; }
|
|
131
184
|
|
|
132
185
|
const event = data.hook_event_name || data.hookEventName || '';
|
|
186
|
+
|
|
187
|
+
// ANTIGRAVITY sends a different shape, and one hook carries both halves.
|
|
188
|
+
//
|
|
189
|
+
// Its payload is `{common: {lastUserInput, conversationId, ...}, args: {...}}`
|
|
190
|
+
// — the person's last message rides on EVERY hook rather than on an event of
|
|
191
|
+
// its own, and the answer arrives as `stopHookArgs.finalModelOutput`. So one
|
|
192
|
+
// Stop registration records the whole exchange, where Claude Code and Codex
|
|
193
|
+
// need two events to do the same thing.
|
|
194
|
+
//
|
|
195
|
+
// It is detected by shape rather than by a flag, because the client name is
|
|
196
|
+
// an argv argument this hook is also given for the other three, and a payload
|
|
197
|
+
// is a fact while an argument is a claim.
|
|
198
|
+
// OPENCODE calls this hook directly from its plugin, which already knows
|
|
199
|
+
// which half it holds — its two halves arrive by different routes (a
|
|
200
|
+
// chat.message hook for the prompt, streamed text parts for the reply), so
|
|
201
|
+
// the plugin does the deciding and this file does the sending.
|
|
202
|
+
if (data.opencode === true) {
|
|
203
|
+
await send(data.session_id || '', data.role === 'reply' ? 'reply' : 'prompt',
|
|
204
|
+
data.body || '', 'OpenCode');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const agCommon = data.common || (data.hookArgs && data.hookArgs.common) || null;
|
|
209
|
+
const agStop = data.stopHookArgs || (data.args && data.args.stopHookArgs) ||
|
|
210
|
+
(data.hookArgs && data.hookArgs.stopHookArgs) || null;
|
|
211
|
+
if (agCommon && (agStop || agCommon.lastUserInput)) {
|
|
212
|
+
await sendAntigravity(agCommon, agStop);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
133
216
|
// The two halves. Nothing else is listened for: PreToolUse and PostToolUse
|
|
134
217
|
// already have hooks and are about tool calls rather than about words.
|
|
135
218
|
let role = '';
|
|
@@ -151,33 +234,5 @@ function readStdin() {
|
|
|
151
234
|
const sessionId = data.session_id || data.sessionId || data.conversation_id || '';
|
|
152
235
|
if (!sessionId) return;
|
|
153
236
|
|
|
154
|
-
|
|
155
|
-
const apiKey = process.env.SOLONGATE_API_KEY || cfg.apiKey || '';
|
|
156
|
-
const apiUrl = process.env.SOLONGATE_API_URL || cfg.apiUrl || 'https://api.solongate.com';
|
|
157
|
-
if (!apiKey) return;
|
|
158
|
-
|
|
159
|
-
const cache = loadPolicyCache();
|
|
160
|
-
if (localLogsOn(cache)) return;
|
|
161
|
-
|
|
162
|
-
const cleaned = redact(body, cache);
|
|
163
|
-
|
|
164
|
-
try {
|
|
165
|
-
await fetch(`${apiUrl}/api/v1/conversations`, {
|
|
166
|
-
method: 'POST',
|
|
167
|
-
headers: {
|
|
168
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
169
|
-
'Content-Type': 'application/json',
|
|
170
|
-
},
|
|
171
|
-
body: JSON.stringify({
|
|
172
|
-
session_id: sessionId,
|
|
173
|
-
role,
|
|
174
|
-
body: cleaned.text,
|
|
175
|
-
redacted: cleaned.masked,
|
|
176
|
-
agent_id: AGENT_ID,
|
|
177
|
-
agent_name: data.agent_name || '',
|
|
178
|
-
source: `${AGENT_ID}-hook`,
|
|
179
|
-
hook_version: HOOK_VERSION,
|
|
180
|
-
}),
|
|
181
|
-
});
|
|
182
|
-
} catch { /* silent: a record that could not be sent must not fail a turn */ }
|
|
237
|
+
await send(sessionId, role, body, data.agent_name || '');
|
|
183
238
|
})();
|
|
@@ -36,6 +36,7 @@ import { join } from 'node:path';
|
|
|
36
36
|
const HOOKS_DIR = join(homedir(), '.solongate', 'hooks');
|
|
37
37
|
const GUARD = join(HOOKS_DIR, 'guard.mjs');
|
|
38
38
|
const AUDIT = join(HOOKS_DIR, 'audit.mjs');
|
|
39
|
+
const CONVERSATION = join(HOOKS_DIR, 'conversation.mjs');
|
|
39
40
|
|
|
40
41
|
// The node binary, written in by the installer.
|
|
41
42
|
//
|
|
@@ -48,6 +49,25 @@ const AUDIT = join(HOOKS_DIR, 'audit.mjs');
|
|
|
48
49
|
const NODE_BAKED = '__SOLONGATE_NODE__';
|
|
49
50
|
const NODE = NODE_BAKED.startsWith('__SOLONGATE') || !existsSync(NODE_BAKED) ? 'node' : NODE_BAKED;
|
|
50
51
|
|
|
52
|
+
// On POSIX the spawn goes through the shared launcher instead, which resolves
|
|
53
|
+
// node at RUN time and leaves the beat every other client leaves.
|
|
54
|
+
//
|
|
55
|
+
// The baked path above is `process.execPath` from the install, and on a Mac
|
|
56
|
+
// that is a Homebrew Cellar path or an nvm version directory — both deleted by
|
|
57
|
+
// a routine upgrade. The `'node'` fallback then needs PATH to have one, which
|
|
58
|
+
// is exactly the assumption that does not hold inside a hook environment. The
|
|
59
|
+
// launcher tries the baked path first, then PATH, then everywhere a Mac keeps a
|
|
60
|
+
// node.
|
|
61
|
+
const LAUNCHER = join(HOOKS_DIR, 'sg-run.sh');
|
|
62
|
+
const USE_LAUNCHER = process.platform !== 'win32' && existsSync(LAUNCHER);
|
|
63
|
+
|
|
64
|
+
/** The argv for one hook run: through the launcher when there is one. */
|
|
65
|
+
function spawnArgs(script) {
|
|
66
|
+
return USE_LAUNCHER
|
|
67
|
+
? ['/bin/sh', [LAUNCHER, script, 'opencode', 'OpenCode']]
|
|
68
|
+
: [NODE, [script, 'opencode', 'OpenCode']];
|
|
69
|
+
}
|
|
70
|
+
|
|
51
71
|
// The guard carries its own 8s backstop; this is the outer bound for the whole
|
|
52
72
|
// round trip so a wedged process can never hang the editor.
|
|
53
73
|
const GUARD_TIMEOUT_MS = 15000;
|
|
@@ -63,7 +83,8 @@ function runHook(script, payload, timeoutMs) {
|
|
|
63
83
|
const finish = (v) => { if (!done) { done = true; resolve(v); } };
|
|
64
84
|
let child;
|
|
65
85
|
try {
|
|
66
|
-
|
|
86
|
+
const [bin, argv] = spawnArgs(script);
|
|
87
|
+
child = spawn(bin, argv, {
|
|
67
88
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
68
89
|
windowsHide: true,
|
|
69
90
|
});
|
|
@@ -104,6 +125,26 @@ function rewritePatch(res) {
|
|
|
104
125
|
return null;
|
|
105
126
|
}
|
|
106
127
|
|
|
128
|
+
// The assistant text held between the part events and the message completing.
|
|
129
|
+
//
|
|
130
|
+
// Keyed by message id, cleared on flush, and dropped whole past a bound: a
|
|
131
|
+
// plugin that grows a buffer for the life of an editor session is a leak in
|
|
132
|
+
// somebody's editor, which is a worse bug than a missing transcript.
|
|
133
|
+
const pending = new Map();
|
|
134
|
+
const MAX_PENDING = 64;
|
|
135
|
+
|
|
136
|
+
// textOf joins the text parts of a message and ignores the rest — a file
|
|
137
|
+
// attachment or a tool call is not something a person said. A synthetic part
|
|
138
|
+
// is one OpenCode wrote itself, which is not either.
|
|
139
|
+
function textOf(parts) {
|
|
140
|
+
if (!Array.isArray(parts)) return '';
|
|
141
|
+
return parts
|
|
142
|
+
.filter((x) => x && x.type === 'text' && typeof x.text === 'string' && !x.synthetic)
|
|
143
|
+
.map((x) => x.text)
|
|
144
|
+
.join('\n')
|
|
145
|
+
.trim();
|
|
146
|
+
}
|
|
147
|
+
|
|
107
148
|
export const SolonGate = async ({ directory, worktree } = {}) => {
|
|
108
149
|
const cwd = directory || worktree || process.cwd();
|
|
109
150
|
|
|
@@ -135,6 +176,58 @@ export const SolonGate = async ({ directory, worktree } = {}) => {
|
|
|
135
176
|
}
|
|
136
177
|
},
|
|
137
178
|
|
|
179
|
+
// ── the conversation record ──────────────────────────────────────────
|
|
180
|
+
//
|
|
181
|
+
// OpenCode is the third client to record this and the only one that needs
|
|
182
|
+
// two different hooks to do it, because its halves arrive by different
|
|
183
|
+
// routes: `chat.message` carries what the person wrote, and the answer is
|
|
184
|
+
// streamed as text PARTS which are only final when the assistant message
|
|
185
|
+
// is.
|
|
186
|
+
//
|
|
187
|
+
// So the parts are held per message id and flushed when that message
|
|
188
|
+
// completes.
|
|
189
|
+
'chat.message': async (input, output) => {
|
|
190
|
+
const text = textOf(output?.parts);
|
|
191
|
+
if (!text) return;
|
|
192
|
+
void runHook(CONVERSATION, {
|
|
193
|
+
opencode: true,
|
|
194
|
+
role: 'prompt',
|
|
195
|
+
body: text,
|
|
196
|
+
session_id: input?.sessionID ?? output?.message?.sessionID ?? '',
|
|
197
|
+
}, GUARD_TIMEOUT_MS);
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
event: async ({ event } = {}) => {
|
|
201
|
+
if (!event || typeof event !== 'object') return;
|
|
202
|
+
|
|
203
|
+
// A text part of a message. `text` is the WHOLE text so far rather than a
|
|
204
|
+
// delta, so the last one wins and nothing is concatenated.
|
|
205
|
+
if (event.type === 'message.part.updated') {
|
|
206
|
+
const part = event.properties?.part;
|
|
207
|
+
if (part?.type === 'text' && part.messageID && typeof part.text === 'string') {
|
|
208
|
+
if (pending.size > MAX_PENDING) pending.clear();
|
|
209
|
+
pending.set(part.messageID, { text: part.text, sessionID: part.sessionID ?? '' });
|
|
210
|
+
}
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The message finished. Anything else — a user message, one still being
|
|
215
|
+
// written — is not a completed answer and is left alone.
|
|
216
|
+
if (event.type === 'message.updated') {
|
|
217
|
+
const info = event.properties?.info;
|
|
218
|
+
if (!info || info.role !== 'assistant' || !info.time?.completed) return;
|
|
219
|
+
const held = pending.get(info.id);
|
|
220
|
+
pending.delete(info.id);
|
|
221
|
+
if (!held || !held.text.trim()) return;
|
|
222
|
+
void runHook(CONVERSATION, {
|
|
223
|
+
opencode: true,
|
|
224
|
+
role: 'reply',
|
|
225
|
+
body: held.text,
|
|
226
|
+
session_id: info.sessionID || held.sessionID || '',
|
|
227
|
+
}, GUARD_TIMEOUT_MS);
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
|
|
138
231
|
'tool.execute.after': async (input, output) => {
|
|
139
232
|
// The ALLOW-path audit record. Fire and forget: it must never delay the
|
|
140
233
|
// editor, and losing one line is better than a stalled tool.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solongate/proxy",
|
|
3
|
-
"version": "0.83.
|
|
3
|
+
"version": "0.83.58",
|
|
4
4
|
"description": "AI tool security proxy: protect any AI tool server with customizable policies, path/command constraints, rate limiting, and audit logging. No code changes required.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -61,12 +61,12 @@
|
|
|
61
61
|
"node": ">=20.0.0"
|
|
62
62
|
},
|
|
63
63
|
"optionalDependencies": {
|
|
64
|
-
"@solongate/guard-linux-x64": "0.83.
|
|
65
|
-
"@solongate/guard-linux-arm64": "0.83.
|
|
66
|
-
"@solongate/guard-darwin-x64": "0.83.
|
|
67
|
-
"@solongate/guard-darwin-arm64": "0.83.
|
|
68
|
-
"@solongate/guard-win32-x64": "0.83.
|
|
69
|
-
"@solongate/guard-win32-arm64": "0.83.
|
|
64
|
+
"@solongate/guard-linux-x64": "0.83.58",
|
|
65
|
+
"@solongate/guard-linux-arm64": "0.83.58",
|
|
66
|
+
"@solongate/guard-darwin-x64": "0.83.58",
|
|
67
|
+
"@solongate/guard-darwin-arm64": "0.83.58",
|
|
68
|
+
"@solongate/guard-win32-x64": "0.83.58",
|
|
69
|
+
"@solongate/guard-win32-arm64": "0.83.58"
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
72
|
"@modelcontextprotocol/sdk": "^1.26.0",
|